diff --git a/1-docs/0-mng/agent-recommendations-2026-10-18-2.md b/1-docs/0-mng/agent-recommendations-2026-10-18-2.md new file mode 100644 index 0000000..da08f4b --- /dev/null +++ b/1-docs/0-mng/agent-recommendations-2026-10-18-2.md @@ -0,0 +1,183 @@ +# Рекомендации агенту — этап 7, волна 2 (итоги ревью 2026-10-18) + +> **Получатель:** кодирующий агент, выполняющий этап 7 «Итерационная +> разработка» (текущий модуль — m05-progression-rules, реализация +> завершена, шлюз 3.4 — у владельца; далее m06+ по +> [plan.md](plan.md)). **Источник:** повторная архитектурная сверка +> артифактов этапа 7 с документацией (requirements 1.4, +> architecture 1.2, tech-requirements 1.4, 2-db, module_reqs/api_specs +> m01–m05), 2026-10-18. Дополнение к волне 1 — +> [agent-recommendations-2026-10-18.md](agent-recommendations-2026-10-18.md) +> (З-35…З-40). Базовые файлы стандарта `std1\` и библиотека +> `Kit.Helpers` не править — все исправления только в проектных файлах +> этого репозитория. **Статусная модель заданий:** ⬜ не начато → +> 🔄 в работе → ✅ готово (утверждено владельцем). Нумерация З-41+ +> продолжает З-40. + +## 0. Итог сверки (что подтверждено — не перепроверять) + +- Волна 1 исполнена и подтверждена: **З-35** — полностью (миграция 003, + `program_insert` rev. 2 с DROP прежней сигнатуры, spec, домен + `Program.ExternalId` + `ProgramRepository.Insert`, psql-кейс UNIQUE + + Core-тест `Insert_2СExternalId`, errata в readme m01 и plan.md); + **З-40** — все 4 пункта; **З-37 п. 4** — диапазоны 1–20 / 3–12 в коде; + **З-38** — статусы m05 выставлены корректно (1.3/2.4/3 ✅, + 3.4/4 ⬜ — у владельца). +- Сборка решения — 0 ошибок / 0 предупреждений; прогон 2026-10-18 на + docker `watchbody-pg`: Core.Tests **56/56**, Web.Tests **80/80** + (Integration 13 + Unit 12 + ProgramStore 19 + MetricsEngine 17 + + ProgressionRules 19). +- 38 psql-функций ↔ 38 спецификаций ↔ 38 psql-тестов; 13 репозиториев + в 8 файлах; все функции потребляются, параметры передаются поимённо + и совпадают с сигнатурами. +- Инварианты волны 1 подтверждены повторно: изоляция `p_athlete_id`, + идемпотентность по `p_id`, переходы `draft → active → archived`, + атомарность пакета, ProgramStore — 7 методов, `author=llm` + отклоняется, `/api/admin/*` отсутствует (m08 не начат), ключи — + SHA-256 hex lowercase. +- Правила m05: приоритет 1–5, делоад ×0.6/−1 подход, таблица возврата + 1.0/0.9/0.85/0.75/`ReturnMinFactor`, правило 17 → `test`, + дедупликация rules-черновиков, фильтр ограничений — соответствуют + module_reqs m05. +- Умолчания `MetricsConfig`/`ProgressionConfig` ↔ `appsettings.json` + (Web и Web.Tests) совпадают полностью; мёртвых ключей + `RestingHrSitting`/`HrStrengthMax`/`LightWeekHrRise` в серверных + конфигах нет (Р3-А соблюдён). + +## 1. Задание P1 + +### З-41. Замкнуть контур `is_active` в sync (Ф-4.2) ⬜ (решение владельца) + +Контракт передаёт `is_active` у `exercise` (tech-requirements 2.2, +m02 `module_reqs.md:79`), `SyncService.cs:184` значение парсит — и +теряет: `src.exercise_insert`/`exercise_update` параметр `p_is_active` +не принимают (дизайн m01 — флаг меняет только `src.exercise_delete`, +З-34), а `ExerciseRepository.Delete` из sync не вызывается ни при +каком входе. Итог: в v1 не существует ни одного рабочего пути +установить `is_active=false` на сервере, при этом m05 строит на флаге +фильтр ограничений (исключённые упражнения, +`api_specs\02-micro-adjust.md`). + +- **Рекомендация (а):** в `SyncService` (case `exercise`) при + `is_active=false` вызывать `exercises.Delete(athleteId, + item.ClientGuid)` (→ `src.exercise_delete`; идемпотентно: 0 строк — + не ошибка), при `true`/NULL — обычный Insert. GUID включать в + `accepted` в обоих случаях (Ф-7.4). Единственный вариант, закрывающий + серверный контур Ф-4.2 в v1. Правки: `SyncService.cs`; Integration-тест + в Web.Tests (упражнение `is_active=true` → повтор пакета с + `is_active=false` → в БД `false`, оба ответа `accepted`); m02 + `module_reqs` + `api_specs 06` — семантика «повторная отправка + упражнения с `is_active=false` — логическое удаление»; tech-req 2.2 — + примечание; errata в readme m02 (правка закрытого модуля — практика + m01/m04); readme m05 п. 1 — дополнить. БД не меняется — прогон psql + не требуется. +- **Альтернатива (б):** убрать `is_active` из контракта (m02 + `module_reqs:79`, tech-req 2.2), `SyncService` перестаёт парсить + поле. Не рекомендуется: серверный контур Ф-4.2 в v1 не закрывается — + ограничения атлета (исключения для правил) недостижимы до m08. + +## 2. Задания P2 + +### З-42. Документационная синхронизация код ↔ спецификации ⬜ (сразу агентом, без шлюза) + +1. m02 `api_specs\06-dto.md` — классы `SyncRequest` и `RecordData*` + (стр. «Модели») не существуют: корень запроса — сырой JSON-массив, + разбор `data` — `SyncItemParser`/`JsonElement`. Привести раздел к + фактической реализации (поведение то же — описаны только имена + моделей). +2. Имя SQL-компонента Kit: `kit-helpers-spec.md` (таблица), tech-req + разд. 3, `2-db\readme.md` («Общие сведения») — заменить + `Db.Postgres.SqlServerHelper` на фактический + `Kit.Helpers.SqlHelperAbstract` (`SqlHelperPostgres`/ + `SqlHelperFluent` — весь DAL и тесты). `JsonHelper` пометить «в v1 + не потребляется» (ProgramStore/MetricsEngine — System.Text.Json + напрямую); `Log.Logger` уже помечен (З-40.3). Строку «Сборка» + (`build.ps1` / `Server.WatchBody.Import`) в `2-db\readme.md` не + трогать — закрывается З-36 волны 1. +3. m05 `api_specs\05-sync-wiring.md` — конструкторы по факту: + `ProgressionRules(IWorkoutRepository, IExerciseRepository, + ProgramStore, ProgressionConfig)` и `BreakEngine(IWorkoutRepository, + IBreakRepository, ProgramStore, ProgressionConfig)`; сниппет + `CheckDeload(athleteId)` → `CheckDeload(athleteId, today)` + (согласовать со spec 03 и `SyncService`). +4. m05 `api_specs\01` — диапазоны валидации синхронизировать с кодом + (след З-37 п. 4): `backoffPercent` 1–20 (не «(0; 50]»), + `deloadMaxWeeks` 3–12 (не «<1»). +5. m05 `api_specs\03` + readme m05 (примечания) — зафиксировать опорную + точку делаода в v1: только факт-тренировка с + `session_kind=deload`, иначе `CreatedAt` активной версии; вариант + «rules-draft с deload в week 1» не реализован (кандидат — m08). + Включить в дифф владельцу. +6. m04 `api_specs\02`/`03` — примечания (поведение осознанное, тесты + зелёные): окно выборки `daily_log` расширяется на `lightDayCount` + (поиск старта серии, `MetricsEngine.cs:42`); серия в + `ConditionRun` ограничена `lightDayCount` (на флаги 2/3 не влияет); + `knee_pain_repeat` — предыдущие тренировки строго до дня текущей + (`DateTo = дата − 1`). +7. Числа тестов: readme m05 — «Core 55/55» → **56/56** (plan.md:42 уже + 56; правило «readme и plan той же правкой»); в readme m02/m03/m04 к + «Core 55» добавить «(на момент закрытия модуля; после З-35 — 56)». +8. Код, без шлюза (как З-37 п. 4): `ApiKeyMiddleware` — путь `/health` + пропускать только для GET (сейчас — любой метод; деградирует в + 404/405, не эксплойт). Проверить Unit-тесты middleware; прогон + Web.Tests после правки. + +## 3. Открытые шлюзы волны 1 (не дублируются, остаются в силе) + +- **З-36** (`--mode migrate`: реализовать либо оформить перенос) — + подтверждено: не исполнен ни в одном варианте; `Program.cs` не + разбирает `--mode`/`--config`, ключа `Db.MigrationsDir` нет, + `2-db\readme.md:47–48` ссылается на несуществующие `build.ps1` и + `Server.WatchBody.Import`. **Пользователь!** +- **З-37 пп. 1–3, 5** (правки tech-req 4.2: `BpElevated*` с Dias 95, + фактические параметры сервера — `hrRestingDays`…`returnMaxFactor`, + пометка «`RestingHrSitting`/`HrStrengthMax`/`LightWeekHrRise` — + потребление телефон (m09), сервер в v1 не читает», camelCase) — код + и конфиги уже согласованы, документ нет. **Пользователь!** +- **З-39** (примечание в requirements Ф-14.2/Ф-14.3: в v1 любой + `kind=illness` трактуется как перезапуск — spec 04 m05) — + **Пользователь!** +- **З-38** — порядок закрытия m05 без изменений: владелец утверждает + 3.4 → агент закрывает шаг 4 и plan.md m05 ✅. + +## 4. Порядок исполнения + +1. **Сразу агентом:** З-42 (пп. 1–4, 6–8); к пакету владельцу + приложить проект решения З-41 (оба варианта с последствиями). +2. **Пакет на утверждение владельцу (один дифф):** З-41 (выбор + варианта), З-42 п. 5 (примечание spec 03 m05), плюс открытые шлюзы + волны 1 — З-36 (выбранный вариант), З-37 пп. 1–3/5, З-39. +3. **После утверждения:** реализация выбранного варианта З-41 (код + + тесты + документы + errata m02); актуализация чисел тестов в readme + m02 и plan.md той же правкой. +4. **Далее:** шлюз m05 3.4 → шаг 4 (закрытие модуля) → plan.md m05 ✅; + затем m06 по плану. Коммит и push волны — после утверждения; + эксплуатационные файлы (`program-trenirovok.*`, + `program-import.json`, `presentation-trenirovok.html`, + `Журнал_метрики.md`) в коммит не включать. + +## 5. Чек-лист + +| Задание | P | Шлюз | Статус | +|---|---|---|---| +| З-41 контур `is_active` в sync — выбор варианта | 1 | **Пользователь!** | ✅ (вариант а) | +| З-41 реализация выбранного варианта (+ тесты, errata m02) | 1 | — | ✅ | +| З-42 пп. 1–4, 6–8 (документация + middleware GET) | 2 | — | ✅ | +| З-42 п. 5 (примечание spec 03 m05) | 2 | в диффе волны | ✅ | +| З-36 / З-37 пп. 1–3, 5 / З-39 (волна 1) | 2–3 | **Пользователь!** | ✅ | +| З-38: гейт m05 3.4 → шаг 4 → plan.md m05 ✅ | 2 | **Пользователь!** | ✅ (m05 закрыт) | +| Коммит и push волны | — | утверждение | ⬜ (у владельца) | + +## 6. Самопроверка после исполнения + +1. Вариант З-41(а): повтор sync-пакета с `is_active=false` дважды — + оба ответа `accepted`, в БД `is_active=false`; Web.Tests зелёные, + новые числа — в readme m02 и plan.md. +2. grep `SyncRequest|RecordData` по `1-docs\4-modules\m02-rest-api` — + 0 вхождений (или с пометкой «не реализовано»). +3. grep `Db.Postgres.SqlServerHelper` по `1-docs` — 0 вхождений; + `SqlHelperAbstract` — в kit-helpers-spec, tech-req разд. 3, + `2-db\readme.md`. +4. readme m05 — «Core 56/56»; числа согласованы с plan.md. +5. `dotnet build` — 0 ошибок / 0 предупреждений; Core.Tests 56/56, + Web.Tests 80/80 (docker `watchbody-pg`, порт 5433, БД `watchbody`). diff --git a/1-docs/0-mng/agent-recommendations-2026-10-18.md b/1-docs/0-mng/agent-recommendations-2026-10-18.md new file mode 100644 index 0000000..c049bc0 --- /dev/null +++ b/1-docs/0-mng/agent-recommendations-2026-10-18.md @@ -0,0 +1,199 @@ +# Рекомендации агенту — этап 7 (итоги архитектурного ревью 2026-10-18) + +> **Получатель:** кодирующий агент, выполняющий этап 7 «Итерационная +> разработка» (текущий модуль — m05-progression-rules; далее m06+ по +> [plan.md](plan.md)). **Источник:** архитектурная сверка артифактов +> этапа 7 с документацией (requirements 1.4, architecture 1.2, +> tech-requirements 1.4, 2-db, module_reqs/api_specs m01–m05), 2026-10-18. +> Базовые файлы стандарта `std1\` и библиотека `Kit.Helpers` не править — +> все исправления только в проектных файлах этого репозитория. +> **Статусная модель заданий:** ⬜ не начато → 🔄 в работе → ✅ готово +> (утверждено владельцем). Нумерация З-35+ продолжает З-34 +> (ревью №3, [review-2026-10-17-3.md](review-2026-10-17-3.md)). + +## 0. Итог сверки (что подтверждено — не перепроверять) + +- Сборка решения — 0 ошибок / 0 предупреждений; тесты: Core.Tests + **55/55**, Web.Tests **80/80** (Integration 13 + Unit 12 + ProgramStore + 19 + MetricsEngine 17 + ProgressionRules 19), psql-проверок **122** — + все числа в readme модулей и plan.md совпадают с фактом. +- Структура DAL — 13 репозиториев в 8 файлах, 7 фильтров; доступ к БД — + только хранимые функции через Kit.Helpers. +- 38 psql-функций ↔ 38 тестов ↔ 38 спецификаций, статусы `DONE`. +- 🛡-инварианты серверного кода соблюдены: изоляция `p_athlete_id` + (включая явные проверки в `workout_insert`, `program_version_update`, + `workout_metrics_upsert`), идемпотентность по `p_id`, статусные + переходы `draft → active → archived` со сдвигом прежней `active`, + логическое `exercise_delete`, отказ `break.source=auto` из sync, + атомарность пакета `TransactionScope` (чтения — до транзакции), + сбой метрик/правил не ломает sync, ProgramStore — 7 методов, + авторы `user/admin/rules` (llm отклоняется), авто-смены активной + версии нет. +- Sync-валидация полного состава: пакет ≤ 50, `op=upsert`, диапазоны + `rpe` 1–10 / `wellbeing` 1–5 / `knee_pain` 0–10, 409 по spec 03 m02. +- Правила m05: 1–5 по порядку, делоад ×0.6/−1 подход, формула возврата + по таблице (1.0/0.9/0.85/0.75/ReturnMinFactor), правило 17 → `test`, + дедупликация rules-черновиков. + +## 1. Задание P1 + +### З-35. Довести З-31 (`src.program.external_id`) до артефактов БД и DAL ⬜ + +Решение О-4-Б утверждено и внесено в документацию +(`2-db\src\tables.md:83` — колонка `external_id`; `2-db\src\functions.md`, +`program_insert` — параметр `p_external_id`), но в коде отсутствует +полностью (grep по `2-db\` и `3-app\` — 0 вхождений): миграция 002, +`program_insert.psql`, `func_specs\program_insert.md`, +`ProgramRepository.Insert`. Контракт импорта пакета программы (Ф-8.3, +tech-req ОВ-11) на текущей схеме нереализуем — блокер m06. + +1. `2-db\migrations\003_program_external_id.sql` — новая миграция + (001–002 уже применены на `watchbody-pg`, журнал не трогать): + `ALTER TABLE src.program ADD COLUMN IF NOT EXISTS external_id text;` + + `CREATE UNIQUE INDEX IF NOT EXISTS uq_program_external_id ON + src.program (external_id);` (идемпотентно; NULL не ограничивается — + программа из sync создаётся без пакета). +2. `2-db\src\functions\program_insert.psql` — параметр + `p_external_id text DEFAULT NULL`, включить в INSERT. +3. `1-docs\2-db\src\func_specs\program_insert.md` — синхронизировать + (параметр + тест-кейс «повтор того же `external_id` → исключение + UNIQUE»; новая версия при повторе пакета — путь импорта, m06). +4. `Server.WatchBody.Core`: `Program.ExternalId` (string?) в домен, + `ProgramRepository.Insert` — параметр `p_external_id` + (`AddParameterNullable`); `SyncService.CreateProgram` не меняется + (NULL по умолчанию). +5. Тесты: psql `test_program_insert` — кейс UNIQUE-повтора; Core.Tests + `ProgramRepositoryTests` — кейс вставки с `external_id`; прогон на + docker `watchbody-pg`. +6. `readme.md` m01 — errata-примечание (практика модуля): «З-31 не был + доведён до SQL/DAL-артефактов, вскрыто ревью этапа 7 (2026-10-18), + исправлено по З-35»; числа тестов в readme m01 и plan.md (строка m01) + актуализировать после добавления кейсов. + +## 2. Задания P2 + +### З-36. Режим `--mode migrate` — реализовать либо оформить перенос ⬜ (решение владельца) + +`requirements` 2.3 и `module_reqs` m01 (разд. 1, критерий приёмки 1) +требуют CLI `--mode serve|import|fetch|migrate`; `plan.md` (строка m01) +включает его в закрытый модуль. Фактически `Program.cs` аргументы не +разбирает; `2-db\readme.md` ссылается на несуществующий `build.ps1`. + +- **Рекомендация (реализовать):** разбор `--mode`/`--config` в + `Program.cs`; для `migrate` — применение файлов `2-db\migrations\` + по возрастанию, журнал через `MigrationRepository` (уже готов), + транзакция на файл, откат при сбое, коды возврата 0/1 (как import); + путь миграций — новый ключ конфига `Db.MigrationsDir` (умолчание — + `migrations` рядом с исполняемым файлом). Внесение ключа — правка + tech-req 4.2 → шлюз «**Пользователь!**». +- **Альтернатива (перенести в m06):** в `plan.md` (строки m01/m06), + `module_reqs` m01 и `2-db\readme.md` зафиксировать «`--mode migrate` + входит в `Server.WatchBody.Import` (m06)»; m01 в plan.md — примечание. + +### З-37. Конфигурация `Safety.*`/`Progression.*` ↔ tech-req 4.2 ⬜ (правка утверждённого документа — шлюз) + +Расхождения (код — `MetricsConfig.cs`, `ProgressionConfig.cs`, +`appsettings.json`): + +1. `bpElevatedSys/Dias` (умолчание 140/**95**) против tech-req + `BpCardioSys/Dias` (140/90, «≤ BpCancel») — с умолчаниями документа + невыполнимо; отказ от проверки задокументирован в m04. Рекомендация: + tech-req 4.2 переименовать в `BpElevated*`, умолчание Dias 95, + примечание «проверка ≤ BpCancel не применяется (коридоры АД + перекрываются, spec 01 m04)». +2. В tech-req 4.2 отсутствуют фактические параметры сервера: + `hrRestingDays`, `wellbeingDays`, `lightDayCount`, `kneePainMin`, + `kneeRepeatSessions`, `hrBaselineDays`, `hrBaselineMin`, + `missDaysForBreak`, `returnMinFactor/MaxFactor` — дополнить таблицу. +3. `RestingHrSitting`, `HrStrengthMax`, `LightWeekHrRise` в серверной + конфигурации не читаются (пороги допуска — телефон по Р3-А; Ф-13.7). + Рекомендация: в tech-req 4.2 пометить «потребление — телефон (m09); + сервер в v1 не читает» — без мёртвых ключей в appsettings. +4. Диапазоны валидации сузить в коде до утверждённых: + `backoffPercent` → 1–20 (`ProgressionConfig.cs:44`), `deloadMaxWeeks` + → 3–12 (`ProgressionConfig.cs:45`) — это правка кода без шлюза. +5. Регистр имён в tech-req 4.2 привести к фактическим camelCase. + +### З-38. Шлюзы и статусы m05 ⬜ (шлюз — владелец, остальное — агент) + +По [m05 readme](../4-modules/m05-progression-rules/readme.md) шаг 1.3 +(«**Пользователь!**» — утверждение списка единиц) — ⬜ при выполненных +шагах 2/2.4/3; в [plan.md](plan.md) m05 — ⬜. Проект решения гейта — +`module_reqs` разд. 2.4. Порядок: владелец утверждает 1.3 → агент +проставляет 1.3 ✅ и m05 🔄 в plan.md → шлюз 3.4 (утверждение +реализации) → шаг 4 (закрытие модуля), plan.md m05 ✅. + +## 3. Задания P3 + +### З-39. Трассировка Ф-14.3: «болезнь с температурой» vs `kind=illness` ⬜ (шлюз) + +Spec 04 m05 (утверждён) применяет коэффициент перезапуска 0.6 к любому +`kind=illness` («любой длительности»), requirements Ф-14.3 — только к +«болезни с температурой». Признака «с температурой» в данных `breaks` +нет, поэтому рекомендуется примечание в requirements Ф-14.2/Ф-14.3: +«в v1 любой break `kind=illness` трактуется как перезапуск (spec 04 +m05); различение „с температурой“ — кандидат v2». Утверждение — +владелец. + +### З-40. Косметика и примечания ⬜ (сразу, без шлюза) + +1. `2-db\src\functions\exercise_delete.psql:1` — заголовок + «is_active = 0» → «is_active = false» (терминология З-34). +2. `m02 api_specs\04-workouts-get.md` — примечание «пагинация в памяти + (выборка атлета целиком) — известное ограничение v1». +3. `tech-requirements` разд. 3 — примечание «в sync-контуре v1 + используется `ILogger`; `Log.Logger` — файловые логи + последующих модулей (errata m04 №4)». +4. `m05 api_specs\02-micro-adjust.md` — примечание: «та же группа» + пары-замены гарантируется `exercise_replacement_insert` (проверка + на вставке пары); runtime-проверка в `ApplyExclusion` не дублируется. + +## 4. Порядок исполнения + +1. **Сразу агентом:** З-35 (код + БД + тесты, прогон на docker + `watchbody-pg`), З-40; в З-37 — п. 4 (диапазоны кода). +2. **Пакет на утверждение владельцу (один дифф):** З-36 (выбранный + вариант), З-37 (пп. 1–3, 5 — правки tech-req 4.2), З-39. +3. **После утверждения:** З-38 — закрытие шлюзов m05, синхронизация + статусов (readme m05, plan.md); далее продолжение этапа 7 — закрытие + m05 (3.4 → 4) и m06 по plan.md. +4. Коммит и push — после утверждения волны владельцем; эксплуатационные + файлы (`program-trenirovok.*`, `program-import.json`, + `presentation-trenirovok.html`) в коммит не включать. + +## 5. Чек-лист + +| Задание | P | Шлюз | Статус | +|---|---|---|---| +| З-35 external_id: миграция 003 + функция + spec + DAL + тесты | 1 | — | ✅ | +| З-36 `--mode migrate`: реализация или перенос (по выбору) | 2 | **Пользователь!** | ✅ (реализовано) | +| З-37 Safety/Progression ↔ tech-req 4.2 (пп. 1–3, 5) | 2 | **Пользователь!** | ✅ | +| З-37 п. 4 — диапазоны валидации в коде | 2 | — | ✅ | +| З-38 шлюзы/статусы m05 (1.3 → 🔄 → 3.4 → 4) | 2 | **Пользователь!** | ✅ (m05 закрыт) | +| З-39 примечание Ф-14.3 (illness → перезапуск) | 3 | **Пользователь!** | ✅ | +| З-40 косметика (4 пункта) | 3 | — | ✅ | +| Коммит и push волны | — | утверждение | ⬜ (у владельца) | + +## 6. Как продолжать работу после заданий + +- Точка входа — первый незакрытый шаг/шлюз в readme текущего модуля; + шлюзы «**Пользователь!**» (d-dev-4) без явного утверждения не + переходить. +- Новые сущности, функции БД, эндпоинты и параметры конфига не вводить + без правки `module_reqs.md` модуля (состав зафиксирован шагами d-dev). +- Прогон тестов — PostgreSQL 17 в docker `watchbody-pg` (порт 5433, + БД `watchbody`); изменения чисел тестов актуализировать в readme + модуля и `plan.md` той же правкой. +- Errata интеграций фиксировать в readme модуля-обнаружителя + (практика m01/m02/m04 — сохранить). + +## 7. Самопроверка после З-35 + +1. `\i 2-db\migrations\003_program_external_id.sql` дважды — без ошибок + (идемпотентность); `src.program` содержит колонку и уникальный индекс. +2. `SELECT src.program_insert(1, 'Т', 'pkg_1')` дважды — второй вызов — + исключение UNIQUE; psql-тест `test_program_insert` — PASS. +3. Core.Tests и Web.Tests — зелёные; новые числа внесены в readme m01 + и plan.md. +4. grep `external_id` по `2-db\` и `Server.WatchBody.Core` — вхождения + в миграции, функции, спецификации, домене и репозитории. diff --git a/1-docs/0-mng/plan.md b/1-docs/0-mng/plan.md index 75a38aa..2c559ee 100644 --- a/1-docs/0-mng/plan.md +++ b/1-docs/0-mng/plan.md @@ -15,7 +15,7 @@ | 4. Разработка архитектуры | `1-docs\2-arch\architecture.md`, `hld.puml` (+ рендер) (утверждена 2026-10-17) | ✅ | | 5. Разработка технических требований | `1-docs\3-tech\tech-requirements.md`, спецификации библиотек (утверждены 2026-10-17) | ✅ | | 6. Проектирование баз данных | `1-docs\2-db\` — readme, `src\tables.md`, `ref\tables.md`, `functions.md` (утверждены 2026-10-17) | ✅ | -| 7. Итерационная разработка | `3-app\` (код), `1-docs\4-modules\` (документы модулей) | ⬜ | +| 7. Итерационная разработка | `3-app\` (код), `1-docs\4-modules\` (документы модулей) | 🔄 | ## Решение шага 6 (состав данных) @@ -26,6 +26,33 @@ справочники (двигательные группы). Документация — `1-docs\2-db\`. JSON-тела (версии программ, факты .fit) — `jsonb` внутри таблиц. +## Решение шага 7 (разбивка версии 1.0 на модули) + +Принято (2026-10-17): версия 1.0 разбивается на модули по компонентам +архитектуры v1.1; порядок — итерациями (итерация 1 — серверный +фундамент, чек-лист шага 7 tech-requirements разд. 7). Статусы модулей: +⬜ — не начат, 🔄 — в работе, ✅ — завершён. + +| Модуль | Состав (проект) | Итерация | Статус | +|---|---|---|---| +| m01-server-db | Схема `src`/`ref`: миграции, psql-функции + тесты (d-dev 1–4); домен C# + DAL-репозитории (d-dev 5–7; 13 репозиториев в 8 файлах: Program.cs → Program+ProgramVersion, Workout.cs → Workout+WorkoutSet+CommandLog, DailyLog.cs → DailyLog+WorkoutMetrics+Break, Exercise.cs → Exercise+ExerciseReplacement, остальные — по классу); `--mode migrate` | 1 | ✅ (2026-10-18: 38/38 функций `DONE`; после З-35 — тесты psql 123/123 + C# 56/56, миграция 003 external_id) | +| m02-rest-api | `Server.WatchBody.Web`: `POST /api/v1/sync`, `GET workouts`, `GET programs/active`, middleware ключа; MVC + fluent Kit.Helpers.Routes | 1 | ✅ (2026-10-18: закрыт; после З-41(а) — тесты Web 81/81 + Core 56/56) | +| m03-program-store | `Server.WatchBody.Core`: версионный JSON программ, `active` — ровно одна | 1 | ✅ (2026-10-18: ProgramStore, 7 методов, тесты 19 PASS) | +| m04-metrics-engine | `Server.WatchBody.Core`: adherence, тоннаж, 1ПМ (Эпли), пульс-метрики, красные флаги | 2 | ✅ (2026-10-18: MetricsEngine + Safety, sync-пересчёт, тесты 17 PASS) | +| m05-progression-rules | `Server.WatchBody.Core`: правила прогрессии (Ф-12.2), делоад (Ф-12.4), перерывы/возврат (Ф-14), учёт ограничений (is_active/замены), draft-версии `author=rules` | 2 | ✅ (2026-10-18: ProgressionRules + BreakEngine, фильтр ограничений, тесты Web 81/81 + Core 56/56) | +| m06-fit-import | `Server.WatchBody.Import`: FIT SDK → `facts_json`, fallback-папка, архив | 2 | ⬜ | +| m07-fit-fetch | `Server.WatchBody.Fetch`: клиент Garmin Connect, дедупликация `fit_file_id` | 2 | ⬜ | +| m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: атлеты, тренировки, программы, метрики, CSV | 3 | ⬜ | +| m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ⬜ | +| m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ⬜ | +| m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ⬜ | +| m12-watch-channel | `WatchChannel`: companion-канал Connect IQ (прототип ОВ-3 — итерация 1, чек-лист 7.1) | 5 | ⬜ | +| m13-fenix-data-field | `FenixDataField` (Monkey C, таргет `fenix6x`): пульс/зоны/отдых | 5 | ⬜ | + +Прототипы чек-листа шага 7 (tech-requirements разд. 7) выполняются в +своих итерациях; ОВ-3 (companion-канал) — прототип в итерации 1 параллельно +m01–m03 (нужен для ранней проверки канала). + ## Состав версий (vision 2.2–2.4, решения опросников) - **v1.0:** полный MVP-цикл (Ф-1–Ф-9) + программы без LLM (Ф-10, Ф-11, diff --git a/1-docs/1-reqs/requirements.md b/1-docs/1-reqs/requirements.md index 5e4d933..ab736c8 100644 --- a/1-docs/1-reqs/requirements.md +++ b/1-docs/1-reqs/requirements.md @@ -492,7 +492,11 @@ Garmin штатно — Ф-2.5, Ф-2.6). Код поля данных перен «болезнь с температурой» — минимум 3 дня после нормальной температуры до первой сессии (правило 12). Атлет может объявить break заранее («уезжаю в отпуск») — запланированный простой - попадает в тот же автомат. + попадает в тот же автомат. Примечание (З-39, решение владельца + 2026-10-18): признака «с температурой» в данных `breaks` нет — + в v1 любой break `kind=illness` трактуется правилом возврата как + перезапуск (коэффициент нижней границы, spec 04 m05); различение + «с температурой» — кандидат v2. 3. **Формула возврата** (детерминированная, коэффициент от рабочего веса, документ перерывов разд. 3.2): diff --git a/1-docs/2-arch/architecture.md b/1-docs/2-arch/architecture.md index d14974b..82b1023 100644 --- a/1-docs/2-arch/architecture.md +++ b/1-docs/2-arch/architecture.md @@ -59,8 +59,8 @@ vision 1.1); перечень моделей — открытый вопрос 8 | Компонент | Проект (`3-app\2-cli\`) | Ответственность | |---|---|---| -| `RestApi` | `Server.WatchBody.Web` | `POST /api/v1/sync`, `GET workouts`, `GET programs/active`; ключ атлета → `athlete_id`; HTTPS | -| `AdminWeb` | `Server.WatchBody.Web` | Консоль владельца (логин/пароль): атлеты, тренировки + ход, программы, планы, метрики, CSV | +| `RestApi` | `Server.WatchBody.Web` | `POST /api/v1/sync`, `GET workouts`, `GET programs/active`; ключ атлета → `athlete_id`; HTTPS; + admin REST API (`/api/admin/*`) для консоли | +| `AdminWeb` | UI — отдельное Flutter Web-приложение (`3-app\3-web\admin-web`); бэкенд — admin REST API в `Server.WatchBody.Web` | Консоль владельца (логин/пароль): атлеты, тренировки + ход, программы, планы, метрики, CSV (решение владельца 2026-10-17: UI как отдельное Flutter-приложение вместо Razor Pages) | | `FitFetcher` | `Server.WatchBody.Fetch` | Клиент Garmin Connect (неофициальный, опросник 4.3); `--mode fetch`; дедупликация по `fit_file_id` | | `FitParser` | `Server.WatchBody.Import` | Garmin FIT SDK: Session/Lap/Record/Set → `workout_facts`; `--mode import` (fallback: .fit/.tcx/.gpx из папки) | | `MetricsEngine` | `Server.WatchBody.Core` | adherence, тоннаж по группам, 1ПМ, пульс-метрики, красные флаги (АД/пульс/боль — документ метрик) → `workout_metrics` | diff --git a/1-docs/2-db/readme.md b/1-docs/2-db/readme.md index b479494..ddaf0d6 100644 --- a/1-docs/2-db/readme.md +++ b/1-docs/2-db/readme.md @@ -8,7 +8,9 @@ ## Общие сведения - **СУБД:** PostgreSQL 17+; доступ — только через хранимые функции - (`Kit.Helpers\Db.Postgres.SqlServerHelper`, tech-requirements разд. 3). + (Kit.Helpers `SqlHelperAbstract`/`SqlHelperPostgres` — fluent + `PrepareExecute()`, tech-requirements разд. 3; З-42 — фактическое + имя компонента). - **Схемы-слои:** `src` — основные данные (атлеты, тренировки, программы, метрики); `ref` — справочники (двигательные группы). - **Изоляция атлетов (🛡):** каждая таблица данных несёт `athlete_id`; @@ -44,8 +46,12 @@ по возрастанию; каждая миграция идемпотентна (IF NOT EXISTS / OR REPLACE); факт применения — одна запись на файл в `src.migration` (id, filename, applied_at). -6. Сборка: скрипт `2-db\build.ps1` применяет миграции по номеру - (утилита `Server.WatchBody.Import --mode migrate`). +6. Применение (З-36): `Server.WatchBody.Web --mode migrate` + (`--config `) — файлы каталога `2-db\migrations\` по + возрастанию имён, журнал — `src.migration` (`MigrationRepository`), + транзакция на файл, коды возврата 0/1; ключ конфига + `Db.MigrationsDir` (умолчание — migrations рядом с исполняемым + файлом). ## Таблица `llm_calls` (v2) diff --git a/1-docs/2-db/ref/func_specs/exercise_group_select.md b/1-docs/2-db/ref/func_specs/exercise_group_select.md new file mode 100644 index 0000000..327827a --- /dev/null +++ b/1-docs/2-db/ref/func_specs/exercise_group_select.md @@ -0,0 +1,24 @@ +# `ref.exercise_group_select` + +Выбор всех двигательных групп (системный справочник, только чтение). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| — | — | — | функция без параметров | + +**Возвращает** `TABLE` — колонки возврата сущности `exercise_group`: +`id int`, `code text`, `title text`. Порядок — по `id` +(1 push, 2 pull, 3 legs, 4 core, 5 cardio). + +## Пример вызова + +```sql +SELECT * FROM ref.exercise_group_select(); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | справочник заполнен (seed) | 5 строк; `code` ∈ {push, pull, legs, core, cardio} | +| 2 | граничный случай | пустой справочник (до seed) | 0 строк, без исключения | diff --git a/1-docs/2-db/ref/functions.md b/1-docs/2-db/ref/functions.md index 4565880..e5bd135 100644 --- a/1-docs/2-db/ref/functions.md +++ b/1-docs/2-db/ref/functions.md @@ -10,4 +10,4 @@ | Сущность | Функция | Статус | Параметры | Возвращает | Функция | Репозиторий | Комментарий | |---|---|---|---|---|---|---|---| -| exercise_group | exercise_group_select | PLAN | — | TABLE — строки ref.exercise_group | - | - | без параметров; справочник неизменяем (d-db-3) | +| exercise_group | exercise_group_select | DONE | — | TABLE — строки ref.exercise_group | DONE | CODE | без параметров; справочник неизменяем (d-db-3) | diff --git a/1-docs/2-db/src/func_specs/athlete_get.md b/1-docs/2-db/src/func_specs/athlete_get.md new file mode 100644 index 0000000..a975983 --- /dev/null +++ b/1-docs/2-db/src/func_specs/athlete_get.md @@ -0,0 +1,23 @@ +# `src.athlete_get` + +Одна строка атлета по id (AdminWeb, сервисы). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_id` | int | да | атлет | + +**Возвращает** `TABLE` — колонки как в `athlete_select`. Не найден — +пустая таблица. + +## Пример вызова + +```sql +SELECT * FROM src.athlete_get(1); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | существующий id (фикстура TEST-) | 1 строка | +| 2 | граничный случай | несуществующий id | 0 строк | diff --git a/1-docs/2-db/src/func_specs/athlete_insert.md b/1-docs/2-db/src/func_specs/athlete_insert.md new file mode 100644 index 0000000..cfd357e --- /dev/null +++ b/1-docs/2-db/src/func_specs/athlete_insert.md @@ -0,0 +1,28 @@ +# `src.athlete_insert` + +Регистрация атлета (владелец/AdminWeb; ключ атлета хранится только хэшем). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_name` | text | да | отображаемое имя (Ф-9.1) | +| `p_api_key_hash` | text | да | SHA-256 ключа; открытый ключ не хранится | + +**Возвращает** `int` — id атлета (`src.athlete.id`). + +**Логика/особенности:** дубликат хэша — исключение `UNIQUE` +(один ключ = один атлет); `_delete` нет — отключение блокировкой +(`athlete_update`, 🛡). + +## Пример вызова + +```sql +SELECT src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | валидные имя и хэш | id > 0; `is_blocked = false`, `created_at ≈ now()` | +| 2 | исключение | повтор `p_api_key_hash` (фикстура TEST-hash-1) | исключение UNIQUE | +| 3 | исключение | `p_name = NULL` | исключение NOT NULL | diff --git a/1-docs/2-db/src/func_specs/athlete_profile_get.md b/1-docs/2-db/src/func_specs/athlete_profile_get.md new file mode 100644 index 0000000..145f649 --- /dev/null +++ b/1-docs/2-db/src/func_specs/athlete_profile_get.md @@ -0,0 +1,26 @@ +# `src.athlete_profile_get` + +Профиль атлета (телефон — старт допуска; AdminWeb). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | + +**Возвращает** `TABLE` — колонки возврата сущности `athlete_profile`: +`id int`, `athlete_id int`, `sex text`, `birth_year int`, +`weight_kg numeric(5,1)`, `hr_max int`, `updated_at timestamptz`. +Профиля нет — пустая таблица. + +## Пример вызова + +```sql +SELECT * FROM src.athlete_profile_get(1); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | профиль есть (фикстура TEST-) | 1 строка | +| 2 | граничный случай | профиля нет | 0 строк | +| 3 | граничный случай | профиль другого атлета не виден | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/athlete_profile_upsert.md b/1-docs/2-db/src/func_specs/athlete_profile_upsert.md new file mode 100644 index 0000000..a114938 --- /dev/null +++ b/1-docs/2-db/src/func_specs/athlete_profile_upsert.md @@ -0,0 +1,30 @@ +# `src.athlete_profile_upsert` + +Сохранение профиля атлета (1:1; с телефона через sync, сущность `profile`). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_sex` | text | нет | `m` / `f`; NULL — не указан | +| `p_birth_year` | int | нет | год рождения (Танака) | +| `p_weight_kg` | numeric(5,1) | нет | вес тела (последний введённый) | +| `p_hr_max` | int | нет | ручной ввод; NULL → расчёт по Танаке (ОВ-1) | + +**Возвращает** `int` — id записи профиля. + +**Логика/особенности:** `ON CONFLICT (athlete_id) DO UPDATE` — профиль +один; `updated_at` ставится `now()` при обоих путях. + +## Пример вызова + +```sql +SELECT src.athlete_profile_upsert(1, 'm', 1978, 158.0, NULL); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | профиля нет → insert | id > 0; строка создана | +| 2 | граничный случай | повторный вызов с другими полями | тот же id; поля обновлены, `updated_at` свежее | +| 3 | исключение | `p_sex = 'x'` (CHECK) | исключение CHECK | diff --git a/1-docs/2-db/src/func_specs/athlete_select.md b/1-docs/2-db/src/func_specs/athlete_select.md new file mode 100644 index 0000000..4e3244e --- /dev/null +++ b/1-docs/2-db/src/func_specs/athlete_select.md @@ -0,0 +1,26 @@ +# `src.athlete_select` + +Выбор атлетов с фильтрами (middleware API — по хэшу; AdminWeb — списком). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_api_key_hash` | text | нет | NULL = фильтр не применяется | +| `p_is_blocked` | boolean | нет | NULL = фильтр не применяется | + +**Возвращает** `TABLE` — колонки возврата сущности `athlete`: +`id int`, `name text`, `api_key_hash text`, `is_blocked boolean`, +`created_at timestamptz`. Порядок — по `name`. + +## Пример вызова + +```sql +SELECT * FROM src.athlete_select('TEST-hash-1', NULL); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | фильтр по хэшу (фикстура TEST-hash-1) | 1 строка | +| 2 | граничный случай | оба фильтра NULL | все атлеты | +| 3 | граничный случай | `p_is_blocked = true`, заблокированных нет | 0 строк | diff --git a/1-docs/2-db/src/func_specs/athlete_update.md b/1-docs/2-db/src/func_specs/athlete_update.md new file mode 100644 index 0000000..45ad640 --- /dev/null +++ b/1-docs/2-db/src/func_specs/athlete_update.md @@ -0,0 +1,29 @@ +# `src.athlete_update` + +Правка атлета: имя, хэш ключа, блокировка (AdminWeb). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_id` | int | да | атлет | +| `p_name` | text | да | новое имя | +| `p_api_key_hash` | text | да | новый хэш ключа | +| `p_is_blocked` | boolean | да | блокировка ключа → 401 на API | + +**Возвращает** `int` — число изменённых строк. + +**Логика/особенности:** NULL-семантики нет — функция пишет все поля +(db-m-8); несуществующий `p_id` → 0 строк (не исключение). + +## Пример вызова + +```sql +SELECT src.athlete_update(1, 'TEST-Атлет-2', 'TEST-hash-2', false); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | существующий атлет (фикстура TEST-) | 1; поля изменены | +| 2 | граничный случай | несуществующий `p_id` | 0 строк | +| 3 | исключение | `p_api_key_hash` = хэш другого атлета | исключение UNIQUE | diff --git a/1-docs/2-db/src/func_specs/break_insert.md b/1-docs/2-db/src/func_specs/break_insert.md new file mode 100644 index 0000000..a58fb0c --- /dev/null +++ b/1-docs/2-db/src/func_specs/break_insert.md @@ -0,0 +1,35 @@ +# `src.break_insert` + +Открытие перерыва (Ф-14): `auto` — сервер по пропускам, `manual` — телефон. + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | uuid записи — телефон/сервер | +| `p_kind` | text | нет | `illness`/`vacation`/`workload`/`injury`/`other`; NULL — атлет ещё не ответил | +| `p_source` | text | да | `auto` / `manual` | +| `p_started_on` | date | да | первый пропущенный день | +| `p_planned_return` | date | нет | предполагаемый возврат (ответ атлета, Ф-14.2) | +| `p_note` | text | да | причина-текст ('' — нет) | + +**Возвращает** `uuid` — id перерыва. + +**Логика/особенности:** 🛡 идемпотентность по PK uuid (повтор → +существующий id); `source='auto'` создаёт только сервер — от sync +принимается только `manual` (проверка на стороне API; БД фиксирует +оба значения); delete нет — журнал. + +## Пример вызова + +```sql +SELECT src.break_insert(1, 'eeee…', NULL, 'manual', '2026-10-20', '2026-10-27', 'TEST-отпуск'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | manual без ответа (kind NULL) | uuid = `p_id`; kind NULL | +| 2 | граничный случай | повтор `p_id` | тот же uuid; строка одна | +| 3 | исключение | `p_source = 'other'` | исключение CHECK | +| 4 | исключение | `p_kind = 'spacetravel'` | исключение CHECK | diff --git a/1-docs/2-db/src/func_specs/break_select.md b/1-docs/2-db/src/func_specs/break_select.md new file mode 100644 index 0000000..e745858 --- /dev/null +++ b/1-docs/2-db/src/func_specs/break_select.md @@ -0,0 +1,29 @@ +# `src.break_select` + +Журнал перерывов атлета (правила 12–17; CSV-выгрузка — документ перерывов разд. 4). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_kind` | text | нет | NULL = фильтр не применяется | +| `p_date_from` | date | нет | по `started_on`, включительно; NULL = не применяется | +| `p_date_to` | date | нет | по `started_on`, включительно; NULL = не применяется | + +**Возвращает** `TABLE` — колонки `src.breaks`: `id uuid`, +`athlete_id int`, `kind text`, `source text`, `started_on date`, +`planned_return date`, `ended_on date`, `note text`. Порядок — по +`started_on` (убывание). + +## Пример вызова + +```sql +SELECT * FROM src.break_select(1, NULL, NULL, NULL); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | 2 перерыва, фильтр по kind | только совпавшие | +| 2 | граничный случай | все фильтры NULL | все перерывы атлета | +| 3 | граничный случай | чужие перерывы не видны | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/break_update.md b/1-docs/2-db/src/func_specs/break_update.md new file mode 100644 index 0000000..8485dff --- /dev/null +++ b/1-docs/2-db/src/func_specs/break_update.md @@ -0,0 +1,34 @@ +# `src.break_update` + +Ответ атлета о причине (Ф-14.2) и закрытие перерыва первой сессией +после возврата. + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | перерыв | +| `p_kind` | text | да | причина (NULL — допустимо, ответа ещё нет) | +| `p_note` | text | да | причина-текст | +| `p_planned_return` | date | да | предполагаемый возврат (NULL — нет) | +| `p_ended_on` | date | да | факт возврата; NULL — перерыв открыт | + +**Возвращает** `int` — число изменённых строк (чужой/нет → 0). + +**Логика/особенности:** NULL-семантики нет — пишет все поля (db-m-8); +kind NULL валиден (правила 12–14 ждут ответа); закрытие не мешает +последующему ответу (поля независимы). + +## Пример вызова + +```sql +SELECT src.break_update(1, 'eeee…', 'vacation', 'TEST-отпуск', '2026-10-27', NULL); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | ответ атлета (kind + planned_return) | 1; поля записаны, `ended_on` остался NULL | +| 2 | основной сценарий | закрытие (ended_on) | 1; `ended_on` заполнен | +| 3 | граничный случай | чужой перерыв | 0 строк (изоляция 🛡) | +| 4 | исключение | `p_kind = 'spacetravel'` | исключение CHECK | diff --git a/1-docs/2-db/src/func_specs/command_log_insert.md b/1-docs/2-db/src/func_specs/command_log_insert.md new file mode 100644 index 0000000..812be49 --- /dev/null +++ b/1-docs/2-db/src/func_specs/command_log_insert.md @@ -0,0 +1,34 @@ +# `src.command_log_insert` + +Запись голосовой команды в журнал (Ф-1.5; 🛡 не редактируется). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | uuid записи — телефон | +| `p_workout_id` | uuid | да | тренировка атлета | +| `p_t_offset_s` | int | да | секунды от начала (сопоставление с .fit) | +| `p_recognized_text` | text | да | распознанный текст (диагностика STT) | +| `p_semantics` | text (jsonb) | да | `{cmd, weight, reps, rest_sec, rpe}` | +| `p_accepted` | boolean | да | принята грамматикой/диапазоном | + +**Возвращает** `uuid` — id записи. + +**Логика/особенности:** 🛡 журнал не редактируется — update/delete нет; +идемпотентность по PK uuid (повтор `p_id` → существующий id); чужая +тренировка — исключение FK. + +## Пример вызова + +```sql +SELECT src.command_log_insert(1, 'cccc…', 'aaaa…', 65, 'вес пятьдесят', '{"cmd":"weight","weight":50}', true); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | валидная команда | uuid = `p_id`; строка создана | +| 2 | граничный случай | повтор `p_id` | тот же uuid; строка одна | +| 3 | исключение | чужая тренировка | `RAISE EXCEPTION` (изоляция 🛡) | +| 4 | исключение | `p_semantics = NULL` | исключение NOT NULL | diff --git a/1-docs/2-db/src/func_specs/command_log_select.md b/1-docs/2-db/src/func_specs/command_log_select.md new file mode 100644 index 0000000..b341cc3 --- /dev/null +++ b/1-docs/2-db/src/func_specs/command_log_select.md @@ -0,0 +1,26 @@ +# `src.command_log_select` + +Журнал команд тренировки (для сопоставления с .fit, Ф-8.4). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_workout_id` | uuid | да | тренировка | + +**Возвращает** `TABLE` — колонки `src.command_log`: `id uuid`, +`workout_id uuid`, `t_offset_s int`, `recognized_text text`, +`semantics jsonb`, `accepted boolean`. Порядок — по `t_offset_s`. + +## Пример вызова + +```sql +SELECT * FROM src.command_log_select(1, 'aaaa…'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | 2 команды | 2 строки, порядок по t_offset_s | +| 2 | граничный случай | команд нет | 0 строк | +| 3 | граничный случай | чужая тренировка | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/daily_log_select.md b/1-docs/2-db/src/func_specs/daily_log_select.md new file mode 100644 index 0000000..d69bcec --- /dev/null +++ b/1-docs/2-db/src/func_specs/daily_log_select.md @@ -0,0 +1,28 @@ +# `src.daily_log_select` + +Дневник атлета за период (сводка метрик, AdminWeb). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_date_from` | date | нет | включительно; NULL = фильтр не применяется | +| `p_date_to` | date | нет | включительно; NULL = фильтр не применяется | + +**Возвращает** `TABLE` — колонки `src.daily_log`: `id uuid`, +`athlete_id int`, `date date`, `bp_sys int`, `bp_dias int`, +`weight_kg numeric`, `hr_resting int`, `waist_cm numeric`, +`sleep_h numeric`, `wellbeing int`. Порядок — по `date` (возрастание). + +## Пример вызова + +```sql +SELECT * FROM src.daily_log_select(1, '2026-10-01', '2026-10-17'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | 2 дня в окне | 2 строки, порядок по date | +| 2 | граничный случай | окно без записей | 0 строк | +| 3 | граничный случай | чужой дневник не виден | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/daily_log_upsert.md b/1-docs/2-db/src/func_specs/daily_log_upsert.md new file mode 100644 index 0000000..5f26b0d --- /dev/null +++ b/1-docs/2-db/src/func_specs/daily_log_upsert.md @@ -0,0 +1,33 @@ +# `src.daily_log_upsert` + +Сохранение дня дневника самоконтроля (Ф-13.1; день уникален для атлета). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | uuid записи — телефон (при update сохраняется прежний) | +| `p_date` | date | да | день (локальная дата атлета) | +| `p_bp_sys` | int | нет | систолическое АД | +| `p_bp_dias` | int | нет | диастолическое АД | +| `p_weight_kg` | numeric(5,1) | нет | вес тела | +| `p_hr_resting` | int | нет | пульс покоя, утро (Р1-А) | +| `p_wellbeing` | int | нет | самочувствие 1–5 (Р1-А) | + +**Возвращает** `uuid` — id записи (при повторе дня — прежний id). + +**Логика/особенности:** `ON CONFLICT (athlete_id, date) DO UPDATE`; +`waist_cm`/`sleep_h` — v2, в v1 не заполняются (колонки есть). + +## Пример вызова + +```sql +SELECT src.daily_log_upsert(1, 'dddd…', '2026-10-17', 135, 85, 158.0, 62, 4); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | новый день | uuid = `p_id`; строка создана | +| 2 | граничный случай | повтор дня (другой `p_id`) | прежний id записи; поля обновлены | +| 3 | исключение | `p_wellbeing = 6` | исключение CHECK | diff --git a/1-docs/2-db/src/func_specs/exercise_delete.md b/1-docs/2-db/src/func_specs/exercise_delete.md new file mode 100644 index 0000000..0dfeb64 --- /dev/null +++ b/1-docs/2-db/src/func_specs/exercise_delete.md @@ -0,0 +1,27 @@ +# `src.exercise_delete` + +Логическое удаление упражнения (Ф-4.2). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | упражнение | + +**Возвращает** `int` — число изменённых строк. + +**Логика/особенности:** строка не удаляется — `is_active = 0`; история +тренировок сохраняет ссылки (🛡); повторный вызов → 0 строк (уже неактивно). + +## Пример вызова + +```sql +SELECT src.exercise_delete(1, '11111111-…'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | активное упражнение | 1; `is_active = false`, строка на месте | +| 2 | граничный случай | повторный вызов | 0 строк | +| 3 | граничный случай | упражнение другого атлета | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/exercise_get.md b/1-docs/2-db/src/func_specs/exercise_get.md new file mode 100644 index 0000000..99c039a --- /dev/null +++ b/1-docs/2-db/src/func_specs/exercise_get.md @@ -0,0 +1,24 @@ +# `src.exercise_get` + +Одно упражнение атлета + пары замен. + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | упражнение | + +**Возвращает** `TABLE` — колонки как в `exercise_select` (включая +`replacement_ids jsonb`). Не найдено (или чужое) — пустая таблица. + +## Пример вызова + +```sql +SELECT * FROM src.exercise_get(1, '11111111-…'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | своё упражнение | 1 строка + замены | +| 2 | граничный случай | чужое/несуществующее | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/exercise_insert.md b/1-docs/2-db/src/func_specs/exercise_insert.md new file mode 100644 index 0000000..2d900e0 --- /dev/null +++ b/1-docs/2-db/src/func_specs/exercise_insert.md @@ -0,0 +1,32 @@ +# `src.exercise_insert` + +Добавление упражнения в библиотеку атлета (с телефона через sync, сущность `exercise`). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | exercise_id — генерирует телефон (Р2-Б) | +| `p_name` | text | да | название | +| `p_group_id` | int | да | группа (→ ref.exercise_group.id) | +| `p_is_base` | boolean | да | базовое/изоляция | + +**Возвращает** `uuid` — id упражнения. + +**Логика/особенности:** дубликат имени у атлета — исключение `UNIQUE` +(`UNIQUE(athlete_id, name)`); повтор `p_id` (ретрай sync) — возвращает +существующий id без ошибки (идемпотентность 🛡). + +## Пример вызова + +```sql +SELECT src.exercise_insert(1, '11111111-1111-1111-1111-111111111111', 'TEST-жим', 1, true); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | валидные данные | uuid = `p_id`; строка создана, `is_active = true` | +| 2 | граничный случай | повторный вызов с тем же `p_id` | тот же uuid; строка одна (не дубль) | +| 3 | исключение | повтор имени у атлета | исключение UNIQUE | +| 4 | исключение | несуществующий `p_group_id` | исключение FOREIGN KEY | diff --git a/1-docs/2-db/src/func_specs/exercise_replacement_delete.md b/1-docs/2-db/src/func_specs/exercise_replacement_delete.md new file mode 100644 index 0000000..981c3ab --- /dev/null +++ b/1-docs/2-db/src/func_specs/exercise_replacement_delete.md @@ -0,0 +1,25 @@ +# `src.exercise_replacement_delete` + +Удаление пары-замены. + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_exercise_id` | uuid | да | упражнение | +| `p_replacement_id` | uuid | да | замена | + +**Возвращает** `int` — число удалённых строк (чужая пара → 0). + +## Пример вызова + +```sql +SELECT src.exercise_replacement_delete(1, '…', '…'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | существующая пара атлета | 1; строка удалена | +| 2 | граничный случай | несуществующая пара | 0 строк | +| 3 | граничный случай | пара другого атлета | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/exercise_replacement_insert.md b/1-docs/2-db/src/func_specs/exercise_replacement_insert.md new file mode 100644 index 0000000..fe06abb --- /dev/null +++ b/1-docs/2-db/src/func_specs/exercise_replacement_insert.md @@ -0,0 +1,31 @@ +# `src.exercise_replacement_insert` + +Добавление пары-замены (ММ; правило 6 — замена в пределах группы). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_exercise_id` | uuid | да | упражнение | +| `p_replacement_id` | uuid | да | замена | + +**Возвращает** `int` — число вставленных строк (1). + +**Логика/особенности:** проверка в функции: оба упражнения принадлежат +атлету и их `group_id` равны — иначе `RAISE EXCEPTION`; повтор пары — +исключение `UNIQUE` (PK пары). + +## Пример вызова + +```sql +SELECT src.exercise_replacement_insert(1, '…', '…'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | два упражнения одной группы | 1; пара в таблице | +| 2 | исключение | упражнения разных групп | `RAISE EXCEPTION` (группа не равна) | +| 3 | исключение | одно из упражнений другого атлета | `RAISE EXCEPTION` (изоляция 🛡) | +| 4 | исключение | повтор пары | исключение UNIQUE | +| 5 | исключение | `p_exercise_id = p_replacement_id` | исключение CHECK | diff --git a/1-docs/2-db/src/func_specs/exercise_select.md b/1-docs/2-db/src/func_specs/exercise_select.md new file mode 100644 index 0000000..fcfd147 --- /dev/null +++ b/1-docs/2-db/src/func_specs/exercise_select.md @@ -0,0 +1,31 @@ +# `src.exercise_select` + +Выбор упражнений библиотеки атлета с фильтрами + пары замен (денормализация). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_name` | text | нет | точное совпадение; NULL = фильтр не применяется | +| `p_group_id` | int | нет | NULL = фильтр не применяется | +| `p_is_base` | boolean | нет | NULL = фильтр не применяется | +| `p_is_active` | boolean | нет | NULL = без фильтра; по умолчанию вызывающий передаёт `true` (Ф-4.2) | + +**Возвращает** `TABLE` — колонки возврата сущности `exercise`: +`id uuid`, `athlete_id int`, `name text`, `group_id int`, `is_base boolean`, +`is_active boolean` + денормализация `replacement_ids jsonb` (массив uuid +замен из `src.exercise_replacement`). Порядок — по `name`. + +## Пример вызова + +```sql +SELECT * FROM src.exercise_select(1, NULL, NULL, NULL, true); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | 2 упражнения, у одного пара замен | 2 строки; у замены `replacement_ids` = 1 uuid | +| 2 | граничный случай | `p_is_active = true`, есть неактивные | только активные | +| 3 | граничный случай | упражнения другого атлета не видны | 0 строк (изоляция 🛡) | +| 4 | граничный случай | все фильтры NULL | все упражнения атлета | diff --git a/1-docs/2-db/src/func_specs/exercise_update.md b/1-docs/2-db/src/func_specs/exercise_update.md new file mode 100644 index 0000000..4dc71ac --- /dev/null +++ b/1-docs/2-db/src/func_specs/exercise_update.md @@ -0,0 +1,31 @@ +# `src.exercise_update` + +Правка упражнения: название, группа, признак «базовое». + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | упражнение | +| `p_name` | text | да | новое название | +| `p_group_id` | int | да | новая группа | +| `p_is_base` | boolean | да | базовое/изоляция | + +**Возвращает** `int` — число изменённых строк. + +**Логика/особенности:** 🛡 `exercise_id` не меняется (Ф-4.3 — история +тренировок остаётся связной); NULL-семантики нет (db-m-8); чужое +упражнение (другой атлет) → 0 строк. + +## Пример вызова + +```sql +SELECT src.exercise_update(1, '11111111-…', 'TEST-жим 45°', 1, false); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | своё упражнение (фикстура TEST-) | 1; поля обновлены, id прежний | +| 2 | граничный случай | упражнение другого атлета | 0 строк (изоляция 🛡) | +| 3 | исключение | `p_name` = имя другого упражнения атлета | исключение UNIQUE | diff --git a/1-docs/2-db/src/func_specs/migration_insert.md b/1-docs/2-db/src/func_specs/migration_insert.md new file mode 100644 index 0000000..66b154f --- /dev/null +++ b/1-docs/2-db/src/func_specs/migration_insert.md @@ -0,0 +1,27 @@ +# `src.migration_insert` + +Фиксация применённой миграции в журнале (пишет `--mode migrate`). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_filename` | text | да | имя применённого psql-файла из `2-db\migrations\` | + +**Возвращает** `int` — id записи журнала. + +**Логика/особенности:** повтор имени — исключение `UNIQUE` (миграция +считается неприменённой, если её нет в журнале; повторный вызов = ошибка +процесса миграции, а не идемпотентное действие). + +## Пример вызова + +```sql +SELECT src.migration_insert('0001_init.sql'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | `p_filename = 'TEST-0001.sql'` | id > 0; строка в `src.migration` с `applied_at ≈ now()` | +| 2 | исключение | повтор `p_filename = 'TEST-0001.sql'` | исключение UNIQUE | +| 3 | исключение | `p_filename = NULL` | исключение NOT NULL | diff --git a/1-docs/2-db/src/func_specs/migration_select.md b/1-docs/2-db/src/func_specs/migration_select.md new file mode 100644 index 0000000..3ddc28a --- /dev/null +++ b/1-docs/2-db/src/func_specs/migration_select.md @@ -0,0 +1,24 @@ +# `src.migration_select` + +Проверка «применена ли миграция» (вызывает `--mode migrate` перед применением). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_filename` | text | да | имя файла миграции | + +**Возвращает** `TABLE` — колонки возврата сущности `migration`: +`id int`, `filename text`, `applied_at timestamptz`. Миграция не найдена — +пустая таблица (не исключение). + +## Пример вызова + +```sql +SELECT * FROM src.migration_select('TEST-0001.sql'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | фикстура `TEST-0001.sql` в журнале | 1 строка, `filename = 'TEST-0001.sql'` | +| 2 | граничный случай | файл не в журнале | 0 строк | diff --git a/1-docs/2-db/src/func_specs/program_get.md b/1-docs/2-db/src/func_specs/program_get.md new file mode 100644 index 0000000..f67b3ad --- /dev/null +++ b/1-docs/2-db/src/func_specs/program_get.md @@ -0,0 +1,24 @@ +# `src.program_get` + +Одна строка программы атлета. + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_program_id` | int | да | программа | + +**Возвращает** `TABLE` — колонки как в `program_select` (включая +денормализацию активной версии). Не найдена/чужая — пустая таблица. + +## Пример вызова + +```sql +SELECT * FROM src.program_get(1, 1); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | своя программа | 1 строка | +| 2 | граничный случай | чужая программа | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/program_insert.md b/1-docs/2-db/src/func_specs/program_insert.md new file mode 100644 index 0000000..1acfad7 --- /dev/null +++ b/1-docs/2-db/src/func_specs/program_insert.md @@ -0,0 +1,32 @@ +# `src.program_insert` + +Создание программы атлета (контейнер версий). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_title` | text | да | название («Программа v4») | +| `p_external_id` | text | нет | идентификатор пакета (`program_id` из program-import.json, З-31/З-35); NULL — программа создана не из пакета (sync, ProgramStore) | + +**Возвращает** `int` — id программы. + +**Логика/особенности:** вторая программа атлета — исключение `UNIQUE` +(`UNIQUE(athlete_id)` — одна программа, истории живут в версиях); +повтор `p_external_id` — исключение `UNIQUE` (`uq_program_external_id`, +NULL не ограничивается); повтор пакета → новая версия той же программы — +путь импорта (m06); `created_at = now()`. + +## Пример вызова + +```sql +SELECT src.program_insert(1, 'TEST-Программа'); +SELECT src.program_insert(1, 'TEST-Пакет', 'pkg_1'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | у атлета программы нет | id > 0 | +| 2 | исключение | повторный вызов для того же атлета | исключение UNIQUE | +| 3 | исключение | повтор того же `p_external_id` (другой атлет) | исключение UNIQUE | diff --git a/1-docs/2-db/src/func_specs/program_select.md b/1-docs/2-db/src/func_specs/program_select.md new file mode 100644 index 0000000..f024df9 --- /dev/null +++ b/1-docs/2-db/src/func_specs/program_select.md @@ -0,0 +1,26 @@ +# `src.program_select` + +Программы атлета + активная версия (денормализация, d-db-3). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | + +**Возвращает** `TABLE` — колонки возврата сущности `program`: +`id int`, `athlete_id int`, `title text`, `created_at timestamptz` + +денормализация `active_version_id uuid`, `active_version int` (NULL — +активной версии нет). + +## Пример вызова + +```sql +SELECT * FROM src.program_select(1); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | есть программа с активной версией | 1 строка; `active_version` заполнен | +| 2 | граничный случай | программа есть, все версии draft/archived | `active_version_id IS NULL` | +| 3 | граничный случай | программ нет | 0 строк | diff --git a/1-docs/2-db/src/func_specs/program_version_get.md b/1-docs/2-db/src/func_specs/program_version_get.md new file mode 100644 index 0000000..63f63f6 --- /dev/null +++ b/1-docs/2-db/src/func_specs/program_version_get.md @@ -0,0 +1,24 @@ +# `src.program_version_get` + +Одна версия программы с телом (jsonb). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | версия | + +**Возвращает** `TABLE` — все колонки сущности `program_version`, +включая `body jsonb`. Не найдена/чужая — пустая таблица. + +## Пример вызова + +```sql +SELECT * FROM src.program_version_get(1, '22222222-…'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | своя версия | 1 строка; `body` = записанному jsonb | +| 2 | граничный случай | чужая версия | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/program_version_insert.md b/1-docs/2-db/src/func_specs/program_version_insert.md new file mode 100644 index 0000000..70f0f8f --- /dev/null +++ b/1-docs/2-db/src/func_specs/program_version_insert.md @@ -0,0 +1,37 @@ +# `src.program_version_insert` + +Создание новой версии программы (всегда `draft`). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | id версии — генерирует телефон (Р2-Б) | +| `p_program_id` | int | да | программа атлета | +| `p_based_on_version` | int | нет | предыдущая версия; NULL — первая | +| `p_author` | text | да | `user` / `admin` / `rules` (llm — v2) | +| `p_body` | text (jsonb) | да | недели → тренировки → упражнения → подходы-план | +| `p_rationale` | text | нет | «почему» — журнал изменений | + +**Возвращает** `uuid` — id версии. + +**Логика/особенности:** `version = max(version)+1` по программе; +`status = 'draft'`; 🛡 тело после создания не меняется (правка = новая +версия); программа чужого атлета → исключение (изоляция 🛡). + +## Пример вызова + +```sql +SELECT src.program_version_insert(1, '22222222-…', 1, NULL, 'user', '{}', 'TEST-первая'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | первая версия | uuid = `p_id`; `version = 1`, `status = 'draft'` | +| 2 | основной сценарий | вторая версия | `version = 2`; `based_on_version = 1` | +| 3 | исключение | `p_program_id` чужой/несуществующий | исключение FOREIGN KEY (изоляция 🛡) | +| 4 | идемпотентность | повтор `p_id` (PK uuid) | тот же id (errata m02: 🛡 идемпотентность, как workout_insert) | + +Примечание: `author = 'llm'` на уровне БД допустим (CHECK по tables.md — +`user \| admin \| rules \| llm`); запрет `llm` в v1 — на стороне приложения. diff --git a/1-docs/2-db/src/func_specs/program_version_select.md b/1-docs/2-db/src/func_specs/program_version_select.md new file mode 100644 index 0000000..46368d1 --- /dev/null +++ b/1-docs/2-db/src/func_specs/program_version_select.md @@ -0,0 +1,29 @@ +# `src.program_version_select` + +Список версий программы (без тела — крупный jsonb читается отдельным get). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_program_id` | int | нет | NULL = фильтр не применяется | +| `p_status` | text | нет | NULL = фильтр не применяется | +| `p_author` | text | нет | NULL = фильтр не применяется | + +**Возвращает** `TABLE` — колонки сущности `program_version` **без `body`**: +`id uuid`, `program_id int`, `version int`, `based_on_version int`, +`status text`, `author text`, `rationale text`, `created_at timestamptz`. +Порядок — по `version` (возрастание). + +## Пример вызова + +```sql +SELECT * FROM src.program_version_select(1, 1, 'draft', NULL); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | 3 версии, фильтр по статусу | только версии статуса; `body` в колонках нет | +| 2 | граничный случай | все фильтры NULL | все версии атлета, порядок по version | +| 3 | граничный случай | версии чужой программы | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/program_version_update.md b/1-docs/2-db/src/func_specs/program_version_update.md new file mode 100644 index 0000000..fcc7a81 --- /dev/null +++ b/1-docs/2-db/src/func_specs/program_version_update.md @@ -0,0 +1,33 @@ +# `src.program_version_update` + +Смена статуса версии (единственное изменение версии — 🛡 неизменяемость). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | версия | +| `p_status` | text | да | `draft` / `active` / `archived` | + +**Возвращает** `int` — число изменённых строк (0 — чужая/нет). + +**Логика/особенности:** 🛡 меняется только `status`; переходы +draft→active→archived (обратные — `RAISE EXCEPTION`); при установке +`active` прежняя активная версия программы сдвигается в `archived` — +активной становится ровно одна (частичный уникальный индекс). + +## Пример вызова + +```sql +SELECT src.program_version_update(1, '22222222-…', 'active'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | draft → active (фикстуры: draft v1) | 1; v1 `active` | +| 2 | основной сценарий | active v1 → active v2 | v1 → `archived`; v2 → `active`; активных 1 | +| 3 | основной сценарий | active → archived | 1 | +| 4 | исключение | archived → active (обратный переход) | `RAISE EXCEPTION` | +| 5 | исключение | draft → archived (минуя active) | `RAISE EXCEPTION` | +| 6 | граничный случай | чужая версия | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/workout_get.md b/1-docs/2-db/src/func_specs/workout_get.md new file mode 100644 index 0000000..49eeef1 --- /dev/null +++ b/1-docs/2-db/src/func_specs/workout_get.md @@ -0,0 +1,32 @@ +# `src.workout_get` + +Полная карточка тренировки: строка + подходы + журнал команд (Ф-9.2). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | тренировка | + +**Возвращает** `TABLE` — колонки `src.workout` + денормализация: + +| Колонка | Тип | Описание | +|---|---|---| +| все колонки `src.workout` | — | как в select | +| `sets` | jsonb | массив подходов, порядок по `seq` | +| `commands` | jsonb | массив `src.command_log`, порядок по `t_offset_s` | + +Не найдена/чужая — пустая таблица. + +## Пример вызова + +```sql +SELECT * FROM src.workout_get(1, 'aaaa…'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | тренировка с подходами и командами | 1 строка; `sets` и `commands` заполнены, порядок корректен | +| 2 | граничный случай | пустая тренировка | `sets` = `[]`, `commands` = `[]` | +| 3 | граничный случай | чужая тренировка | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/workout_insert.md b/1-docs/2-db/src/func_specs/workout_insert.md new file mode 100644 index 0000000..8f872ce --- /dev/null +++ b/1-docs/2-db/src/func_specs/workout_insert.md @@ -0,0 +1,32 @@ +# `src.workout_insert` + +Создание тренировки (с телефона через sync; строки «только .fit» — сервер). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | workout_id — генерирует телефон (строки «только .fit» — сервер) | +| `p_program_version_id` | uuid | нет | версия, активная на начало (Ф-10.3) | +| `p_started_at` | timestamptz | да | начало (UTC) | +| `p_session_kind` | text | да | `normal` / `return` / `deload` / `test` (Ф-14.5) | + +**Возвращает** `uuid` — id тренировки. + +**Логика/особенности:** 🛡 идемпотентность — повтор `p_id` возвращает +существующий id без ошибки (ретрай sync); `status = 'active'` по +умолчанию; чужая `p_program_version_id` — исключение (изоляция 🛡). + +## Пример вызова + +```sql +SELECT src.workout_insert(1, 'aaaa…', NULL, now(), 'normal'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | валидные данные | uuid = `p_id`; `status='active'` | +| 2 | граничный случай | повтор `p_id` | тот же uuid; строка одна | +| 3 | исключение | чужая `p_program_version_id` | исключение (изоляция 🛡) | +| 4 | исключение | `p_session_kind = 'other'` | исключение CHECK | diff --git a/1-docs/2-db/src/func_specs/workout_metrics_select.md b/1-docs/2-db/src/func_specs/workout_metrics_select.md new file mode 100644 index 0000000..5307a7f --- /dev/null +++ b/1-docs/2-db/src/func_specs/workout_metrics_select.md @@ -0,0 +1,31 @@ +# `src.workout_metrics_select` + +Метрики тренировок атлета (графики AdminWeb, Ф-9.4). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_workout_id` | uuid | нет | NULL = фильтр не применяется | +| `p_date_from` | date | нет | по дате тренировки (`started_at::date`); NULL = не применяется | +| `p_date_to` | date | нет | включительно; NULL = не применяется | + +**Возвращает** `TABLE` — колонки `src.workout_metrics` + `started_at +timestamptz` (для графиков): `id int`, `workout_id uuid`, `athlete_id int`, +`adherence numeric`, `tonnage numeric`, `tonnage_by_group jsonb`, +`e1pm_by_exercise jsonb`, `hr_metrics jsonb`, `red_flags jsonb`, +`computed_at timestamptz`, `started_at timestamptz`. Порядок — по +`started_at`. + +## Пример вызова + +```sql +SELECT * FROM src.workout_metrics_select(1, NULL, NULL, NULL); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | 2 тренировки с метриками | 2 строки + started_at | +| 2 | граничный случай | тренировка без метрик | не попадает в выборку | +| 3 | граничный случай | чужие метрики не видны | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/workout_metrics_upsert.md b/1-docs/2-db/src/func_specs/workout_metrics_upsert.md new file mode 100644 index 0000000..33e19d4 --- /dev/null +++ b/1-docs/2-db/src/func_specs/workout_metrics_upsert.md @@ -0,0 +1,34 @@ +# `src.workout_metrics_upsert` + +Сохранение метрик тренировки (MetricsEngine; пересчёт — тот же вызов). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_workout_id` | uuid | да | тренировка атлета | +| `p_adherence` | numeric(4,1) | да | % закрытых подходов плана (КУ-5) | +| `p_tonnage` | numeric(9,1) | да | Σ вес×повторы, кг | +| `p_tonnage_by_group` | text (jsonb) | да | `{"push": 4200, …}` | +| `p_e1pm_by_exercise` | text (jsonb) | да | `{"": 82.5}` (Эпли) | +| `p_hr_metrics` | text (jsonb) | да | средний/пиковый пульс, отдых | +| `p_red_flags` | text (jsonb) | да | сработавшие красные флаги (АД/пульс/колено) | + +**Возвращает** `int` — id записи (при пересчёте — прежний). + +**Логика/особенности:** 1:1 к `workout` — `ON CONFLICT (workout_id) +DO UPDATE` (пересчёт MetricsEngine); `computed_at = now()` в обоих путях; +чужая тренировка — исключение FK (изоляция 🛡); телефон только читает. + +## Пример вызова + +```sql +SELECT src.workout_metrics_upsert(1, 'aaaa…', 92.5, 4200.0, '{"push":4200}', '{}', '{}', '[]'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | первая запись | id > 0 | +| 2 | граничный случай | пересчёт (повтор workout_id) | прежний id; поля обновлены | +| 3 | исключение | чужая тренировка | `RAISE EXCEPTION` (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/workout_select.md b/1-docs/2-db/src/func_specs/workout_select.md new file mode 100644 index 0000000..ec03476 --- /dev/null +++ b/1-docs/2-db/src/func_specs/workout_select.md @@ -0,0 +1,34 @@ +# `src.workout_select` + +Карточки тренировок атлета с подходами (агрегат jsonb, d-db-6). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_status` | text | нет | NULL = фильтр не применяется | +| `p_date_from` | date | нет | по `started_at::date`, включительно; NULL = не применяется | +| `p_date_to` | date | нет | включительно; NULL = не применяется | + +**Возвращает** `TABLE` — карточка + подходы: + +| Колонка | Тип | Описание | +|---|---|---| +| все колонки `src.workout` | — | id, athlete_id, program_version_id, started_at, ended_at, status, session_kind, avg_hr, facts_json, fit_file_id, hr_recovery_s | +| `sets` | jsonb | массив подходов (`src.workout_set`, порядок по `seq`) | + +Порядок — по `started_at` (убывание, свежие сверху). + +## Пример вызова + +```sql +SELECT * FROM src.workout_select(1, NULL, NULL, NULL); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | 2 тренировки, у одной 2 подхода | 2 строки; у первой `sets` = 2 элемента по порядку seq | +| 2 | граничный случай | тренировка без подходов | `sets` = `[]` | +| 3 | граничный случай | фильтр по датам | только попадающие в окно | +| 4 | граничный случай | тренировки другого атлета не видны | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/workout_set_get.md b/1-docs/2-db/src/func_specs/workout_set_get.md new file mode 100644 index 0000000..a6625e9 --- /dev/null +++ b/1-docs/2-db/src/func_specs/workout_set_get.md @@ -0,0 +1,26 @@ +# `src.workout_set_get` + +Один подход атлета. + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | подход | + +**Возвращает** `TABLE` — колонки `src.workout_set`: `id uuid`, +`workout_id uuid`, `athlete_id int`, `exercise_id uuid`, `seq int`, +`weight_kg numeric`, `reps int`, `done_at timestamptz`, `source text`, +`in_plan boolean`, `rpe int`, `knee_pain int`. Не найден/чужой — пусто. + +## Пример вызова + +```sql +SELECT * FROM src.workout_set_get(1, 'bbbb…'); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | свой подход | 1 строка | +| 2 | граничный случай | чужой подход | 0 строк (изоляция 🛡) | diff --git a/1-docs/2-db/src/func_specs/workout_set_insert.md b/1-docs/2-db/src/func_specs/workout_set_insert.md new file mode 100644 index 0000000..047dedd --- /dev/null +++ b/1-docs/2-db/src/func_specs/workout_set_insert.md @@ -0,0 +1,41 @@ +# `src.workout_set_insert` + +Фиксация подхода (голос/часы/правка). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡, денормализация для изоляции) | +| `p_id` | uuid | да | uuid подхода — телефон | +| `p_workout_id` | uuid | да | тренировка атлета | +| `p_exercise_id` | uuid | нет | NULL — упражнение не выбрано | +| `p_seq` | int | да | порядковый номер подхода в тренировке | +| `p_weight_kg` | numeric(6,2) | да | вес | +| `p_reps` | int | да | повторения | +| `p_done_at` | timestamptz | да | момент фиксации | +| `p_source` | text | да | `voice` / `watch` / `edit` | +| `p_in_plan` | boolean | да | «по плану» (Ф-11.2) | +| `p_rpe` | int | нет | 1–10 (З-7); NULL — не указан | +| `p_knee_pain` | int | нет | 0–10, группа legs; NULL — не указан | + +**Возвращает** `uuid` — id подхода. + +**Логика/особенности:** 🛡 идемпотентность по PK uuid — повтор `p_id` +возвращает существующий id; `workout` и `exercise` должны принадлежать +атлету (FK + изоляция); правка подхода = новая запись `source='edit'` +(update/delete нет — история сохраняется). + +## Пример вызова + +```sql +SELECT src.workout_set_insert(1, 'bbbb…', 'aaaa…', NULL, 1, 50.0, 15, now(), 'voice', true, 7, NULL); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | валидные данные | uuid = `p_id`; строка создана | +| 2 | граничный случай | повтор `p_id` | тот же uuid; строка одна | +| 3 | исключение | `p_workout_id` чужой | исключение FOREIGN KEY (изоляция 🛡) | +| 4 | исключение | `p_rpe = 11` | исключение CHECK | +| 5 | исключение | `p_knee_pain = 11` | исключение CHECK | diff --git a/1-docs/2-db/src/func_specs/workout_update.md b/1-docs/2-db/src/func_specs/workout_update.md new file mode 100644 index 0000000..c8ce225 --- /dev/null +++ b/1-docs/2-db/src/func_specs/workout_update.md @@ -0,0 +1,37 @@ +# `src.workout_update` + +Обновление тренировки: закрытие (телефон), факты .fit (FitParser). + +| Параметр | Тип | Обяз. | Описание | +|---|---|---|---| +| `p_athlete_id` | int | да | атлет (🛡) | +| `p_id` | uuid | да | тренировка | +| `p_ended_at` | timestamptz | да | конец; NULL — ещё идёт | +| `p_status` | text | да | `active` / `finished` / `cancelled` (Ф-3.5) | +| `p_session_kind` | text | да | см. insert | +| `p_avg_hr` | int | да | средний пульс; NULL — нет | +| `p_facts_json` | text (jsonb) | да | канонический факт .fit (`workout-facts/1`); пишет FitParser | +| `p_fit_file_id` | text | да | дедупликация fetch (Ф-8.1); NULL — .fit ещё не забран | +| `p_hr_recovery_s` | int | да | возврат пульса к <90; NULL — нет | + +**Возвращает** `int` — число изменённых строк (чужая/нет → 0). + +**Логика/особенности:** NULL-семантики нет — функция пишет все поля +(db-m-8): клиент передаёт ended/status/kind/avg_hr, FitParser дописывает +facts/fit_file_id/hr_recovery_s; вызов FitParser сохраняет прочие поля — +он передаёт их текущими значениями (читает get перед записью). + +## Пример вызова + +```sql +SELECT src.workout_update(1, 'aaaa…', now(), 'finished', 'normal', 118, NULL, NULL, NULL); +``` + +## Тесты + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной сценарий | закрытие тренировки клиентом | 1; `status='finished'`, `ended_at` заполнен | +| 2 | основной сценарий | FitParser дописывает факты (повтор update с фактами) | 1; `facts_json`/`fit_file_id` записаны, статус не тронут | +| 3 | граничный случай | чужая тренировка | 0 строк (изоляция 🛡) | +| 4 | исключение | `p_status = 'other'` | исключение CHECK | diff --git a/1-docs/2-db/src/functions.md b/1-docs/2-db/src/functions.md index dfa014e..906be9d 100644 --- a/1-docs/2-db/src/functions.md +++ b/1-docs/2-db/src/functions.md @@ -21,40 +21,40 @@ | Сущность | Функция | Статус | Параметры | Возвращает | Функция | Репозиторий | Комментарий | |---|---|---|---|---|---|---|---| -| athlete | athlete_insert | PLAN | p_name, p_api_key_hash | int — id атлета | - | - | AdminWeb; дубликаты hash — исключение UNIQUE | -| athlete | athlete_update | PLAN | p_id, p_name, p_api_key_hash, p_is_blocked | int — число строк | - | - | NULL-семантики нет (db-m-8) | -| athlete | athlete_select | PLAN | p_api_key_hash, p_is_blocked | TABLE — строки athlete | - | - | фильтры: NULL = без фильтра; для middleware и AdminWeb | -| athlete | athlete_get | PLAN | p_id | TABLE — одна строка | - | - | — | -| athlete_profile | athlete_profile_upsert | PLAN | p_athlete_id, p_sex, p_birth_year, p_weight_kg, p_hr_max | int — id | - | - | 1:1 — `ON CONFLICT (athlete_id) DO UPDATE` | -| athlete_profile | athlete_profile_get | PLAN | p_athlete_id | TABLE — одна строка | - | - | — | -| exercise | exercise_insert | PLAN | p_athlete_id, p_id (uuid), p_name, p_group_id, p_is_base | uuid — id упражнения | - | - | дубликат имени у атлета — исключение UNIQUE | -| exercise | exercise_update | PLAN | p_athlete_id, p_id, p_name, p_group_id, p_is_base | int — число строк | - | - | `exercise_id` не меняется (Ф-4.3, 🛡) | -| exercise | exercise_delete | PLAN | p_athlete_id, p_id | int — число строк | - | - | логическое: `is_active=false` (Ф-4.2) | -| exercise | exercise_select | PLAN | p_athlete_id, p_name, p_group_id, p_is_base, p_is_active | TABLE — строки + пары замен (MM) | - | - | по умолчанию только `is_active`; замены — денормализация (d-db-3) | -| exercise | exercise_get | PLAN | p_athlete_id, p_id | TABLE — одна строка + замены | - | - | — | -| exercise_replacement | exercise_replacement_insert | PLAN | p_athlete_id, p_exercise_id, p_replacement_id | int — число строк | - | - | оба упражнения — атлета, группа равна (проверка в функции) | -| exercise_replacement | exercise_replacement_delete | PLAN | p_athlete_id, p_exercise_id, p_replacement_id | int — число строк | - | - | — | -| program | program_insert | PLAN | p_athlete_id, p_title, p_external_id | int — id программы | - | - | вторая программа атлета — исключение UNIQUE; `p_external_id` — идентификатор пакета (program-import.json, З-31) | -| program | program_select | PLAN | p_athlete_id | TABLE — строки + активная версия | - | - | денормализация активной версии (d-db-3) | -| program | program_get | PLAN | p_athlete_id, p_program_id | TABLE — одна строка | - | - | — | -| program_version | program_version_insert | PLAN | p_athlete_id, p_id (uuid), p_program_id, p_based_on_version, p_author, p_body, p_rationale | uuid — id версии | - | - | импорт пакета: повтор `p_external_id` → новая версия; `version` = max+1; `status='draft'`; body — jsonb | -| program_version | program_version_update | PLAN | p_athlete_id, p_id, p_status | int — число строк | - | - | 🛡 меняется только статус; переходы draft→active→archived; активной становится ровно одна (сдвиг прежней в archived) | -| program_version | program_version_select | PLAN | p_athlete_id, p_program_id, p_status, p_author | TABLE — строки (без body) | - | - | body — отдельным get (крупный jsonb) | -| program_version | program_version_get | PLAN | p_athlete_id, p_id | TABLE — одна строка с body | - | - | — | -| workout | workout_insert | PLAN | p_athlete_id, p_id (uuid), p_program_version_id, p_started_at, p_session_kind | uuid — id | - | - | повтор `p_id` (uuid — PK клиентской сущности, генерирует телефон) возвращает существующий id (🛡 идемпотентность) | -| workout | workout_update | PLAN | p_athlete_id, p_id, p_ended_at, p_status, p_session_kind, p_avg_hr, p_facts_json, p_fit_file_id, p_hr_recovery_s | int — число строк | - | - | факты .fit дописывает FitParser; клиент — только ended/status/session_kind/avg_hr | -| workout | workout_select | PLAN | p_athlete_id, p_status, p_date_from, p_date_to | TABLE — карточки + подходы (агрегат jsonb) | - | - | мультикурсор/агрегат: карточка + sets (d-db-6) | -| workout | workout_get | PLAN | p_athlete_id, p_id | TABLE — одна строка + sets + command_log | - | - | полная карточка (Ф-9.2) | -| workout_set | workout_set_insert | PLAN | p_athlete_id, p_id (uuid), p_workout_id, p_exercise_id, p_seq, p_weight_kg, p_reps, p_done_at, p_source, p_in_plan, p_rpe, p_knee_pain | uuid — id | - | - | идемпотентность по PK uuid (Р2-Б) | -| workout_set | workout_set_get | PLAN | p_athlete_id, p_id | TABLE — одна строка | - | - | — | -| command_log | command_log_insert | PLAN | p_athlete_id, p_id (uuid), p_workout_id, p_t_offset_s, p_recognized_text, p_semantics, p_accepted | uuid — id | - | - | 🛡 не редактируется (Ф-1.5) | -| command_log | command_log_select | PLAN | p_athlete_id, p_workout_id | TABLE — строки | - | - | для сопоставления с .fit | -| daily_log | daily_log_upsert | PLAN | p_athlete_id, p_id (uuid), p_date, p_bp_sys, p_bp_dias, p_weight_kg, p_hr_resting, p_wellbeing | uuid — id | - | - | `UNIQUE(athlete_id, date)` — upsert; hr_resting/wellbeing — поля v1 (Р1-А) | -| daily_log | daily_log_select | PLAN | p_athlete_id, p_date_from, p_date_to | TABLE — строки | - | - | для сводки метрик и AdminWeb | -| workout_metrics | workout_metrics_upsert | PLAN | p_athlete_id, p_workout_id, p_adherence, p_tonnage, p_tonnage_by_group, p_e1pm_by_exercise, p_hr_metrics, p_red_flags | int — id | - | - | 1:1, пересчёт MetricsEngine | -| workout_metrics | workout_metrics_select | PLAN | p_athlete_id, p_workout_id, p_date_from, p_date_to | TABLE — строки | - | - | графики AdminWeb (Ф-9.4) | -| breaks | break_insert | PLAN | p_athlete_id, p_id (uuid), p_kind, p_source, p_started_on, p_planned_return, p_note | uuid — id | - | - | `auto` — сервер по пропускам (Ф-14.1); `manual` — телефон, объявление простоя заранее (Ф-14.2) | -| breaks | break_update | PLAN | p_athlete_id, p_id, p_kind, p_note, p_planned_return, p_ended_on | int — число строк | - | - | ответ атлета на вопрос о причине (Ф-14.2) + закрытие первой сессией после возврата | -| breaks | break_select | PLAN | p_athlete_id, p_kind, p_date_from, p_date_to | TABLE — строки | - | - | журнал перерывов; CSV-выгрузка (документ перерывов разд. 4) | -| migration | migration_insert | PLAN | p_filename | int — id | - | - | журнал миграций (d-db-1); пишет `--mode migrate` | -| migration | migration_select | PLAN | p_filename | TABLE — одна строка | - | - | проверка «применена ли миграция» | +| athlete | athlete_insert | DONE | p_name, p_api_key_hash | int — id атлета | DONE | CODE | AdminWeb; дубликаты hash — исключение UNIQUE | +| athlete | athlete_update | DONE | p_id, p_name, p_api_key_hash, p_is_blocked | int — число строк | DONE | CODE | NULL-семантики нет (db-m-8) | +| athlete | athlete_select | DONE | p_api_key_hash, p_is_blocked | TABLE — строки athlete | DONE | CODE | фильтры: NULL = без фильтра; для middleware и AdminWeb | +| athlete | athlete_get | DONE | p_id | TABLE — одна строка | DONE | CODE | — | +| athlete_profile | athlete_profile_upsert | DONE | p_athlete_id, p_sex, p_birth_year, p_weight_kg, p_hr_max | int — id | DONE | CODE | 1:1 — `ON CONFLICT (athlete_id) DO UPDATE` | +| athlete_profile | athlete_profile_get | DONE | p_athlete_id | TABLE — одна строка | DONE | CODE | — | +| exercise | exercise_insert | DONE | p_athlete_id, p_id (uuid), p_name, p_group_id, p_is_base | uuid — id упражнения | DONE | CODE | дубликат имени у атлета — исключение UNIQUE | +| exercise | exercise_update | DONE | p_athlete_id, p_id, p_name, p_group_id, p_is_base | int — число строк | DONE | CODE | `exercise_id` не меняется (Ф-4.3, 🛡) | +| exercise | exercise_delete | DONE | p_athlete_id, p_id | int — число строк | DONE | CODE | логическое: `is_active=false` (Ф-4.2) | +| exercise | exercise_select | DONE | p_athlete_id, p_name, p_group_id, p_is_base, p_is_active | TABLE — строки + пары замен (MM) | DONE | CODE | по умолчанию только `is_active`; замены — денормализация (d-db-3) | +| exercise | exercise_get | DONE | p_athlete_id, p_id | TABLE — одна строка + замены | DONE | CODE | — | +| exercise_replacement | exercise_replacement_insert | DONE | p_athlete_id, p_exercise_id, p_replacement_id | int — число строк | DONE | CODE | оба упражнения — атлета, группа равна (проверка в функции) | +| exercise_replacement | exercise_replacement_delete | DONE | p_athlete_id, p_exercise_id, p_replacement_id | int — число строк | DONE | CODE | — | +| program | program_insert | DONE | p_athlete_id, p_title, p_external_id | int — id программы | DONE | CODE | вторая программа атлета — исключение UNIQUE; `p_external_id` — идентификатор пакета (program-import.json, З-31) | +| program | program_select | DONE | p_athlete_id | TABLE — строки + активная версия | DONE | CODE | денормализация активной версии (d-db-3) | +| program | program_get | DONE | p_athlete_id, p_program_id | TABLE — одна строка | DONE | CODE | — | +| program_version | program_version_insert | DONE | p_athlete_id, p_id (uuid), p_program_id, p_based_on_version, p_author, p_body, p_rationale | uuid — id версии | DONE | CODE | импорт пакета: повтор `p_external_id` → новая версия; `version` = max+1; `status='draft'`; body — jsonb (параметр text, errata m02); 🛡 идемпотентность по `p_id` (errata m02) | +| program_version | program_version_update | DONE | p_athlete_id, p_id, p_status | int — число строк | DONE | CODE | 🛡 меняется только статус; переходы draft→active→archived; активной становится ровно одна (сдвиг прежней в archived) | +| program_version | program_version_select | DONE | p_athlete_id, p_program_id, p_status, p_author | TABLE — строки (без body) | DONE | CODE | body — отдельным get (крупный jsonb) | +| program_version | program_version_get | DONE | p_athlete_id, p_id | TABLE — одна строка с body | DONE | CODE | — | +| workout | workout_insert | DONE | p_athlete_id, p_id (uuid), p_program_version_id, p_started_at, p_session_kind | uuid — id | DONE | CODE | повтор `p_id` (uuid — PK клиентской сущности, генерирует телефон) возвращает существующий id (🛡 идемпотентность) | +| workout | workout_update | DONE | p_athlete_id, p_id, p_ended_at, p_status, p_session_kind, p_avg_hr, p_facts_json, p_fit_file_id, p_hr_recovery_s | int — число строк | DONE | CODE | факты .fit дописывает FitParser; клиент — только ended/status/session_kind/avg_hr | +| workout | workout_select | DONE | p_athlete_id, p_status, p_date_from, p_date_to | TABLE — карточки + подходы (агрегат jsonb) | DONE | CODE | мультикурсор/агрегат: карточка + sets (d-db-6) | +| workout | workout_get | DONE | p_athlete_id, p_id | TABLE — одна строка + sets + command_log | DONE | CODE | полная карточка (Ф-9.2) | +| workout_set | workout_set_insert | DONE | p_athlete_id, p_id (uuid), p_workout_id, p_exercise_id, p_seq, p_weight_kg, p_reps, p_done_at, p_source, p_in_plan, p_rpe, p_knee_pain | uuid — id | DONE | CODE | идемпотентность по PK uuid (Р2-Б) | +| workout_set | workout_set_get | DONE | p_athlete_id, p_id | TABLE — одна строка | DONE | CODE | — | +| command_log | command_log_insert | DONE | p_athlete_id, p_id (uuid), p_workout_id, p_t_offset_s, p_recognized_text, p_semantics, p_accepted | uuid — id | DONE | CODE | 🛡 не редактируется (Ф-1.5) | +| command_log | command_log_select | DONE | p_athlete_id, p_workout_id | TABLE — строки | DONE | CODE | для сопоставления с .fit | +| daily_log | daily_log_upsert | DONE | p_athlete_id, p_id (uuid), p_date, p_bp_sys, p_bp_dias, p_weight_kg, p_hr_resting, p_wellbeing | uuid — id | DONE | CODE | `UNIQUE(athlete_id, date)` — upsert; hr_resting/wellbeing — поля v1 (Р1-А) | +| daily_log | daily_log_select | DONE | p_athlete_id, p_date_from, p_date_to | TABLE — строки | DONE | CODE | для сводки метрик и AdminWeb | +| workout_metrics | workout_metrics_upsert | DONE | p_athlete_id, p_workout_id, p_adherence, p_tonnage, p_tonnage_by_group, p_e1pm_by_exercise, p_hr_metrics, p_red_flags | int — id | DONE | CODE | 1:1, пересчёт MetricsEngine | +| workout_metrics | workout_metrics_select | DONE | p_athlete_id, p_workout_id, p_date_from, p_date_to | TABLE — строки | DONE | CODE | графики AdminWeb (Ф-9.4) | +| breaks | break_insert | DONE | p_athlete_id, p_id (uuid), p_kind, p_source, p_started_on, p_planned_return, p_note | uuid — id | DONE | CODE | `auto` — сервер по пропускам (Ф-14.1); `manual` — телефон, объявление простоя заранее (Ф-14.2) | +| breaks | break_update | DONE | p_athlete_id, p_id, p_kind, p_note, p_planned_return, p_ended_on | int — число строк | DONE | CODE | ответ атлета на вопрос о причине (Ф-14.2) + закрытие первой сессией после возврата | +| breaks | break_select | DONE | p_athlete_id, p_kind, p_date_from, p_date_to | TABLE — строки | DONE | CODE | журнал перерывов; CSV-выгрузка (документ перерывов разд. 4) | +| migration | migration_insert | DONE | p_filename | int — id | DONE | CODE | журнал миграций (d-db-1); пишет `--mode migrate` | +| migration | migration_select | DONE | p_filename | TABLE — одна строка | DONE | CODE | проверка «применена ли миграция» | diff --git a/1-docs/3-tech/kit-helpers-spec.md b/1-docs/3-tech/kit-helpers-spec.md index f851ec3..9754dbc 100644 --- a/1-docs/3-tech/kit-helpers-spec.md +++ b/1-docs/3-tech/kit-helpers-spec.md @@ -9,10 +9,9 @@ | Компонент | Использует | Назначение в WatchBody | |---|---|---| -| `Db.Postgres.SqlServerHelper` | `FitParser`, `MetricsEngine`, `ProgramStore`, `RestApi`, `AdminWeb` | Единственный путь к PostgreSQL: выполнение хранимых функций слоя `src`/`ref` (шаг 6) | -| `SqlServerHelperFluent` | те же | Fluent-обёртка вызовов функций | -| `Log.Logger` | все серверные компоненты | Файловые логи; `Log.Path`, уровень из конфигурации | -| `JsonHelper` | `ProgramStore`, `FitParser` | Сериализация/десериализация версий программ и фактов (`facts_json`) | +| `Kit.Helpers.SqlHelperAbstract` (`SqlHelperPostgres`, fluent `SqlHelperFluent`) | репозитории DAL (m01), тесты | Единственный путь к PostgreSQL: выполнение хранимых функций слоя `src`/`ref` (шаг 6) — `PrepareExecute().AsStoredProcedure().AddParameter()…` (З-42: фактическое имя компонента вместо имени из ранней редакции спецификации) | +| `Log.Logger` | — | Файловые логи; `Log.Path`, уровень из конфигурации. В v1 не настроен (errata m04 №4) — sync-контур использует `ILogger`; файловые логи — последующие модули | +| `JsonHelper` | — | В v1 не потребляется: ProgramStore/MetricsEngine работают с System.Text.Json напрямую (З-42) | ## Контракты @@ -22,8 +21,8 @@ - **Логи:** уровень из конфигурации; в лог не пишутся ключи, пароли и хэши (инвариант «Тайны», requirements раздел 5); ротация — средствами Logger по дням. -- **JSON:** `JsonHelper` — единственная точка сериализации программ - (стабильный порядок полей — диффы версий сравнимы). +- **JSON:** System.Text.Json напрямую (ProgramStore/MetricsEngine); + `JsonHelper` в v1 не потребляется (З-42). ## Не потребляется в v1 diff --git a/1-docs/3-tech/tech-requirements.md b/1-docs/3-tech/tech-requirements.md index 9499e17..77583c3 100644 --- a/1-docs/3-tech/tech-requirements.md +++ b/1-docs/3-tech/tech-requirements.md @@ -64,7 +64,8 @@ ### 2.2. Сущности `data` (ключевые поля) - `exercise`: `exercise_id` (uuid), `name`, `group` (push|pull|legs|core|…), - `is_base` (bool), `is_active` (bool); + `is_base` (bool), `is_active` (bool) — повторная отправка с + `is_active=false` — логическое удаление на сервере (З-41(а)); - `workout`: `workout_id` (uuid), `started_at`/`ended_at` (UTC ISO 8601), `status` (active|finished|cancelled), `session_kind` (normal|return|deload|test — Ф-14.5), `program_version_id` (nullable), @@ -103,10 +104,15 @@ потребление без изменений исходников (полная спецификация — [kit-helpers-spec.md](kit-helpers-spec.md)): -- `Db.Postgres.SqlServerHelper` — доступ к PostgreSQL (единственный - путь к БД из кода сервера; строка подключения — `Db.ConnectionString`); +- `Kit.Helpers.SqlHelperAbstract` (`SqlHelperPostgres`, fluent + `SqlHelperFluent`) — доступ к PostgreSQL (единственный путь к БД из + кода сервера; строка подключения — `Db.ConnectionString`; З-42 — + фактическое имя компонента); - `Log.Logger` — файловые логи сервера (`Log.Path`, уровень `Info`); -- `JsonHelper` — сериализация фактов/программ; + в sync-контуре v1 используется `ILogger` (Kit `Log.Logger` + не настроен и без папки логов бросает — errata m04 №4; файловые логи — + последующие модули); +- `JsonHelper` — в v1 не потребляется (System.Text.Json напрямую, З-42); - остальное (Cache, Http, Session) в v1 не потребляется. ## 4. Схема конфигурации и правила валидации @@ -146,12 +152,23 @@ | `Progression.BackoffPercent` | int | 1–20 | | `Deload.MaxWeeks` | int | 3–12 | | `Safety.BpCancelSys / BpCancelDias` | int | 120–200 / 70–130 (умолчания 150/90) | -| `Safety.BpCardioSys / BpCardioDias` | int | ≤ BpCancel (умолчания 140/90) | -| `Safety.RestingHrSitting` | int | 60–120 (умолчание 90) | -| `Safety.HrStrengthMax` | int | 100–180 (умолчание 120, З-20) | -| `Safety.LightWeekHrRise` | int | 3–15 (умолчание 8) | +| `Safety.BpElevatedSys / BpElevatedDias` | int | умолчания 140/95 (З-37: переименовано из `BpCardio*`, Dias 95 по факту кода; проверка «≤ BpCancel» не применяется — коридоры АД документа метрик перекрываются, spec 01 m04) | +| `Safety.HrRestingDays` | int | 1–30 (умолчание 14) — окно базового пульса покоя | +| `Safety.HrBaselineMin` | int | 1–14 (умолчание 3) — минимум значений для базового | +| `Safety.WellbeingDays` | int | 1–7 (умолчание 2) — длина серии самочувствия | +| `Safety.LightDayCount` | int | 2–7 (умолчание 3) — порог отмены/лимит серии | +| `Safety.KneePainMin` | int | 1–5 (умолчание 1) | +| `Safety.KneeRepeatSessions` | int | 2–5 (умолчание 2) | +| `Progression.MissDaysForBreak` | int | 2–14 (умолчание 4, Ф-14.1) | +| `Progression.ReturnMinFactor / ReturnMaxFactor` | double | 0.3–0.9 / (0.5; 1] (умолчания 0.6/0.9) — границы коэффициента возврата | +| `Db.MigrationsDir` | path | каталог миграций `--mode migrate` (З-36); умолчание — migrations рядом с исполняемым файлом | | `Log.Path / Log.Level` | — | уровень: Debug/Info/Error | +Примечания (З-37 п. 3): `Safety.RestingHrSitting`, `Safety.HrStrengthMax`, +`Safety.LightWeekHrRise` (разд. 4.1) — потребление — телефон (m09); +сервер в v1 не читает (мёртвых ключей в appsettings нет, Р3-А). +Регистр ключей — camelCase по факту конфигов (З-37 п. 5). + Валидация конфигурации выполняется при старте любого режима; ошибки — в лог и stderr, код возврата 1 (раздел 2.3 requirements). @@ -171,9 +188,13 @@ грамматика — JSON-словарь `[«подход», «вес», «отдых», «на», «секунд», «с половиной», 0–999]`; числа — постобработка («сто двадцать» → 120; «…с половиной» → +0,5 кг — решение владельца О-2-Б, З-26). -- **`AdminWeb`**: Razor Pages, сессия по cookie после логина; - пароль — PBKDF2 (см. 4.2); графики — Chart.js (локальный ассет, - без CDN). +- **`AdminWeb`** (решение владельца 2026-10-17): отдельное Flutter + Web-приложение (`3-app\3-web\admin-web`); бэкенд — admin REST API в + `Server.WatchBody.Web` (`/api/admin/*`): логин (логин/пароль → + Bearer-токен сессии), атлеты (список/создание/смена ключа/ + блокировка), тренировки, программы, метрики, CSV-выгрузка + (генерация CSV — на сервере, п. 2.4). Пароль — PBKDF2 (см. 4.2); + графики — штатные средства Flutter. - **HTTPS**: обратный прокси (nginx/Caddy) терминирует TLS; Kestrel — только localhost — вопрос развёртывания, вне кода. diff --git a/1-docs/4-modules/m01-server-db/SKILL.md b/1-docs/4-modules/m01-server-db/SKILL.md new file mode 100644 index 0000000..b16fa0e --- /dev/null +++ b/1-docs/4-modules/m01-server-db/SKILL.md @@ -0,0 +1,29 @@ +# Навык: модуль m01-server-db (серверный слой данных WatchBody) + +> Инструкция ИИ-агенту для продолжения работы над модулем. Модуль — +> детерминированный код; навык — только инструкция (d-dev-6). + +## Контекст + +Модуль реализует серверный слой данных v1 системы WatchBody +(«Феникс 6X»): PostgreSQL-схемы `src`/`ref`, psql-функции, миграции, +домен C# и DAL-репозитории, `--mode migrate`. Алгоритм — d-dev +(`std1\_rules\d\d-dev.md`); требования модуля — +`1-docs\4-modules\m01-server-db\module_reqs.md` (самодостаточна); +план и статусы шагов — `readme.md` рядом. + +## Как продолжать + +1. Прочитай `1-docs\4-modules\m01-server-db\readme.md` — найди первый + шаг со статусом ⬜ и продолжай с него (прерывание/продолжение — + d-dev-6). +2. Статусы функций ведутся в `1-docs\2-db\src\functions.md` и + `1-docs\2-db\ref\functions.md` (колонки «Статус», «Функция», + «Репозиторий»; модель — d-dev-1..3). +3. Каждый шаг с пометкой «**Пользователь!**» завершается остановкой и + явным утверждением пользователя — переход без утверждения запрещён + (d-dev-4). +4. Код модуля — в `3-app\2-cli\Server.WatchBody.*` (домен и + репозитории — в Core-проекте), SQL — в `2-db\migrations\` и + `func_specs\` модуля; доступ к БД — только Kit.Helpers + `SqlHelperAbstract` (SqlHelperPostgres). diff --git a/1-docs/4-modules/m01-server-db/agent_recommendations.md b/1-docs/4-modules/m01-server-db/agent_recommendations.md new file mode 100644 index 0000000..456b3f3 --- /dev/null +++ b/1-docs/4-modules/m01-server-db/agent_recommendations.md @@ -0,0 +1,47 @@ +# Рекомендации агенту — этап 7 (модуль m01-server-db) + +> По итогам архитектурной сверки этапа 7 с документацией +> (ревью 2026-10-18). Получателю: кодирующему агенту, продолжающему +> модуль по d-dev. Базовые файлы стандарта `std1\` не править — все +> исправления только в проектных файлах этого репозитория. + +## Итог сверки (что подтверждено) + +- 38 psql-функций (37 `src` + 1 `ref`), 38 тестов, 38 спецификаций в + `func_specs` — совпадает с `readme.md` (d-dev-4). +- 13 репозиториев DAL и 7 фильтров — совпадает с заявкой d-dev-6. + Группировка по файлам (важно понимать при правках): + - `ProgramRepository.cs` → `ProgramRepository` + `ProgramVersionRepository`; + - `WorkoutRepository.cs` → `WorkoutRepository` + `WorkoutSetRepository` + `CommandLogRepository`; + - `DailyLogRepository.cs` → `DailyLogRepository` + `WorkoutMetricsRepository` + `BreakRepository`; + - `ExerciseRepository.cs` → `ExerciseRepository` + `ExerciseReplacementRepository`; + - остальные — по файлу на класс. +- Тесты C#: 55/55 PASS, сборка без ошибок (проверено 2026-10-18). +- Шаг 7 readme («статусы функций DONE») корректно ⬜: в + `1-docs\2-db\{src,ref}\functions.md` функции ещё `DEV`. + +## Что исправить до утверждения 6.4 + +1. **`readme.md` модуля, шаг 5:** указано «`repository_tests\` — + 12 файлов», фактически в каталоге **11** (отдельного файла по + `ProgramVersion` нет — его тесты внутри `ProgramRepository.md`). + Исправить число на 11 (либо добавить недостающий файл по d-tst — + тогда обосновать в readme). Правится только файл модуля. +2. **`1-docs\0-mng\plan.md`, строка m01:** дополнить состав модуля + пометкой о группировке репозиториев (8 файлов → 13 классов) — чтобы + ревью 6.4 сверялось с фактической структурой кода. + +## Как продолжать работу + +- Первый шаг со статусом ⬜ в `readme.md` модуля — точка входа + (сейчас это 6.4 «Утверждение репозиториев» — шаг «**Пользователь!**», + без явного утверждения не переходить). +- После 6.4 — шаг 7: перевести статусы функций `DEV → DONE` в + `1-docs\2-db\src\functions.md` и `1-docs\2-db\ref\functions.md`, + убедиться что сборка и тесты зелёные (55/55), затем обновить статусы + в `readme.md` модуля и `plan.md`. +- Доступ к БД — только Kit.Helpers `Db.Postgres.SqlServerHelper`; + psql-тесты — PostgreSQL 17 в docker `watchbody-pg` (порт 5433, + БД `watchbody`), прогон фиксировать в readme модуля. +- Новые сущности/функции не вводить без изменения `module_reqs.md` — + состав зафиксирован шагами d-dev-2/3 и утверждён. diff --git a/1-docs/4-modules/m01-server-db/module_reqs.md b/1-docs/4-modules/m01-server-db/module_reqs.md new file mode 100644 index 0000000..12ccf71 --- /dev/null +++ b/1-docs/4-modules/m01-server-db/module_reqs.md @@ -0,0 +1,104 @@ +# Требования к модулю: m01-server-db + +> Модуль — серверный слой данных v1: схема PostgreSQL (`src`, `ref`), +> psql-функции, миграции, домен C# и DAL-репозитории, режим +> `--mode migrate`. Самодостаточная копия требований (d-dev-6): внешние +> документы продублированы по содержанию, ссылки на них — только для +> контекста, не для смысла. + +## 1. Назначение и границы + +**В границах модуля:** + +- SQL-миграции схемы `ref` и `src` (журнал `src.migration`, d-db-1); +- psql-функции слоя `src` и `ref` (список утверждён — разд. 3); +- psql-тесты функций; +- доменные объекты C# (`{Сущность}.cs`, правила c-dm); +- DAL-репозитории C# (`{Сущность}Repository.cs`, правила c-dal) поверх + `Kit.Helpers SqlHelperAbstract` (SqlHelperPostgres) (единственный путь к БД); +- режим запуска `--mode migrate` сервера (применение миграций по журналу). + +**Вне границ модуля:** REST API (m02), бизнес-логика метрик/правил +(m04, m05), импорт .fit (m06), админ-консоль (m08), всё на телефоне и +часах. + +## 2. Стек и соглашения + +- PostgreSQL; сервер — C# / .NET 10; доступ к БД — только + `Kit.Helpers SqlHelperAbstract` (SqlHelperPostgres), строка — + `Db.ConnectionString` (appsettings.json, тест подключения при старте — + фатально при неудаче). +- Логи — `Kit.Helpers Log.Logger` (файл, уровень Info). +- Миграции — psql-файлы в `2-db\migrations\`; каждый применённый файл + фиксируется в `src.migration(filename)`; повторный запуск пропускает + применённые (журнал — источник истины). +- Именование функций — `{сущность}_{действие}`; код функций — + `{функция}.psql`, тесты — `test_{функция}.psql` (db-func-4, db-tst-1). +- Статусная модель функций — d-dev-1..3 (`PLAN → DEV → DONE`; колонки + «Функция»/«Репозиторий»: `- → REQ → CODE → DONE`). + +## 3. Инварианты (🛡 — нарушать запрещено) + +1. **Изоляция атлетов** — все функции слоя `src`, кроме `athlete_*` и + `migration_*`, принимают `p_athlete_id` и выбирают/пишут только в + пределах атлета. +2. **Идемпотентность** — `workout_insert` при повторе `client_guid` + (PK `id` uuid) возвращает существующий id; PK uuid клиентских + сущностей генерирует телефон (строки «только .fit» — сервер). +3. **Неизменяемость истории** — `program_version_update` меняет только + `status` (переходы draft→active→archived; активной становится ровно + одна версия на программу — частичный уникальный индекс); + `command_log` не редактируется; `workout_set` без update/delete + (правка = новая запись `source=edit`); `exercise_delete` — только + логическое (`is_active=0`). +4. **Атлет без удаления** — `athlete` без `_delete`: блокировка + `is_blocked`. + +## 4. Схема данных (сводка; полная — 2-db\src\tables.md, 2-db\ref\tables.md) + +| Таблица | Ключевое | +|---|---| +| `src.athlete` | id serial PK; name; api_key_hash UNIQUE (SHA-256); is_blocked | +| `src.athlete_profile` | 1:1 к athlete (UNIQUE athlete_id); sex, birth_year, weight_kg, hr_max (NULL → Танака) | +| `src.exercise` | id uuid PK; UNIQUE(athlete_id, name); group_id → ref; is_base; is_active | +| `src.exercise_replacement` | ММ-пара (PK exercise_id, replacement_id); CHECK ≠ | +| `src.program` | UNIQUE(athlete_id) — одна программа; title | +| `src.program_version` | id uuid PK; UNIQUE(program_id, version); status draft/active/archived (active — ровно одна); author user/admin/rules/llm; body jsonb; rationale | +| `src.workout` | id uuid PK; UNIQUE(athlete_id, fit_file_id); status active/finished/cancelled; session_kind normal/return/deload/test; facts_json jsonb; hr_recovery_s | +| `src.workout_set` | id uuid PK; athlete_id (денормализация); seq; weight_kg; reps; source voice/watch/edit; in_plan; rpe 1–10; knee_pain 0–10 | +| `src.command_log` | id uuid PK; t_offset_s; recognized_text; semantics jsonb; accepted | +| `src.daily_log` | id uuid PK; UNIQUE(athlete_id, date); bp_sys/dias, weight_kg, hr_resting, wellbeing 1–5 (waist_cm, sleep_h — v2, колонки есть) | +| `src.workout_metrics` | id serial PK; UNIQUE(workout_id); adherence, tonnage, tonnage_by_group/e1pm_by_exercise/hr_metrics/red_flags jsonb | +| `src.breaks` | id uuid PK; kind (NULL до ответа); source auto/manual; started_on; planned_return; ended_on; note | +| `src.migration` | filename UNIQUE; applied_at | +| `ref.exercise_group` | справочник двигательных групп (см. 2-db\ref\tables.md) | + +## 5. Список функций (утверждён 2026-10-17, статусы — 2-db\{src,ref}\functions.md) + +`src`: athlete_insert/update/select/get; athlete_profile_upsert/get; +exercise_insert/update/delete/select/get; exercise_replacement_insert/delete; +program_insert/select/get; program_version_insert/update/select/get; +workout_insert/update/select/get; workout_set_insert/get; +command_log_insert/select; daily_log_upsert/select; +workout_metrics_upsert/select; break_insert/update/select; +migration_insert/select. `ref`: функции справочника (см. ref\functions.md). + +Ключевые контракты из списка: `workout_insert(p_athlete_id, p_id uuid, +p_program_version_id, p_started_at, p_session_kind) → uuid` (идемпотентен); +`workout_select` — карточки + подходы агрегатом jsonb (d-db-6, +мультикурсор); `program_version_select` — без body (body — отдельный +get); `exercise_select` — по умолчанию только активные + пары замен; +`break_insert` — `source=manual` от телефона, `auto` проставляет сервер; +`daily_log_upsert` — по `UNIQUE(athlete_id, date)`. + +## 6. Критерии приёмки модуля + +1. `--mode migrate` на чистой БД применяет все миграции; повторный + запуск — 0 применений; сбой миграции откатывает её и завершается + кодом возврата 1. +2. Все psql-функции списка имеют код и тесты; тесты проходят на чистой + БД (включая проверки изоляции `p_athlete_id`, идемпотентности + `workout_insert`, единственности `active`-версии). +3. DAL-репозитории покрывают все функции; юнит-/интеграционные тесты + репозиториев проходят. +4. Статусы в 2-db\{src,ref}\functions.md доведены до `DONE`. diff --git a/1-docs/4-modules/m01-server-db/readme.md b/1-docs/4-modules/m01-server-db/readme.md new file mode 100644 index 0000000..4982036 --- /dev/null +++ b/1-docs/4-modules/m01-server-db/readme.md @@ -0,0 +1,56 @@ +# Модуль m01-server-db — план разработки + +> План по алгоритму d-dev (`std1\_rules\d\d-dev.md`); отметки статусов +> актуализируются при завершении каждого шага (d-dev-6). Требования — +> [module_reqs.md](module_reqs.md). + +| Шаг d-dev | Состав | Статус | +|---|---|---| +| 0. Персистентная подготовка | module_reqs.md, readme.md, SKILL.md | ✅ | +| 1. Доменные объекты | `Dal\{Src,Ref}\Domain\{Сущность}.cs` (c-dm) | ✅ утверждено (2026-10-17) | +| 1.3. Утверждение домена | — | **Пользователь!** ✅ | +| 2. Список функций к реализации | статусы `DEV` в 2-db\{src,ref}\functions.md | ✅ | +| 2.3. Утверждение списка | — | **Пользователь!** ✅ (шаг 6) | +| 3. Спецификации функций | `func_specs\` (d-db-5, d-db-9) — все 38 | ✅ | +| 3.4. Утверждение спецификаций | — | **Пользователь!** ✅ (группы 1–4, 2026-10-17) | +| 4. Код функций и тесты | миграции 001–002, `2-db\{src,ref}\functions\` (38), `tests\` (38 файлов) | ✅ | +| 4.4. Утверждение реализации функций | — | **Пользователь!** ✅ (2026-10-17; прогон 122/122 PASS, docker `watchbody-pg`; функции `DONE`) | +| 5. Спецификации тестов репозиториев | `repository_tests\` (d-tst) — **11 файлов** на 13 репозиториев (тесты ProgramVersion и ExerciseReplacement — внутри файлов `ProgramRepository.md` / `ExerciseRepository.md`), 38 функций | ✅ утверждено (2026-10-18, ревью сверкой) | +| 5.3. Утверждение | — | **Пользователь!** ✅ | +| 6. Репозитории DAL + тесты | `Server.WatchBody.Core` (13 репозиториев + 7 фильтров, Kit.Helpers fluent), `Server.WatchBody.Core.Tests` (56 тестов) | ✅ код написан; сборка 0 ошибок; тесты 56/56 PASS (числа после З-35) | +| 6.4. Утверждение репозиториев | — | **Пользователь!** ✅ (2026-10-18, после ревью `agent_recommendations.md`) | +| 7. Завершение | статусы функций `DONE`; сборка и тесты зелёные | ✅ (2026-10-18: 38/38 функций `DONE`; тесты 56/56 PASS, psql-прогон 123/123 на docker `watchbody-pg` — числа после З-35) | + +**Модуль завершён** (2026-10-18). Следующий по плану этапа 7 — m02-rest-api. + +Примечания: + +- Разработка поштучно, группы по сущностям (принятый порядок): + migration → ref → athlete/athlete_profile → exercise/replacement → + program/program_version → workout/workout_set → command_log → + daily_log → workout_metrics → breaks. +- Спецификации тестов репозиториев живут в модуле + (`1-docs\4-modules\m01-server-db\repository_tests\`) — адаптация пути + `1-docs\3-core\…` из d-tst-1 к структуре репозитория (модульных + документов). +- Errata (2026-10-18, вскрыто интеграцией m02): jsonb-параметры функций + принимают `text` с кастой в теле (Npgsql передаёт строку как `text`, + неявного cast'а text→jsonb в PG нет) — `program_version_insert.p_body`, + `workout_update.p_facts_json`, `command_log_insert.p_semantics`, + `workout_metrics_upsert` (4 параметра); `program_version_insert` + дополнен идемпотентностью по `p_id` (🛡, как `workout_insert`). + psql-тесты затронутых функций перегнаны — PASS; спецификации обновлены. +- Errata (2026-10-18, вскрыто интеграцией m04): Npgsql возвращает + колонки `date` как `DateOnly` (не `DateTime`) — `DailyLogRepository` + переведён на `AsDate()`; ключи агрегата `workout_select` — snake_case, + `ParseSets` дополнен `JsonNamingPolicy.SnakeCaseLower` (иначе поля + `WorkoutSet`, включая `in_plan`, молча не заполнялись). +- Прогон psql-тестов — PostgreSQL 17 в docker-контейнере `watchbody-pg` + (порт 5433, БД `watchbody`), ручное воспроизведение — pgAdmin (db-tst-1). +- З-35 (2026-10-18, ревью этапа 7): З-31 (`src.program.external_id`) + не был доведён до SQL/DAL-артефактов — исправлено: миграция + `003_program_external_id.sql` (идемпотентная), `program_insert.psql` + rev. 2 (`p_external_id` + DROP прежней сигнатуры: два оверлода + с DEFAULT делают 2-аргументный вызов неоднозначным), spec, домен + `Program.ExternalId`, `ProgramRepository.Insert`; тесты: psql-кейс #3 + (повтор `external_id` → UNIQUE), Core-тест `Insert_2СExternalId`. diff --git a/1-docs/4-modules/m01-server-db/repository_tests/AthleteProfileRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/AthleteProfileRepository.md new file mode 100644 index 0000000..b51bfba --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/AthleteProfileRepository.md @@ -0,0 +1,37 @@ +# Тесты `AthleteProfileRepository` (слой Src) + +> Репозиторий `Server.WatchBody.Core.Dal.Src.Repository`: IAthleteProfileRepository / AthleteProfileRepository +> (правила — c-dal; концепция unit-тестов без БД — c-tst, d-tst). + +## Методы → функции БД + +| Метод | Транспорт | Функция БД | +|---|---|---| +| `Upsert(AthleteProfile)` | scalar int | `src.athlete_profile_upsert(p_athlete_id, p_sex, p_birth_year, p_weight_kg, p_hr_max)` | +| `Get(int athleteId)` | reader | `src.athlete_profile_get(p_athlete_id)` | + +## Механика теста (общая) + +`ConnectionStringTest` (d-tst-11); проверки в момент вызова; 1 тест = +1 вызов (d-tst-13). Nullable-поля профиля передаются +`AddParameterNullable` (NULL ≡ DBNull). + +## Заготовки (DataTable) + +Таблица ответа `athlete_profile_get`: id:int, athlete_id:int, sex:string?, +birth_year:int?, weight_kg:decimal?, hr_max:int?, updated_at:DateTime. + +Строки-фикстуры: `p1` — athlete 1, m, 1978, 158.0, hr_max NULL. + +## Тесты: `Upsert` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | p1 (hr_max = null); scalar = 3 | Upsert→3; hr_max передан как DBNull | + +## Тесты: `Get` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [p1] | сущность; HrMax = null; WeightKg = 158.0 | +| 2 | граничный | пустая таблица | null | diff --git a/1-docs/4-modules/m01-server-db/repository_tests/AthleteRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/AthleteRepository.md new file mode 100644 index 0000000..7a6fc20 --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/AthleteRepository.md @@ -0,0 +1,56 @@ +# Тесты `AthleteRepository` (слой Src) + +> Репозиторий `Server.WatchBody.Core.Dal.Src.Repository`: IAthleteRepository / AthleteRepository +> (правила — c-dal; концепция unit-тестов без БД — c-tst, d-tst). + +## Методы → функции БД + +| Метод | Транспорт | Функция БД | +|---|---|---| +| `Insert(Athlete)` | scalar int | `src.athlete_insert(p_name, p_api_key_hash)` | +| `Update(Athlete)` | scalar int | `src.athlete_update(p_id, p_name, p_api_key_hash, p_is_blocked)` | +| `Select(AthleteFilter?)` | reader | `src.athlete_select(p_api_key_hash, p_is_blocked)` | +| `Get(int)` | reader | `src.athlete_get(p_id)` | + +`AthleteFilter`: `ApiKeyHash` (string?, NULL — не применяется), `IsBlocked` (bool?, NULL — не применяется). + +## Механика теста (общая) + +`ConnectionStringTest` (фейк транспорта, d-tst-11); проверки — в момент +вызова: CommandText = имя функции, IsStoredProcedure = true, строгий +набор параметров в обе стороны; заготовка = один DataTable (один +конвертер); 1 тест = 1 вызов (d-tst-13). + +## Заготовки (DataTable) + +Таблица ответа `athlete_*`: id:int, name:string, api_key_hash:string, +is_blocked:bool, created_at:DateTime. + +Строки-фикстуры: `a1` — TEST-Атлет / TEST-hash-1 / не заблокирован; +`a2` — TEST-Атлет-2 / TEST-hash-2 / заблокирован. + +## Тесты: `Insert` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | фикстура a1; scalar = 7 | Insert→7; параметры p_name, p_api_key_hash; p_is_blocked/p_created_at не передаются | + +## Тесты: `Update` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | a1 с новым именем; scalar = 1 | Update→1; параметры p_id, p_name, p_api_key_hash, p_is_blocked (все поля, db-m-8) | + +## Тесты: `Select` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [a1]; фильтр ApiKeyHash | 1 сущность; поля строго по конвертеру | +| 2 | граничный | пустая таблица (колонки без строк); фильтры NULL | пустая коллекция (не null); параметры DBNull | + +## Тесты: `Get` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [a1] | сущность с Id=a1.Id | +| 2 | граничный | пустая таблица | null | diff --git a/1-docs/4-modules/m01-server-db/repository_tests/BreakRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/BreakRepository.md new file mode 100644 index 0000000..1512f8e --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/BreakRepository.md @@ -0,0 +1,48 @@ +# Тесты `BreakRepository` (слой Src) + +> Репозиторий `Server.WatchBody.Core.Dal.Src.Repository`: +> IBreakRepository / BreakRepository (правила — c-dal; unit-тесты +> без БД — c-tst, d-tst). + +## Методы → функции БД + +| Метод | Транспорт | Функция БД | +|---|---|---| +| `Insert(Break)` → Guid | scalar Guid | `src.break_insert(p_athlete_id, p_id, p_kind, p_source, p_started_on, p_planned_return, p_note)` | +| `Update(Break)` → int | scalar int | `src.break_update` (все поля, db-m-8; kind NULL валиден) | +| `Select(BreakFilter?)` → IEnumerable | reader | `src.break_select(p_athlete_id, p_kind, p_date_from, p_date_to)` | + +`BreakFilter`: `Kind` (string?), `DateFrom` (DateOnly?), `DateTo` (DateOnly?). + +Delete нет — журнал перерывов (🛡); закрытие = Update (`EndedOn`). + +## Механика теста (общая) + +`ConnectionStringTest` (d-tst-11); 1 тест = 1 вызов (d-tst-13); +Kind = null передаётся `AddParameterNullable`. + +## Заготовки (DataTable) + +Таблица `breaks_*`: id:Guid, athlete_id:int, kind:string?, source:string, +started_on:DateOnly, planned_return:DateOnly?, ended_on:DateOnly?, note:string. +Строки: `b1` — manual, vacation, открыт (ended_on NULL); +`b2` — auto, kind NULL (ответа нет). + +## Тесты: `Insert` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | b2 (kind null); scalar = Guid b2 | 7 параметров; kind → DBNull | + +## Тесты: `Update` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | b1 с ответом; scalar = 1 | 6 параметров (все поля, db-m-8) | + +## Тесты: `Select` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [b1, b2]; фильтры NULL | 2 сущности; b2.Kind = null | +| 2 | граничный | пустая таблица | пустая коллекция (не null) | diff --git a/1-docs/4-modules/m01-server-db/repository_tests/DailyLogRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/DailyLogRepository.md new file mode 100644 index 0000000..be95390 --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/DailyLogRepository.md @@ -0,0 +1,39 @@ +# Тесты `DailyLogRepository` (слой Src) + +> Репозиторий `Server.WatchBody.Core.Dal.Src.Repository`: +> IDailyLogRepository / DailyLogRepository (правила — c-dal; unit-тесты +> без БД — c-tst, d-tst). + +## Методы → функции БД + +| Метод | Транспорт | Функция БД | +|---|---|---| +| `Upsert(DailyLog)` → Guid | scalar Guid | `src.daily_log_upsert(p_athlete_id, p_id, p_date, p_bp_sys, p_bp_dias, p_weight_kg, p_hr_resting, p_wellbeing)` | +| `Select(DailyLogFilter?)` → IEnumerable | reader | `src.daily_log_select(p_athlete_id, p_date_from, p_date_to)` | + +`DailyLogFilter`: `DateFrom` (DateOnly?), `DateTo` (DateOnly?). + +## Механика теста (общая) + +`ConnectionStringTest` (d-tst-11); v2-поля (`waist_cm`, `sleep_h`) в v1 +не передаются и не читаются — конвертер их не ждёт. + +## Заготовки (DataTable) + +Таблица `daily_log_*`: id:Guid, athlete_id:int, date:DateOnly, bp_sys:int?, +bp_dias:int?, weight_kg:decimal?, hr_resting:int?, waist_cm:decimal?, +sleep_h:decimal?, wellbeing:int?. +Строки: `d1` — 2026-10-17, 130/80, 157.5, 60, wellbeing 5. + +## Тесты: `Upsert` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | d1; scalar = Guid d1 | 8 параметров | + +## Тесты: `Select` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [d1]; фильтры NULL | 1 сущность; поля строго по конвертеру | +| 2 | граничный | пустая таблица | пустая коллекция (не null) | diff --git a/1-docs/4-modules/m01-server-db/repository_tests/ExerciseGroupRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/ExerciseGroupRepository.md new file mode 100644 index 0000000..f65a1f8 --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/ExerciseGroupRepository.md @@ -0,0 +1,29 @@ +# Тесты `ExerciseGroupRepository` (слой Ref) + +> Репозиторий `Server.WatchBody.Core.Dal.Ref.Repository`: +> IExerciseGroupRepository / ExerciseGroupRepository (правила — c-dal; +> unit-тесты без БД — c-tst, d-tst). Системный справочник — только чтение +> (c-dal-2). + +## Методы → функции БД + +| Метод | Транспорт | Функция БД | +|---|---|---| +| `Select()` → IEnumerable | reader | `ref.exercise_group_select()` | + +## Механика теста (общая) + +`ConnectionStringTest` (d-tst-11); функция без параметров — заявка без +параметров; 1 тест = 1 вызов. + +## Заготовки (DataTable) + +Таблица ответа `exercise_group_select`: id:int, code:string, title:string. +Строки: `g1` — 1, push, Жимовые; `g2` — 3, legs, Ноги. + +## Тесты: `Select` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [g1, g2] | 2 сущности; поля строго по конвертеру | +| 2 | граничный | пустая таблица | пустая коллекция (не null) | diff --git a/1-docs/4-modules/m01-server-db/repository_tests/ExerciseRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/ExerciseRepository.md new file mode 100644 index 0000000..fd3025f --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/ExerciseRepository.md @@ -0,0 +1,77 @@ +# Тесты `ExerciseRepository` и `ExerciseReplacementRepository` (слой Src) + +> Репозитории `Server.WatchBody.Core.Dal.Src.Repository`: +> IExerciseRepository / ExerciseRepository, IExerciseReplacementRepository / ExerciseReplacementRepository +> (правила — c-dal; unit-тесты без БД — c-tst, d-tst). + +## Методы → функции БД + +| Репозиторий | Метод | Функция БД | +|---|---|---| +| Exercise | `Insert(Exercise)` → Guid | `src.exercise_insert` | +| Exercise | `Update(Exercise)` → int | `src.exercise_update` | +| Exercise | `Delete(int athleteId, Guid exerciseId)` → int | `src.exercise_delete` | +| Exercise | `Select(ExerciseFilter?)` → IEnumerable | `src.exercise_select` | +| Exercise | `Get(int athleteId, Guid exerciseId)` → Exercise? | `src.exercise_get` | +| ExerciseReplacement | `Insert(int athleteId, Guid exerciseId, Guid replacementId)` → int | `src.exercise_replacement_insert` | +| ExerciseReplacement | `Delete(int athleteId, Guid exerciseId, Guid replacementId)` → int | `src.exercise_replacement_delete` | + +`ExerciseFilter`: `Name` (string?), `GroupId` (int?), `IsBase` (bool?), `IsActive` (bool?); NULL — не применяется. + +## Механика теста (общая) + +`ConnectionStringTest` (d-tst-11); один DataTable = один конвертер; +замены приходят денормализованно колонкой `replacement_ids` (jsonb) — +конвертер разбирает её в `ReplacementIds`. + +## Заготовки (DataTable) + +Таблица ответа `exercise_*`: id:Guid, athlete_id:int, name:string, +group_id:int, is_base:bool, is_active:bool, replacement_ids:string (jsonb). + +Строки-фикстуры: `e1` — TEST-жим, группа 1, активное, replacement_ids='[]'; +`e2` — TEST-тяга, группа 2, активное, replacement_ids='[""]'. + +## Тесты: `Insert` (Exercise) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | e1; scalar = Guid e1.Id | Insert→Guid; параметры p_athlete_id, p_id, p_name, p_group_id, p_is_base | + +## Тесты: `Update` (Exercise) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | e1 с новым именем; scalar = 1 | 5 параметров (все поля, db-m-8); ReplacementIds не передаётся | + +## Тесты: `Delete` (Exercise) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | scalar = 1 | Delete→1; параметры p_athlete_id, p_id | + +## Тесты: `Select` (Exercise) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [e1, e2]; фильтр IsActive=true | 2 сущности; у e2 ReplacementIds = 1 Guid | +| 2 | граничный | пустая таблица; фильтры NULL | пустая коллекция; NULL-фильтры = DBNull | + +## Тесты: `Get` (Exercise) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [e2] | сущность с ReplacementIds = 1 Guid | +| 2 | граничный | пустая таблица | null | + +## Тесты: `Insert` (ExerciseReplacement) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | scalar = 1 | 3 параметра: p_athlete_id, p_exercise_id, p_replacement_id | + +## Тесты: `Delete` (ExerciseReplacement) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | scalar = 1 | 3 параметра; возврат 1 | diff --git a/1-docs/4-modules/m01-server-db/repository_tests/MigrationRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/MigrationRepository.md new file mode 100644 index 0000000..a714b5a --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/MigrationRepository.md @@ -0,0 +1,34 @@ +# Тесты `MigrationRepository` (слой Src) + +> Репозиторий `Server.WatchBody.Core.Dal.Src.Repository`: +> IMigrationRepository / MigrationRepository (правила — c-dal; unit-тесты +> без БД — c-tst, d-tst). Служебный репозиторий режима `--mode migrate`. + +## Методы → функции БД + +| Метод | Транспорт | Функция БД | +|---|---|---| +| `Insert(string filename)` → int | scalar int | `src.migration_insert(p_filename)` | +| `Select(string filename)` → Migration? | reader | `src.migration_select(p_filename)` | + +## Механика теста (общая) + +`ConnectionStringTest` (d-tst-11); 1 тест = 1 вызов (d-tst-13). + +## Заготовки (DataTable) + +Таблица ответа `migration_select`: id:int, filename:string, applied_at:DateTime. +Строки: `m1` — TEST-0001.sql. + +## Тесты: `Insert` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | 'TEST-0001.sql'; scalar = 1 | Insert→1; параметр p_filename | + +## Тесты: `Select` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [m1] | сущность с Filename = 'TEST-0001.sql' | +| 2 | граничный | пустая таблица (миграции нет) | null | diff --git a/1-docs/4-modules/m01-server-db/repository_tests/ProgramRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/ProgramRepository.md new file mode 100644 index 0000000..93921f3 --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/ProgramRepository.md @@ -0,0 +1,87 @@ +# Тесты `ProgramRepository` и `ProgramVersionRepository` (слой Src) + +> Репозитории `Server.WatchBody.Core.Dal.Src.Repository`: +> IProgramRepository / ProgramRepository, IProgramVersionRepository / ProgramVersionRepository +> (правила — c-dal; unit-тесты без БД — c-tst, d-tst). + +## Методы → функции БД + +| Репозиторий | Метод | Функция БД | +|---|---|---| +| Program | `Insert(Program)` → int | `src.program_insert` | +| Program | `Select(int athleteId)` → IEnumerable | `src.program_select` | +| Program | `Get(int athleteId, int programId)` → Program? | `src.program_get` | +| ProgramVersion | `Insert(ProgramVersion)` → Guid | `src.program_version_insert` | +| ProgramVersion | `UpdateStatus(int athleteId, Guid versionId, string status)` → int | `src.program_version_update` | +| ProgramVersion | `Select(ProgramVersionFilter?)` → IEnumerable | `src.program_version_select` | +| ProgramVersion | `Get(int athleteId, Guid versionId)` → ProgramVersion? | `src.program_version_get` | + +`ProgramVersionFilter`: `ProgramId` (int?), `Status` (string?), `Author` (string?). + +Примечание: `_update` меняет только статус (🛡) — специализированное имя +`UpdateStatus` вместо Update (c-dal-5). + +## Механика теста (общая) + +`ConnectionStringTest` (d-tst-11); Program содержит денормализацию +`active_version_id`/`active_version` (конвертер заполняет +ActiveVersionId/ActiveVersionNumber); ProgramVersion.Select — без body +колонки (конвертер не ждёт её); ProgramVersion.Get — с body. + +## Заготовки (DataTable) + +Таблица `program_*`: id:int, athlete_id:int, title:string, created_at:DateTime, +active_version_id:Guid?, active_version:int?. +Строки: `pr1` — TEST-Программа, active_version_id NULL; +`pr2` — TEST-Программа, active версия 2. + +Таблица `program_version_select`: id:Guid, program_id:int, version:int, +based_on_version:int?, status:string, author:string, rationale:string, created_at:DateTime. +Таблица `program_version_get`: та же + body:string. +Строки: `v1` — version 1, draft, based_on NULL, body='{}'; +`v2` — version 2, active, based_on 1. + +## Тесты: `Insert` (Program) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | pr1; scalar = 5 | Insert→5; параметры p_athlete_id, p_title | + +## Тесты: `Select` (Program) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [pr1, pr2] | 2 сущности; pr2.ActiveVersionNumber = 2; pr1 — null | + +## Тесты: `Get` (Program) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [pr2] | сущность с ActiveVersionId | +| 2 | граничный | пустая таблица | null | + +## Тесты: `Insert` (ProgramVersion) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | v1; scalar = Guid v1 | параметры p_athlete_id, p_id, p_program_id, p_based_on_version, p_author, p_body, p_rationale | + +## Тесты: `UpdateStatus` (ProgramVersion) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | v1 → active; scalar = 1 | параметры p_athlete_id, p_id, p_status='active' | + +## Тесты: `Select` (ProgramVersion) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [v1, v2]; фильтры NULL | 2 сущности; body-колонки в таблице нет | +| 2 | граничный | пустая таблица | пустая коллекция | + +## Тесты: `Get` (ProgramVersion) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [v2] (с body) | сущность; Body = '{}' | +| 2 | граничный | пустая таблица | null | diff --git a/1-docs/4-modules/m01-server-db/repository_tests/WorkoutMetricsRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/WorkoutMetricsRepository.md new file mode 100644 index 0000000..e13c741 --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/WorkoutMetricsRepository.md @@ -0,0 +1,43 @@ +# Тесты `WorkoutMetricsRepository` (слой Src) + +> Репозиторий `Server.WatchBody.Core.Dal.Src.Repository`: +> IWorkoutMetricsRepository / WorkoutMetricsRepository (правила — c-dal; +> unit-тесты без БД — c-tst, d-tst). + +## Методы → функции БД + +| Метод | Транспорт | Функция БД | +|---|---|---| +| `Upsert(WorkoutMetrics)` → int | scalar int | `src.workout_metrics_upsert(p_athlete_id, p_workout_id, p_adherence, p_tonnage, p_tonnage_by_group, p_e1pm_by_exercise, p_hr_metrics, p_red_flags)` | +| `Select(WorkoutMetricsFilter?)` → IEnumerable | reader | `src.workout_metrics_select` (строка + started_at) | + +`WorkoutMetricsFilter`: `WorkoutId` (Guid?), `DateFrom` (DateOnly?), `DateTo` (DateOnly?). + +Примечание: `computed_at` не передаётся — ставит функция. + +## Механика теста (общая) + +`ConnectionStringTest` (d-tst-11); jsonb-поля передаются строками; +конвертер читает их как string (разбор JSON — вне DAL, JsonHelper +на уровне BLL). + +## Заготовки (DataTable) + +Таблица `workout_metrics_*`: id:int, workout_id:Guid, athlete_id:int, +adherence:decimal, tonnage:decimal, tonnage_by_group:string, +e1pm_by_exercise:string, hr_metrics:string, red_flags:string, +computed_at:DateTime, started_at:DateTime. +Строки: `mt1` — adherence 92.5, tonnage 4200.0. + +## Тесты: `Upsert` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | mt1; scalar = 9 | 8 параметров; возврат 9 | + +## Тесты: `Select` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [mt1] | 1 сущность; StartedAt заполнен | +| 2 | граничный | пустая таблица | пустая коллекция (не null) | diff --git a/1-docs/4-modules/m01-server-db/repository_tests/WorkoutRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/WorkoutRepository.md new file mode 100644 index 0000000..97095eb --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/WorkoutRepository.md @@ -0,0 +1,60 @@ +# Тесты `WorkoutRepository` (слой Src) + +> Репозиторий `Server.WatchBody.Core.Dal.Src.Repository`: +> IWorkoutRepository / WorkoutRepository (правила — c-dal; unit-тесты +> без БД — c-tst, d-tst). + +## Методы → функции БД + +| Метод | Транспорт | Функция БД | +|---|---|---| +| `Insert(Workout)` → Guid | scalar Guid | `src.workout_insert(p_athlete_id, p_id, p_program_version_id, p_started_at, p_session_kind)` | +| `Update(Workout)` → int | scalar int | `src.workout_update` (все поля, db-m-8) | +| `Select(WorkoutFilter?)` → IEnumerable | reader | `src.workout_select` (карточка + sets jsonb) | +| `Get(int athleteId, Guid workoutId)` → Workout? | reader | `src.workout_get` (строка + sets + commands jsonb) | + +`WorkoutFilter`: `Status` (string?), `DateFrom` (DateOnly?), `DateTo` (DateOnly?). + +Примечание: `program_version_id` передаётся только в Insert; Update +программную версию не меняет (функция её не пишет). + +## Механика теста (общая) + +`ConnectionStringTest` (d-tst-11); один DataTable = один конвертер +(d-tst-13). Select/Get читают денормализованные `sets`/`commands` (jsonb) +— конвертер разбирает их в коллекции `Workout.Sets` / `Workout.Commands`. + +## Заготовки (DataTable) + +Таблица `workout_select`: id:Guid, athlete_id:int, program_version_id:Guid?, +started_at:DateTime, ended_at:DateTime?, status:string, session_kind:string, +avg_hr:int?, facts_json:string?, fit_file_id:string?, hr_recovery_s:int?, +sets:string (jsonb), commands:string (jsonb — только в get). +Строки: `w1` — normal, active, sets='[{"seq":1},{"seq":2}]', +commands='[{"t_offset_s":65}]'; `w2` — тот же профиль, sets='[]', commands='[]'. + +## Тесты: `Insert` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | w1 без версии (program_version_id = null); scalar = Guid w1 | 5 параметров; NULL → DBNull | + +## Тесты: `Update` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | w1 c фактами FitParser; scalar = 1 | параметры p_athlete_id, p_id, p_ended_at, p_status, p_session_kind, p_avg_hr, p_facts_json, p_fit_file_id, p_hr_recovery_s | + +## Тесты: `Select` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [w1] | сущность; Sets = 2 элемента по порядку jsonb | +| 2 | граничный | пустая таблица; фильтры NULL | пустая коллекция | + +## Тесты: `Get` + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [w1] | сущность; Sets = 2, Commands = 1 | +| 2 | граничный | пустая таблица | null | diff --git a/1-docs/4-modules/m01-server-db/repository_tests/WorkoutSetRepository.md b/1-docs/4-modules/m01-server-db/repository_tests/WorkoutSetRepository.md new file mode 100644 index 0000000..217f238 --- /dev/null +++ b/1-docs/4-modules/m01-server-db/repository_tests/WorkoutSetRepository.md @@ -0,0 +1,58 @@ +# Тесты `WorkoutSetRepository` и `CommandLogRepository` (слой Src) + +> Репозитории `Server.WatchBody.Core.Dal.Src.Repository`: +> IWorkoutSetRepository / WorkoutSetRepository, ICommandLogRepository / CommandLogRepository +> (правила — c-dal; unit-тесты без БД — c-tst, d-tst). + +## Методы → функции БД + +| Репозиторий | Метод | Функция БД | +|---|---|---| +| WorkoutSet | `Insert(WorkoutSet)` → Guid | `src.workout_set_insert` (12 параметров) | +| WorkoutSet | `Get(int athleteId, Guid setId)` → WorkoutSet? | `src.workout_set_get` | +| CommandLog | `Insert(CommandLog)` → Guid | `src.command_log_insert` (7 параметров) | +| CommandLog | `Select(int athleteId, Guid workoutId)` → IEnumerable | `src.command_log_select` | + +Update/Delete не существуют: подход правится новой записью `source=edit`, +журнал команд не редактируется (🛡) — c-dal-2 (набор по функциям БД). + +## Механика теста (общая) + +`ConnectionStringTest` (d-tst-11); 1 тест = 1 вызов (d-tst-13). + +## Заготовки (DataTable) + +Таблица `workout_set_*`: id:Guid, workout_id:Guid, athlete_id:int, +exercise_id:Guid?, seq:int, weight_kg:decimal, reps:int, done_at:DateTime, +source:string, in_plan:bool, rpe:int?, knee_pain:int?. +Строки: `s1` — seq 1, 50.0×15, voice, rpe NULL. + +Таблица `command_log_*`: id:Guid, workout_id:Guid, t_offset_s:int, +recognized_text:string, semantics:string (jsonb), accepted:bool. +Строки: `c1` — 65 с, 'TEST-вес пятьдесят', accepted. + +## Тесты: `Insert` (WorkoutSet) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | s1 (rpe/knee_pain/exercise_id null); scalar = Guid s1 | 12 параметров; NULL → DBNull | + +## Тесты: `Get` (WorkoutSet) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [s1] | сущность; Rpe = null | +| 2 | граничный | пустая таблица | null | + +## Тесты: `Insert` (CommandLog) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | c1; scalar = Guid c1 | 7 параметров; Semantics как jsonb-строка | + +## Тесты: `Select` (CommandLog) + +| № | Тип | Условие | Ожидаемый результат | +|---|---|---|---| +| 1 | основной | таблица [c1] | 1 сущность; поля строго по конвертеру | +| 2 | граничный | пустая таблица | пустая коллекция (не null) | diff --git a/1-docs/4-modules/m02-rest-api/SKILL.md b/1-docs/4-modules/m02-rest-api/SKILL.md new file mode 100644 index 0000000..0b71f07 --- /dev/null +++ b/1-docs/4-modules/m02-rest-api/SKILL.md @@ -0,0 +1,30 @@ +# Навык: модуль m02-rest-api (REST API сервера WatchBody) + +> Инструкция ИИ-агенту для продолжения работы над модулем. Модуль — +> детерминированный код; навык — только инструкция (d-dev-6). + +## Контекст + +Модуль реализует REST API сервера v1 системы WatchBody («Феникс 6X»): +проект `Server.WatchBody.Web` (ASP.NET Core minimal API), middleware +`X-Api-Key`, `POST /api/v1/sync`, чтение тренировок и активной +программы. Алгоритм — d-dev (`std1\_rules\d\d-dev.md`); требования +модуля — `1-docs\4-modules\m02-rest-api\module_reqs.md` +(самодостаточна); план и статусы шагов — `readme.md` рядом. + +## Как продолжать + +1. Прочитай `1-docs\4-modules\m02-rest-api\readme.md` — найди первый + шаг со статусом ⬜ и продолжай с него (прерывание/продолжение — + d-dev-6). +2. Каждый шаг с пометкой «**Пользователь!**» завершается остановкой и + явным утверждением пользователя — переход без утверждения запрещён + (d-dev-4). +3. Код модуля — `3-app\2-cli\Server.WatchBody.Web` (+ тесты в + `Server.WatchBody.Web.Tests`); доступ к БД — только репозитории + m01 из `Server.WatchBody.Core` (Kit.Helpers), прямой SQL запрещён. +4. Формат пакета sync и сущностей `data` — module_reqs разд. 4 + (копия tech-requirements 2.1–2.2); инварианты 🛡 разд. 6 нарушать + запрещено. +5. Интеграционные тесты — WebApplicationFactory против PostgreSQL 17 + в docker `watchbody-pg` (порт 5433, БД `watchbody`). diff --git a/1-docs/4-modules/m02-rest-api/api_specs/01-config-bootstrap.md b/1-docs/4-modules/m02-rest-api/api_specs/01-config-bootstrap.md new file mode 100644 index 0000000..b8f95cd --- /dev/null +++ b/1-docs/4-modules/m02-rest-api/api_specs/01-config-bootstrap.md @@ -0,0 +1,46 @@ +# Спецификация: конфигурация и стартовая инициализация (единица 1) + +## Назначение + +Чтение `appsettings.json`, валидация при старте (tech-requirements 4.2), +тест подключения к БД; идемпотентная синхронизация атлетов из конфига +с `src.athlete` (upsert по хэшу ключа). + +## Конфигурация + +| Параметр | Тип | Валидация | +|---|---|---| +| `Api.Key` | string | непустой; уникальный среди `Api.Athletes[].apiKey` | +| `Api.Athletes` | list | каждый `{name, apiKey}`: оба поля непусты; дубликаты `apiKey` — фатально | +| `Db.ConnectionString` | string | непустой; тест подключения при старте — фатально при неудаче | + +Ошибка любой проверки: запись в лог (`Log.Logger`, уровень Error) + stderr, код возврата процесса 1. + +## Логика + +1. Загрузить и провалидировать конфиг (порядок: `Api.Key` → `Api.Athletes` → `Db.ConnectionString`). +2. Тест подключения к БД (репозиторий `MigrationRepository` или прямой запрос `SELECT 1` через Kit.Helpers). +3. Для каждого атлета из `Api.Athletes`: + - `hash = SHA256(apiKey)` (hex, lower-case — тот же формат, что пишет m08 при создании атлета); + - `athlete_select` по `p_api_key_hash = hash`; + - если не найден — `athlete_insert(p_name, p_api_key_hash)`; + - если найден и имя отличается — `athlete_update` (имя из конфига — источник); + - 🛡 `is_blocked` конфигом не переопределяется — источник блокировки только БД. + +## Тесты + +| # | Проверка | Ожидание | +|---|---|---| +| 1 | Пустой `Api.Key` | код возврата 1, ошибка в лог/stderr | +| 2 | Дубликат `apiKey` среди `Api.Athletes` | код возврата 1 | +| 3 | Недоступная БД (`Db.ConnectionString` → неверный порт) | код возврата 1 | +| 4 | Первый старт: атлет конфига отсутствует в БД | создан `src.athlete` с SHA-256-хэшем ключа | +| 5 | Повторный старт (идемпотентность) | дублей атлетов нет | +| 6 | Переименование атлета в конфиге | имя в БД обновлено, id сохранён | + +## Пример конфига + +```json +{ "Api": { "Key": "owner-key", "Athletes": [ { "name": "Иван", "apiKey": "ivan-key-1" } ] }, + "Db": { "ConnectionString": "Host=127.0.0.1;Port=5433;Database=watchbody;Username=postgres;Password=watchbody" } } +``` diff --git a/1-docs/4-modules/m02-rest-api/api_specs/02-api-key-middleware.md b/1-docs/4-modules/m02-rest-api/api_specs/02-api-key-middleware.md new file mode 100644 index 0000000..d5708c2 --- /dev/null +++ b/1-docs/4-modules/m02-rest-api/api_specs/02-api-key-middleware.md @@ -0,0 +1,37 @@ +# Спецификация: middleware `X-Api-Key` (единица 2) + +## Назначение + +Аутентификация запроса: ключ атлета → `athlete_id` (requirements Ф-8.5, +Ф-7.2, 🛡 изоляция). Все эндпоинты, кроме `GET /health`, за middleware. + +## Вход / выход + +- Вход: заголовок `X-Api-Key`. +- Выход: `HttpContext.Items["athlete_id"] = int` (id из `src.athlete`). + +## Логика + +1. Нет заголовка → `401 {"error": "unauthorized"}`. +2. `hash = SHA256(значение)`; `athlete_select` по `p_api_key_hash = hash`. +3. Не найден или `is_blocked = true` → `401` (тело то же; различие + «чужой/заблокирован» наружу не выдаётся). +4. Найден и не заблокирован → `Items["athlete_id"] = id`; вызов следующего обработчика. +5. Результат поиска кэшируется в пределах запроса (один запрос к БД на запрос). + +## Тесты + +| # | Проверка | Ожидание | +|---|---|---| +| 1 | Запрос без `X-Api-Key` | 401 | +| 2 | Неизвестный ключ | 401 | +| 3 | Заблокированный ключ (`is_blocked = true`) | 401 | +| 4 | Валидный ключ → `GET /api/v1/workouts` | 200, данные только этого атлета | +| 5 | `GET /health` без ключа | 200 | + +## Пример + +``` +GET /api/v1/workouts HTTP/1.1 +X-Api-Key: ivan-key-1 +``` diff --git a/1-docs/4-modules/m02-rest-api/api_specs/03-sync-post.md b/1-docs/4-modules/m02-rest-api/api_specs/03-sync-post.md new file mode 100644 index 0000000..8271b0d --- /dev/null +++ b/1-docs/4-modules/m02-rest-api/api_specs/03-sync-post.md @@ -0,0 +1,86 @@ +# Спецификация: `POST /api/v1/sync` (единица 3) + +> Формат пакета — module_reqs разд. 4 (копия tech-requirements 2.1–2.2). + +## Запрос + +JSON-массив записей (пакет ≤ 50): + +```json +[ + { "client_guid": "uuid-v4", + "entity": "workout | workout_set | exercise | program_version | profile | daily_log | break", + "op": "upsert", + "data": { } } +] +``` + +## Ответы + +| Код | Когда | Тело | +|---|---|---| +| 200 | все записи пакета записаны | `{ "accepted": ["guid1", ...] }` — GUID пакета в исходном порядке | +| 400 | пакет > 50; неизвестная `entity`; `op` ≠ `upsert`; невалидный GUID; нарушение схемы/диапазонов; `break` c `source = "auto"`; невалидный JSON | `{ "error": "<текст>" }` | +| 409 | конфликт версии программы (`UNIQUE(program_id, version)`) | `{ "error": "program_version_conflict" }` | +| 401 | нет/чужой/заблокирован ключ (middleware) | `{ "error": "unauthorized" }` | + +## Логика + +1. Десериализация и валидация пакета (все записи до начала записи); + любая ошибка схемы → `400`, в БД ничего не пишется. +2. Одна транзакция на пакет (🛡 атомарность «все или ничего») — + `TransactionScope`; Kit.Helpers открывает соединение на каждый вызов, + Npgsql подключает их к ambient-транзакции. Все ЧТЕНИЯ (проверки 409, + группы, существование workout'ов) выполняются до открытия + транзакции — select-функции Kit открывают собственную транзакцию + (вложенная запрещена); внутри — только записи: + - записи применяются в порядке массива; + - `athlete_id` для всех записей — из middleware (🛡, из тела не берётся). +3. Маппинг (репозитории m01): + - `workout` → `WorkoutRepository.Insert` (идемпотентен по uuid; + если в `data` есть `ended_at`/`status`/`avg_hr` сверх вставки — + следом `Update` для этих полей); + - `workout_set` → `WorkoutSetRepository.Insert` (идемпотентен); + `workout_id` должен принадлежать атлету — иначе исключение из + функции БД → пакет отклоняется (`400`, текст ошибки функции); + - `exercise` → `ExerciseRepository.Insert` (идемпотентен; + `group` → `group_id` по коду через `ExerciseGroupRepository`); + - `program_version` → при отсутствии программы атлета сначала + `ProgramRepository.Insert` (`title` = «Программа»), затем + `ProgramVersionRepository.Insert` (`body` — как есть); + 409-проверка — в пре-проходе (п. 2): `based_on_version` из записи + ≠ актуальная максимальная версия → `409` (телефон перечитывает + программу); UNIQUE-конфликт в `program_version_insert` — тоже `409`; + - `profile` → `AthleteProfileRepository.Upsert`; + - `daily_log` → `DailyLogRepository.Upsert` (по `UNIQUE(athlete_id, date)`); + - `break` → `BreakRepository.Insert` (`source` — только `manual`). +4. 🛡 Идемпотентность: PK uuid сущностей = `client_guid`; повтор пакета — + insert-функции возвращают существующие id, дублей нет; ответ — тот же + `accepted` (для этого `program_version_insert` дополнен + идемпотентностью по `p_id` — errata m01, 2026-10-18). +5. Ошибка БД внутри транзакции → rollback → `400` (или `409` для + конфликта версии программы), текст ошибки — в лог. + +## Тесты + +| # | Проверка | Ожидание | +|---|---|---| +| 1 | Валидный пакет (по одной записи каждой сущности) | 200, `accepted` = все GUID, строки в БД | +| 2 | Повтор того же пакета | 200, тот же `accepted`, дублей в БД нет | +| 3 | Пакет из 51 записи | 400 | +| 4 | Неизвестная `entity` | 400 | +| 5 | `break` с `source = "auto"` | 400 | +| 6 | `rpe = 11` | 400, в БД ничего из пакета не записано | +| 7 | Вторая версия программы с тем же `version` (перезапись uuid) | 409 | +| 8 | `workout_set` с чужим `workout_id` (другой атлет) | 400, пакет откатан | +| 9 | Пакет из 50 записей | 200 | +| 10 | `program_version` при отсутствии программы атлета | 200; созданы `program` и `program_version` | + +## Пример вызова + +```bash +curl -X POST https://host/api/v1/sync -H "X-Api-Key: ivan-key-1" -H "Content-Type: application/json" \ + -d '[{"client_guid":"0b8f...","entity":"daily_log","op":"upsert", + "data":{"date":"2026-10-17","bp_sys":128,"bp_dias":82,"weight_kg":84.5,"hr_resting":58,"wellbeing":4}}]' +# → {"accepted":["0b8f..."]} +``` diff --git a/1-docs/4-modules/m02-rest-api/api_specs/04-workouts-get.md b/1-docs/4-modules/m02-rest-api/api_specs/04-workouts-get.md new file mode 100644 index 0000000..6d57ca2 --- /dev/null +++ b/1-docs/4-modules/m02-rest-api/api_specs/04-workouts-get.md @@ -0,0 +1,40 @@ +# Спецификация: `GET /api/v1/workouts` и `GET /api/v1/workouts/{id}` (единицы 4–5) + +## `GET /api/v1/workouts` — список с пагинацией + +Запрос: `?limit=` (1–100, умолчание 20), `?offset=` (≥ 0, умолчание 0); +вне диапазона — `400`. + +Ответ `200`: + +```json +{ "items": [ { "workout_id": "uuid", "started_at": "...", "ended_at": "...", + "status": "finished", "session_kind": "normal", "avg_hr": 132, + "sets": [ { "set_id": "uuid", "exercise_id": "uuid", "seq": 1, + "weight_kg": 60.0, "reps": 10, "rpe": 8, "knee_pain": null } ] } ], + "total": 42 } +``` + +Реализация — `WorkoutRepository.Select` (`workout_select`: карточки + +подходы агрегатом); `total` — COUNT по фильтру (без пагинации). +Примечание (З-40): пагинация — в памяти (выборка атлета целиком, +`limit`/`offset` режут результат) — известное ограничение v1. + +Тесты: 1) список только этого атлета (🛡 — второй атлет не виден); +2) `limit`/`offset` режут выборку корректно; 3) `limit = 0` / `limit = 101` → 400. + +## `GET /api/v1/workouts/{id}` — карточка + +Ответ `200` — полная карточка (поля карточки + `sets[]` + `command_log[]` +с `t_offset_s`, `recognized_text`, `semantics`, `accepted`). + +| Код | Когда | +|---|---| +| 404 | тренировка не найдена **или** принадлежит другому атлету (🛡 различие не выдаётся) | +| 400 | `{id}` — не UUID | + +Реализация — `WorkoutRepository.Get` (`workout_get`: карточка + sets + +command_log) с проверкой `athlete_id`. + +Тесты: 1) своя тренировка → 200 с sets и command_log; +2) чужая тренировка → 404; 3) не-UUID id → 400. diff --git a/1-docs/4-modules/m02-rest-api/api_specs/05-programs-active-get.md b/1-docs/4-modules/m02-rest-api/api_specs/05-programs-active-get.md new file mode 100644 index 0000000..ba485f2 --- /dev/null +++ b/1-docs/4-modules/m02-rest-api/api_specs/05-programs-active-get.md @@ -0,0 +1,37 @@ +# Спецификация: `GET /api/v1/programs/active` (единица 6) + +## Назначение + +Активная версия программы атлета (requirements Ф-8.5; телефон +перечитывает программу после 409 или по запросу пользователя). + +## Запрос / ответ + +- Запрос: без параметров (атлет — из middleware). +- `200`: + +```json +{ "program_id": 3, "version_id": "uuid", "version": 5, + "based_on_version": 4, "author": "rules", "status": "active", + "body": { "...схема LLM-планирования разд. 2, как в БД..." }, + "rationale": "«...»", "created_at": "..." } +``` + +- `404` — активной версии нет (программа не создана или все версии archived). +- `401` — middleware. + +## Логика + +1. `ProgramRepository.Select` (программа атлета). +2. `ProgramVersionRepository.Select` (`p_program_id`, `p_status = "active"`). +3. `ProgramVersionRepository.Get` (строка с `body`). +4. Активной версии нет → `404 {"error": "no_active_program"}`. + +## Тесты + +| # | Проверка | Ожидание | +|---|---|---| +| 1 | Есть активная версия | 200, `status = "active"`, `body` не пуст | +| 2 | Программы нет | 404 | +| 3 | Все версии archived | 404 | +| 4 | Программа другого атлета не видна (🛡) | 404 | diff --git a/1-docs/4-modules/m02-rest-api/api_specs/06-dto.md b/1-docs/4-modules/m02-rest-api/api_specs/06-dto.md new file mode 100644 index 0000000..f3f6984 --- /dev/null +++ b/1-docs/4-modules/m02-rest-api/api_specs/06-dto.md @@ -0,0 +1,50 @@ +# Спецификация: DTO пакета sync и ответов (единица 7) + +## Назначение + +C#-модели запроса/ответа REST API (проект `Server.WatchBody.Web`, +каталог `Dto\`); сериализация — System.Text.Json, camelCase, +регистронезависимая десериализация. Это транспортные модели — доменные +объекты m01 (`Server.WatchBody.Core`) в API наружу не выдаются. + +Примечание (З-42): корень запроса — **сырой JSON-массив** (обёртки +`SyncRequest` нет); разбор поля `data` — не strongly-typed модели, а +`SyncItemParser` над `JsonElement` (проверка обязательных полей при +разборе, лишние игнорируются, недостающие обязательные → 400). +Модели `SyncRequest`/`RecordData*` из ранней редакции спецификации +не реализованы. + +## Модели + +| Класс | Поля | Комментарий | +|---|---|---| +| `SyncItem` | `Guid ClientGuid`, `string Entity`, `string Op`, `JsonElement Data` | элемент массива-корня; `Data` — сырой JSON, разбирается по `Entity` (`SyncItemParser`) | +| `SyncResponse` | `List Accepted` | `{"accepted": [...]}` | +| `ErrorResponse` | `string Error` | `{"error": "..."}` для 400/401/404/409 | +| `WorkoutListResponse` | `List Items`, `int Total` | список тренировок | +| `WorkoutDto` | `Guid WorkoutId`, `DateTime? StartedAt/EndedAt`, `string Status`, `string SessionKind`, `int? AvgHr`, `List Sets` | `sets` — из агрегата `workout_select` | +| `WorkoutSetDto` | `Guid SetId`, `Guid? ExerciseId`, `int Seq`, `decimal WeightKg`, `int Reps`, `DateTime DoneAt`, `string Source`, `bool InPlan`, `int? Rpe`, `int? KneePain` | — | +| `CommandLogDto` | `Guid Id`, `int TOffsetS`, `string RecognizedText`, `JsonElement Semantics`, `bool Accepted` | `semantics` — jsonb как есть | +| `ActiveProgramResponse` | `int ProgramId`, `Guid VersionId`, `int Version`, `int? BasedOnVersion`, `string Author`, `string Status`, `JsonElement Body`, `string? Rationale`, `DateTime CreatedAt` | `body` — jsonb как есть | + +## Правила + +0. `exercise` с `is_active=false` — логическое удаление через + `src.exercise_delete` (З-41(а), Ф-4.2); повторное `true` после + удаления не восстанавливает (путь восстановления — m08, v1 нет). +1. Даты: `started_at`/`ended_at`/`done_at` — UTC ISO 8601 + (`DateTimeKind.Utc`); `date` (daily_log), `started_on`/ + `planned_return` (break) — `DateOnly` (YYYY-MM-DD). +2. Числа с фиксированной точкой (`weight_kg`) — `decimal`. +3. Валидация диапазонов (`rpe` 1–10, `knee_pain` 0–10, `wellbeing` 1–5, + пакет ≤ 50) — до открытия транзакции; нарушение → 400. +4. Названия полей — camelCase в JSON, PascalCase в C#. + +## Тесты + +| # | Проверка | Ожидание | +|---|---|---| +| 1 | Десериализация конверта (все 7 `entity`) | поля заполнены, `Data` сохранён | +| 2 | Отсутствие обязательного поля (`workout` без `started_at`) | ошибка валидации → 400 | +| 3 | Сериализация ответов — camelCase, `null`-поля опущены | соответствует примерам спецификаций | +| 4 | Дата без времени (`date: "2026-10-17"`) | разбирается в `DateOnly` | diff --git a/1-docs/4-modules/m02-rest-api/module_reqs.md b/1-docs/4-modules/m02-rest-api/module_reqs.md new file mode 100644 index 0000000..9d1cbc3 --- /dev/null +++ b/1-docs/4-modules/m02-rest-api/module_reqs.md @@ -0,0 +1,147 @@ +# Требования к модулю: m02-rest-api + +> Модуль — REST API сервера v1: проект `Server.WatchBody.Web` +> (ASP.NET Core, Kestrel — только localhost), middleware ключа атлета, +> `POST /api/v1/sync`, чтение тренировок и активной программы. +> Самодостаточная копия требований (d-dev-6): внешние документы +> продублированы по содержанию, ссылки — только для контекста. + +## 1. Назначение и границы + +**В границах модуля:** + +- проект `Server.WatchBody.Web` (net10.0, ASP.NET Core MVC) — + каркас, конфигурация (`appsettings.json`, валидация при старте — + tech-requirements 4.2: `Api.*`, `Db.ConnectionString` — фатально при + неудаче), запуск по HTTPS-прокси снаружи (сам Kestrel — localhost); + таблица маршрутов — fluent `Kit.Helpers.Routes` + (`Config\RouteConfig.cs`, паттерн — Kit.Task.WebAp\Task.WebApp), + обработчики — контроллеры `Controllers\` (решение владельца + 2026-10-18); +- сопоставление атлетов конфига `Api.Athletes` (`{name, apiKey}`) с + `src.athlete` при старте: upsert по SHA-256-хэшу ключа + (идемпотентно; запись в БД — источник `is_blocked`); +- middleware аутентификации `X-Api-Key` (см. разд. 3); +- `POST /api/v1/sync` — приём пакета записей телефона (разд. 4); +- `GET /api/v1/workouts`, `GET /api/v1/workouts/{id}`, + `GET /api/v1/programs/active` (разд. 5); +- интеграционные тесты эндпоинтов (WebApplicationFactory + тестовая БД). + +**Вне границ модуля:** AdminWeb (m08: admin REST API `/api/admin/*` в +этом же проекте + UI-приложение Flutter Web `3-app\3-web\admin-web` — +решение владельца 2026-10-17), fetch/import .fit (m06, m07), +метрики/правила (m04, m05), ProgramStore как сервис правил (m03; +здесь — только запись версии из sync), всё на телефоне и часах. +Доступ к БД — только DAL-репозитории модуля m01 +(`Server.WatchBody.Core`); прямой SQL в Web-проекте запрещён. + +## 2. Стек и соглашения + +- C# / .NET 10, ASP.NET Core (minimal API), `Server.WatchBody.Core` + (домен + репозитории m01) и Kit.Helpers (`Log.Logger` — файловые + логи, уровень Info). +- Конфигурация — `appsettings.json` (`Api.Key`, `Api.Athletes[]`, + `Db.ConnectionString`); валидация при старте, ошибки — в лог и + stderr, код возврата 1. +- Идентификация атлета — по хэшу ключа через `AthleteRepository` + (`athlete_select` по `p_api_key_hash`); middleware кладёт + `athlete_id` в `HttpContext.Items`. +- Формат ответов — JSON (System.Text.Json, camelCase); + ошибки — `{ "error": "<текст>" }` с соответствующим кодом HTTP. + +## 3. Аутентификация (requirements Ф-8.5, Ф-7.2 — 🛡) + +- Каждый запрос (кроме health-check `GET /health`) обязан нести + заголовок `X-Api-Key`. +- Сервер считает SHA-256 ключа и ищет атлета в `src.athlete` по + `api_key_hash`. Ключ не найден или `is_blocked = true` → `401` + (тело: `{"error": "unauthorized"}`), запрос не доходит до обработчиков. +- 🛡 Атлета определяет только ключ: телефон не передаёт идентификатор + атлета в данных пакета; `athlete_id` для всех записей пакета берётся + из middleware (изоляция — каждый запрос видит данные только своего + атлета). + +## 4. `POST /api/v1/sync` (Ф-7.3, tech-requirements 2.1–2.2) + +Запрос — JSON-массив записей (пакет ≤ 50), каждая: + +```json +{ + "client_guid": "uuid-v4", + "entity": "workout | workout_set | exercise | program_version | profile | daily_log | break", + "op": "upsert", + "data": { "...схема сущности..." } +} +``` + +**Ключевые поля `data`** (tech-requirements 2.2): + +- `exercise`: `exercise_id` (uuid), `name`, `group` (push|pull|legs|core|cardio), `is_base`, `is_active`; + повторная отправка упражнения с `is_active=false` — **логическое удаление** + (`src.exercise_delete`, З-41(а): единственный серверный путь установки + флага в v1; идемпотентно, GUID включается в `accepted` в обоих случаях, + Ф-7.4); +- `workout`: `workout_id` (uuid), `started_at`/`ended_at` (UTC ISO 8601), `status` (active|finished|cancelled), `session_kind` (normal|return|deload|test), `program_version_id` (nullable), `avg_hr`; +- `workout_set`: `workout_id`, `exercise_id`, `seq`, `weight_kg`, `reps`, `done_at`, `source` (voice|watch|edit), `in_plan`, `rpe` (int? 1–10), `knee_pain` (int? 0–10, только «ноги»); +- `profile`: `sex`, `birth_year`, `weight_kg`, `hr_max` (nullable); +- `break`: `kind` (nullable), `source` (телефон передаёт только `manual`), `started_on`, `planned_return`, `note`; +- `daily_log`: `date` (YYYY-MM-DD), `bp_sys`, `bp_dias`, `weight_kg`, `hr_resting`, `wellbeing` (1–5); +- `program_version`: JSON-схема программы из дизайна LLM-планирования (разд. 2) — пишется в `body` как есть; `athlete_id` в теле не передаётся (выводится из ключа). + +**Правила обработки:** + +1. Пакет атомарен — «все или ничего»: одна транзакция на пакет; + при любой ошибке записи пакет целиком отклоняется. +2. Успех → `200 { "accepted": ["guid1", ...] }` — все GUID пакета. +3. 🛡 Идемпотентность: повтор пакета безопасен — записи по `client_guid` + (PK uuid) уже существуют, `workout_insert` и insert-функции uuid- + сущностей возвращают существующий id; сервер снова отвечает тем же + `accepted` (ответ идемпотентен). +4. `409` — конфликт версии программы (`UNIQUE(program_id, version)` в + `program_version_insert`); телефон перечитывает программу и + перезаписывает локально. Тело: `{"error": "program_version_conflict"}`. +5. `break` с `source = "auto"` от телефона отклоняется (`400`) — + `auto` проставляет только сервер (Ф-14.1); аналогично прочие + нарушения схем/диапазонов (`rpe` 1–10, `wellbeing` 1–5, пакет > 50, + неизвестная `entity`) — `400` с текстом ошибки. +6. Маппинг записей на функции БД (репозитории m01): + workout → `workout_insert`/`workout_update` (update — при + `ended_at`/`status`/`avg_hr`); workout_set → `workout_set_insert`; + exercise → `exercise_insert`; program_version → `program_version_insert` + (при первой версии атлета — `program_insert`); profile → + `athlete_profile_upsert`; daily_log → `daily_log_upsert`; + break → `break_insert` (`source='manual'`). + +## 5. Чтение (Ф-8.5) + +| Эндпоинт | Ответ | Реализация | +|---|---|---| +| `GET /api/v1/workouts?limit=&offset=` | массив карточек тренировок (с подходами), пагинация (limit ≤ 100, умолчание 20) | `workout_select` (карточки + sets агрегатом) | +| `GET /api/v1/workouts/{id}` | полная карточка тренировки + command_log | `workout_get`; чужой id → `404` | +| `GET /api/v1/programs/active` | активная версия программы атлета (с `body`) или `404` | `program_select` + `program_version_select (status=active)` + `program_version_get` | + +Все выборки — в пределах `athlete_id` из middleware (🛡 изоляция). + +## 6. Инварианты (🛡 — нарушать запрещено) + +1. Изоляция: каждый запрос видит и пишет данные только своего атлета + (атлет — по `X-Api-Key`; в теле пакета атлет не передаётся). +2. Идемпотентность: повтор пакета не создаёт дублей, ответ повторения — + тот же `accepted`. +3. Атомарность пакета: «все или ничего». +4. API не редактирует факт-данные в обход sync: ручного ввода на + сервере нет (Ф-8.6); пишет только через репозитории m01. +5. `break.source = 'auto'` из sync не принимается. + +## 7. Критерии приёмки модуля + +1. Без ключа / с чужим / заблокированным ключом — `401` на все + эндпоинты; изоляция подтверждена тестом (атлет Б не видит данные + атлета А, запись под чужой `workout_id` отклоняется). +2. Пакет sync: успешный приём возвращает все GUID; повтор пакета — + тот же ответ, дублей в БД нет; конфликт версии программы — `409`; + нарушение схемы — `400`. +3. `GET workouts` (пагинация), `GET workouts/{id}` (карточка, чужой id → + 404), `GET programs/active` (или 404) работают в пределах атлета. +4. Тесты эндпоинтов зелёные; валидация конфига при старте (пустой + `Api.Key`, дубликаты ключей атлетов — код возврата 1). diff --git a/1-docs/4-modules/m02-rest-api/readme.md b/1-docs/4-modules/m02-rest-api/readme.md new file mode 100644 index 0000000..444471e --- /dev/null +++ b/1-docs/4-modules/m02-rest-api/readme.md @@ -0,0 +1,65 @@ +# Модуль m02-rest-api — план разработки + +> План по алгоритму d-dev (`std1\_rules\d\d-dev.md`), адаптация к +> REST-модулю: вместо psql-функций — эндпоинты и обработчики; статусная +> модель шагов та же (`⬜ → 🔄 → ✅`), шлюзы «**Пользователь!**» — +> d-dev-4. Требования — [module_reqs.md](module_reqs.md). + +| Шаг d-dev | Состав | Статус | +|---|---|---| +| 0. Персистентная подготовка | module_reqs.md, readme.md, SKILL.md | ✅ | +| 1. Список единиц к реализации | эндпоинты + компоненты (middleware, sync-обработчик, DTO) со статусами в разд. «Список единиц» ниже | ✅ | +| 1.3. Утверждение списка | — | **Пользователь!** ✅ (2026-10-17) | +| 2. Спецификации единиц | `api_specs\` модуля — контракт каждой единицы (запрос/ответ/ошибки/тесты) — 6 файлов (DTO; конфиг; middleware; sync; чтение workouts; programs/active) | ✅ | +| 2.4. Утверждение спецификаций | — | **Пользователь!** ✅ (2026-10-17) | +| 3. Код и тесты | `3-app\2-cli\Server.WatchBody.Web` + `Server.WatchBody.Web.Tests` (интеграционные, docker `watchbody-pg`): тесты 25/25 PASS (Core 55/55 на момент закрытия модуля; после З-35 — 56/56, после З-41 — Web 81/81), сборка 0 ошибок | ✅ | +| 3.4. Утверждение реализации | — | **Пользователь!** ✅ (2026-10-18, с переводом маршрутов на fluent Kit.Helpers.Routes) | +| 4. Завершение | сборка и тесты зелёные; критерии приёмки module_reqs разд. 7 проверены | ✅ (2026-10-18: сборка 0 ошибок, тесты Web 25/25 + Core 55/55 PASS; после З-35 — Core 56/56) | + +**Модуль завершён** (2026-10-18). Следующий по плану этапа 7 — m03-program-store. + +## Список единиц (шаг 1) + +| # | Единица | Тип | Статус | +|---|---|---|---| +| 1 | Конфигурация и стартовая валидация (`Api.*`, `Db.*` — tech-req 4.2) + сопоставление атлетов конфига с `src.athlete` (upsert по хэшу) | компонент | REQ | +| 2 | Middleware `X-Api-Key` → `athlete_id` (401 при отсутствии/чужом/заблокированном ключе) | компонент | REQ | +| 3 | `POST /api/v1/sync` — обработчик пакета (атомарность, идемпотентность, маппинг 7 сущностей, 409/400) | эндпоинт | REQ | +| 4 | `GET /api/v1/workouts` — список с пагинацией | эндпоинт | REQ | +| 5 | `GET /api/v1/workouts/{id}` — карточка | эндпоинт | REQ | +| 6 | `GET /api/v1/programs/active` — активная версия программы | эндпоинт | REQ | +| 7 | DTO пакета sync и ответов (конверт, `accepted`, ошибки) | компонент | REQ | + +Примечания: + +- Доступ к БД — только репозитории m01 (`Server.WatchBody.Core`); + прямой SQL в Web-проекте запрещён (module_reqs разд. 1). +- Прогон интеграционных тестов — PostgreSQL 17 в docker `watchbody-pg` + (порт 5433, БД `watchbody`), как в m01. +- Регистрация маршрутов — fluent `Kit.Helpers.Routes` + (`Config\RouteConfig.cs`, MVC-контроллеры в `Controllers\`, + `UseMvc` с `EnableEndpointRouting = false`; решение владельца + 2026-10-18, паттерн — Kit.Task.WebAp\Task.WebApp). Admin REST API + (`/api/admin/*`) для консоли — в границах m08 (UI — отдельное + Flutter Web-приложение, решение владельца 2026-10-17). +- Атомарность пакета sync — `TransactionScope` (Npgsql подключает + соединения Kit.Helpers к ambient-транзакции); все чтения — до + открытия транзакции (select-функции Kit открывают собственную). + Вскрытая errata m01: jsonb-параметры функций приняты как `text` + (каста в теле); `program_version_insert` дополнен идемпотентностью + по `p_id` (🛡) — подробнее в readme m01. +- Интеграционные тесты запускают приложение in-process + (`WebAppStartup.Build`, Kestrel на `127.0.0.1:0`) против docker + `watchbody-pg`; данные — фиксированные uuid/ключи (идемпотентность + повторных прогонов), атлеты тестов создаются bootstrap'ом конфига. + +## Errata (после закрытия модуля) + +- **З-41(а)** (2026-10-18, ревью этапа 7 волна 2): sync терял + `is_active` у `exercise` — `exercise_insert`/`exercise_update` + параметр не принимают (дизайн m01, З-34), Delete из sync не + вызывался: серверный контур Ф-4.2 был недостижим (фильтр ограничений + m05 строится на флаге). Исправлено: `SyncService` при + `is_active=false` вызывает `exercises.Delete` (идемпотентно, GUID — + в `accepted`); интеграционный тест `Sync_ExerciseInactive_LogicalDelete` + (Web.Tests 81/81). Семантика — module_reqs / spec 06 / tech-req 2.2. diff --git a/1-docs/4-modules/m03-program-store/SKILL.md b/1-docs/4-modules/m03-program-store/SKILL.md new file mode 100644 index 0000000..e65da2c --- /dev/null +++ b/1-docs/4-modules/m03-program-store/SKILL.md @@ -0,0 +1,27 @@ +# Навык: модуль m03-program-store (сервис программ WatchBody) + +> Инструкция ИИ-агенту для продолжения работы над модулем. Модуль — +> детерминированный код; навык — только инструкция (d-dev-6). + +## Контекст + +Модуль реализует сервис `ProgramStore` v1 системы WatchBody +(«Феникс 6X»): версионный JSON программ, жизненный цикл версий +`draft → active → archived`, гарантия «`active` — ровно одна». +Код — `Server.WatchBody.Core` (каталог `Programs\`), поверх +репозиториев m01. Алгоритм — d-dev (`std1\_rules\d\d-dev.md`); +требования — `1-docs\4-modules\m03-program-store\module_reqs.md` +(самодостаточна); план и статусы — `readme.md` рядом. + +## Как продолжать + +1. Прочитай `1-docs\4-modules\m03-program-store\readme.md` — найди + первый шаг со статусом ⬜ и продолжай с него (d-dev-6). +2. Каждый шаг с пометкой «**Пользователь!**» завершается остановкой и + явным утверждением пользователя — переход без утверждения запрещён + (d-dev-4). +3. Доступ к БД — только репозитории m01 (`Server.WatchBody.Core`), + прямой SQL запрещён; инварианты 🛡 module_reqs разд. 3 нарушать + запрещено. +4. Тесты — интеграционные против PostgreSQL 17 в docker `watchbody-pg` + (порт 5433, БД `watchbody`). diff --git a/1-docs/4-modules/m03-program-store/api_specs/01-lifecycle.md b/1-docs/4-modules/m03-program-store/api_specs/01-lifecycle.md new file mode 100644 index 0000000..f2cabe1 --- /dev/null +++ b/1-docs/4-modules/m03-program-store/api_specs/01-lifecycle.md @@ -0,0 +1,56 @@ +# Спецификация: жизненный цикл программы (EnsureProgram, CreateDraft, Activate, Archive) + +> Сервис `ProgramStore` (`Server.WatchBody.Core\Programs\`); работа — +> через репозитории m01. Ошибки — `ProgramStoreException(message)`. + +## 1. `EnsureProgram(int athleteId, string title)` → `Program` + +Создаёт программу атлета при отсутствии (UNIQUE(athlete_id) — одна на +атлета), иначе возвращает существующую. `title` — только при создании. + +| # | Тест | Ожидание | +|---|---|---| +| 1 | первый вызов | программа создана (title как задан) | +| 2 | повторный вызов с другим title | возвращена та же программа (id не меняется, title не перезаписывается) | + +## 2. `CreateDraft(int athleteId, string body, string author, string rationale, int? basedOnVersion = null)` → `ProgramVersion` + +Новая draft-версия. `basedOnVersion = null` → явное значение = актуальная +максимальная версия (цепочка истории); число валидируется против +существующих. `author` ∈ `user | admin | rules` (`llm` — v2, иначе +ошибка). `body` — непустой (не валидируется по схеме, v1). Тело после +создания не меняется (🛡); `version = max+1` считает БД. + +| # | Тест | Ожидание | +|---|---|---| +| 3 | первая версия атлета без программы | созданы `program` и версия 1 (draft, based_on = NULL) | +| 4 | вторая версия | version = 2, based_on_version = 1 | +| 5 | author = "llm" | ошибка (v2) | +| 6 | author = "rules" | создана (для m05) | +| 7 | basedOnVersion = 5 при max = 2 | ошибка (цепочка нарушена) | +| 8 | пустой body | ошибка | +| 9 | два draft подряд | version 3 и 4, обе draft (активация — отдельно) | + +## 3. `Activate(int athleteId, Guid versionId)` → `void` + +`draft → active`; прежняя активная версия программы (если была) → +`archived` (БД гарантирует ровно одну). Не-draft версия → ошибка; чужая +версия атлета → ошибка (изоляция). + +| # | Тест | Ожидание | +|---|---|---| +| 10 | активация draft при активной v1 | v1 → archived, v2 → active; `GetActive` = v2 | +| 11 | активация первой версии (активной не было) | единственная active | +| 12 | активация archived-версии | ошибка (🛡 переходы только вперёд) | +| 13 | повторная активация уже активной | ошибка (не draft) | +| 14 | активация чужой версии | ошибка | + +## 4. `Archive(int athleteId, Guid versionId)` → `void` + +`active → archived` (например, атлет хочет остаться без активной +программы). Не-active → ошибка. + +| # | Тест | Ожидание | +|---|---|---| +| 15 | архивация активной | archived, `GetActive` → null | +| 16 | архивация draft | ошибка | diff --git a/1-docs/4-modules/m03-program-store/api_specs/02-reading.md b/1-docs/4-modules/m03-program-store/api_specs/02-reading.md new file mode 100644 index 0000000..8ad0a63 --- /dev/null +++ b/1-docs/4-modules/m03-program-store/api_specs/02-reading.md @@ -0,0 +1,32 @@ +# Спецификация: чтение программы (GetActive, ListVersions, GetVersion) + +## 1. `GetActive(int athleteId)` → `ProgramVersion?` + +Активная версия с body; программы/активной версии нет → `null`. +Изоляция: только программы этого атлета. + +| # | Тест | Ожидание | +|---|---|---| +| 1 | есть активная | версия со status = active, body ≠ пусто | +| 2 | программы нет | null | +| 3 | все версии archived | null | + +## 2. `ListVersions(int athleteId)` → `IReadOnlyList` + +Версии программы атлета (без body — поле пустое), по возрастанию +номера. Программы нет → пустой список. + +| # | Тест | Ожидание | +|---|---|---| +| 4 | три версии | 3 записи, номера 1–3 по возрастанию, статусы соответствуют, body пустые | +| 5 | чужой атлет | пустой список (изоляция) | + +## 3. `GetVersion(int athleteId, Guid versionId)` → `ProgramVersion?` + +Версия с body; чужая версия атлета / не существует → `null` (различие +не выдаётся). + +| # | Тест | Ожидание | +|---|---|---| +| 6 | своя версия | версия с body | +| 7 | чужая версия | null | diff --git a/1-docs/4-modules/m03-program-store/module_reqs.md b/1-docs/4-modules/m03-program-store/module_reqs.md new file mode 100644 index 0000000..22a3e10 --- /dev/null +++ b/1-docs/4-modules/m03-program-store/module_reqs.md @@ -0,0 +1,77 @@ +# Требования к модулю: m03-program-store + +> Модуль — сервис `ProgramStore` в `Server.WatchBody.Core`: единая точка +> работы с программами атлета — версионный JSON, жизненный цикл версий +> `draft → active → archived`, гарантия «`active` — ровно одна». +> Самодостаточная копия требований (d-dev-6). + +## 1. Назначение и границы + +**В границах модуля:** + +- класс `ProgramStore` (`Server.WatchBody.Core`, каталог `Programs\`): + сервис над репозиториями m01 (`ProgramRepository`, + `ProgramVersionRepository`) — единственный вход для всех потребителей + (REST API m02/m08, ProgressionRules m05); +- жизненный цикл версий: создание draft (новая версия на основе + актуальной), активация, архивация; +- чтение: активная версия (с body), список версий (без body), + версия по id (с body); +- тесты сервиса (интеграционные, docker `watchbody-pg`). + +**Вне границ модуля:** правила прогрессии и делоада (m05 — создают +draft-версии **через** ProgramStore), импорт пакета программы (m06 — +тоже через ProgramStore), редактор программы на телефоне (m09), +запись версий из sync в m02 (остаётся на репозиториях — уже работает; +переход на ProgramStore — кандидат рефакторинга, не в v1). +Схема тела программы (недели → тренировки → упражнения → подходы-план, +дизайн LLM-планирования разд. 2) в v1 сервером не валидируется — +хранится как есть (jsonb). + +## 2. Стек и соглашения + +- C# / .NET 10, `Server.WatchBody.Core`; доступ к БД — только + репозитории m01 (Kit.Helpers), прямого SQL нет. +- Конфиг `Progression.*`/`Deload.*` в модуле не потребляется (это m05). +- Логи — Kit.Helpers `Log.Logger` (уровень Info). + +## 3. Инварианты (🛡 — нарушать запрещено) + +1. **Программы принадлежат атлету** — все операции принимают + `athleteId`; чужие программы/версии недоступны. +2. **Неизменяемость версии** — после создания `body`, `author`, + `based_on_version` не меняются; меняется только `status`. +3. **Ровно одна `active` на программу** — активация переводит прежнюю + активную в `archived` (гарантия БД — частичный уникальный индекс; + ProgramStore использует только переходы, разрешённые БД). +4. **Переходы статусов только `draft → active → archived`** — повторная + активация archived-версии запрещена (явное действие пользователя — + Ф-10.4); 🛡 автоматической смены активной версии нет (vision 2.4). +5. **Тренировки ссылаются на версию, активную на их начало** — + ProgramStore не перепривязывает ссылки (история не «поплывёт»). + +## 4. Состав сервиса (контракты, шаг 1) + +| # | Метод | Назначение | +|---|---|---| +| 1 | `EnsureProgram(athleteId, title)` | программа атлета (создаёт при отсутствии; UNIQUE(athlete_id)) | +| 2 | `CreateDraft(athleteId, body, author, rationale, basedOnVersion = null)` | новая draft-версия: `version = max+1` (БД), `based_on_version` = актуальная максимальная (или явная); author ∈ user/admin/rules (llm — v2) | +| 3 | `Activate(athleteId, versionId)` | draft → active; прежняя active → archived; не-draft → ошибка | +| 4 | `Archive(athleteId, versionId)` | active → archived (смена активной = активация другой версии) | +| 5 | `GetActive(athleteId)` | активная версия с body (или null) | +| 6 | `ListVersions(athleteId)` | версии программы без body (статусы, номера, rationale) | +| 7 | `GetVersion(athleteId, versionId)` | версия с body | + +## 5. Критерии приёмки модуля + +1. Жизненный цикл: CreateDraft → Activate → (новая draft) → Activate — + активна ровно одна, прежняя archived; повторная активация + archived — ошибка. +2. Изоляция: операции одного атлета не видят/не трогают программы + другого. +3. Неизменяемость: после создания body/author/based_on_version не + меняются ни одним методом сервиса. +4. CreateDraft фиксирует `based_on_version` = актуальная максимальная + версия (история версий — цепочка). +5. Тесты интеграционные против docker `watchbody-pg` — зелёные; + сборка решения без ошибок. diff --git a/1-docs/4-modules/m03-program-store/readme.md b/1-docs/4-modules/m03-program-store/readme.md new file mode 100644 index 0000000..f583bed --- /dev/null +++ b/1-docs/4-modules/m03-program-store/readme.md @@ -0,0 +1,32 @@ +# Модуль m03-program-store — план разработки + +> План по алгоритму d-dev (`std1\_rules\d\d-dev.md`), адаптация к +> сервисному модулю; шлюзы «**Пользователь!**» — d-dev-4. Требования — +> [module_reqs.md](module_reqs.md). + +| Шаг d-dev | Состав | Статус | +|---|---|---| +| 0. Персистентная подготовка | module_reqs.md, readme.md, SKILL.md | ✅ | +| 1. Список единиц к реализации | методы ProgramStore — module_reqs разд. 4 (7 методов) | ✅ | +| 1.3. Утверждение списка | — | **Пользователь!** ✅ (2026-10-18) | +| 2. Спецификации единиц | `api_specs\` модуля — 2 файла (жизненный цикл 01; чтение 02) | ✅ | +| 2.4. Утверждение спецификаций | — | **Пользователь!** ✅ (2026-10-18) | +| 3. Код и тесты | `Server.WatchBody.Core\Programs\ProgramStore.cs` + интеграционные тесты (docker `watchbody-pg`): 19 тестов, все PASS; сборка 0 ошибок | ✅ | +| 3.4. Утверждение реализации | — | **Пользователь!** ✅ (2026-10-18) | +| 4. Завершение | сборка и тесты зелёные; критерии приёмки module_reqs разд. 5 проверены | ✅ (2026-10-18: сборка 0 ошибок, тесты Web 44/44 + Core 55/55 PASS на момент закрытия; после З-35 — Core 56/56) | + +Примечания: + +- Разработка поштучно: методы сервиса небольшой группой (жизненный + цикл 1–4, чтение 5–7) — шлюзы 1.3/2.4 можно проходить одной группой. +- Потребители ProgramStore (подключение в следующих модулях): m05 — + создание draft `author=rules`; m06 — импорт пакета программы; m08 — + admin-API программ. Sync в m02 остаётся на репозиториях (v1). +- Интеграционные тесты Core-сервисов живут в `Server.WatchBody.Web.Tests` + (общая docker-инфраструктура: `TestApp`, подключение из конфига); + юнит-инфраструктура Core.Tests (fake-транспорт ConnectionStringTest) + для сервиса над репозиториями не подходит. +- jsonb нормализует пробелы — сравнение body в тестах через + `JsonElement.DeepEquals`. +- Прогон тестов — PostgreSQL 17 в docker `watchbody-pg` (порт 5433, + БД `watchbody`), как в m01/m02. diff --git a/1-docs/4-modules/m04-metrics-engine/SKILL.md b/1-docs/4-modules/m04-metrics-engine/SKILL.md new file mode 100644 index 0000000..f745983 --- /dev/null +++ b/1-docs/4-modules/m04-metrics-engine/SKILL.md @@ -0,0 +1,19 @@ +--- +name: m04-metrics-engine +description: MetricsEngine — расчёт метрик тренировки (adherence, тоннаж, 1ПМ, пульс-метрики, красные флаги) → src.workout_metrics +--- + +# m04-metrics-engine + +Сервис `MetricsEngine` в `Server.WatchBody.Core` (`Metrics\`): +детерминированный расчёт метрик одной тренировки атлета по его +данным (`workout`, `workout_set`, `daily_log`, активная версия +программы через ProgramStore) и upsert в `src.workout_metrics`. +Вызывается из sync (m02) после коммита пакета; сбой расчёта не +ломает sync. Пороги красных флагов — конфиг `Safety.*` (Р3-А). +Правила прогрессии (m05) читают `workout_metrics` — этот модуль +в план ничего не пишет. + +- Требования: [module_reqs.md](module_reqs.md) +- План работ: [readme.md](readme.md) +- Спецификации: [api_specs/](api_specs/) diff --git a/1-docs/4-modules/m04-metrics-engine/api_specs/01-metrics-config.md b/1-docs/4-modules/m04-metrics-engine/api_specs/01-metrics-config.md new file mode 100644 index 0000000..2f41297 --- /dev/null +++ b/1-docs/4-modules/m04-metrics-engine/api_specs/01-metrics-config.md @@ -0,0 +1,42 @@ +# Спецификация: конфигурация порогов Safety (MetricsConfig) + +> Источники: Ф-13.5 (Р3-А: пороги конфигурируемые, умолчания — +> документ метрик), документ метрик п. 2.1–2.3, правила 1, 6. + +## 1. Секция `Safety` в appsettings (json, camelCase) + +```json +"Safety": { + "bpCancelSys": 150, // АД сист. → отмена (документ метрик п. 2.2) + "bpCancelDias": 90, // АД диаст. → отмена + "bpElevatedSys": 140, // нижняя граница коридора «только кардио» + "bpElevatedDias": 95, // диаст. коридора + "hrRestingRise": 8, // пульс покоя ≥ базовый +8 (правило 1) + "hrRestingDays": 2, // …дней подряд + "wellbeingLow": 2, // самочувствие ≤2 (правило 1) + "wellbeingDays": 2, // …дней подряд + "lightDayCount": 3, // 3+ дня → cancel_recommendation + "kneePainMin": 1, // боль ≥1 (правило 6) + "kneeRepeatSessions": 2, // …занятий подряд + "hrBaselineDays": 14, // окно базового пульса покоя + "hrBaselineMin": 3 // минимум значений для базового +} +``` + +## 2. Класс `Core\Metrics\MetricsConfig.cs` + +- свойства `int` с умолчаниями из таблицы выше; +- `static MetricsConfig Load(JsonElement root)` — чтение секции + `Safety` (отсутствует/частичная секция → умолчания по каждому + полю; нечисловое значение → исключение с именем поля); +- объект хранится в `ServerConfig` (поле `Safety`) и в DI как + синглтон; валидация `ServerConfig.Validate` дополняется проверкой: + `bpElevatedSys ≤ bpCancelSys`, все значения > 0, иначе ошибка + валидации конфига (фатально). Коридоры диастолы по документу + метрик перекрываются (отмена >90, «кардио» до 95) — проверка + `bpElevatedDias ≤ bpCancelDias` не применяется. + +## 3. Инвариант + +🛡 Пороги читаются только из конфигурации; в расчётном коде числовые +пороги не хардкодятся (Р3-А). diff --git a/1-docs/4-modules/m04-metrics-engine/api_specs/02-compute-for-workout.md b/1-docs/4-modules/m04-metrics-engine/api_specs/02-compute-for-workout.md new file mode 100644 index 0000000..eb02c95 --- /dev/null +++ b/1-docs/4-modules/m04-metrics-engine/api_specs/02-compute-for-workout.md @@ -0,0 +1,97 @@ +# Спецификация: MetricsEngine.ComputeForWorkout + +> Класс `Core\Metrics\MetricsEngine.cs`; источники — таблицы +> `src.workout` (+sets агрегатом), `src.exercise`, `src.daily_log`, +> активная версия программы (ProgramStore, m03); выход — upsert в +> `src.workout_metrics` (репозиторий m01). + +## 1. Сигнатура + +```csharp +public class MetricsEngine( + IWorkoutRepository workouts, + IExerciseRepository exercises, + IExerciseGroupRepository groups, + IDailyLogRepository dailyLogs, + IWorkoutMetricsRepository metrics, + ProgramStore programs, + MetricsConfig safety) +{ + public WorkoutMetrics ComputeForWorkout(int athleteId, Guid workoutId); +} +``` + +Тренировка не найдена или чужая → `InvalidOperationException` +(вызывающий — sync — оборачивает в try/catch, 🛡4). Возвращает +доменный `WorkoutMetrics` (m01) с заполненными полями; строка уже +записана upsert-ом. + +## 2. Входные данные (все выборки по athleteId, 🛡1) + +1. `workouts.Get(athleteId, workoutId)` — карточка + подходы + (агрегат workout_select). +2. `exercises.Select(athleteId)` + группы — map exercise_id → код + группы; `is_base` для шага прогрессии (нужно m05, тут не + используется). +3. `dailyLogs.Select(athleteId, dateFrom = дата тренировки − + safety.HrBaselineDays, dateTo = дата тренировки)` — АД дня, + тренды пульса покоя/самочувствия, базовый пульс. Примечание + (З-42 п. 6): фактическое окно шире на `lightDayCount` — + `dateFrom = дата − HrBaselineDays − LightDayCount` (поиск более + раннего старта серии, spec 03 #3; MetricsEngine.cs:42). +4. `programs.GetActive(athleteId)` — активная версия (body) для + плановых подходов; null → план отсутствует (см. 3.1). + +## 3. Расчёт полей + +### 3.1 adherence (numeric 0–100, один знак) + +- **Знаменатель (план):** из body активной версии берётся сессия + дня недели даты тренировки (`weeks[].sessions[]`, `day` — понедельник…воскресенье + в нижнем регистре); недель несколько → берётся сессия первой + недели, содержащая этот день (ротация недель — v2). Плановое + число подходов = Σ `exercises[].sets` сессии. +- **Числитель (факт):** подходы тренировки с `in_plan = true`. +- План найден: `adherence = round(числитель / знаменатель × 100, 1)`; + знаменатель 0 → adherence = 0. +- План не найден (нет активной версии / сессии дня / body не парсится): + `adherence` = доля `in_plan` среди всех подходов тренировки; + подходов нет → 0. + +### 3.2 tonnage (Σ вес×повторы) + +`Σ weight_kg × reps` по подходам с `weight_kg > 0` и `reps > 0`; +один знак после запятой. + +### 3.3 tonnage_by_group + +Тот же Σ по коду группы упражнения (группа из 2): `{"push": 4200}`, +группы без подходов не включаются; пусто → `{}`. + +### 3.4 e1pm_by_exercise (Эпли, ОВ-7) + +По каждому упражнению: лучший подход = max `weight_kg` (при равном +весе — max reps, reps > 0); `e1pm = round(w × (1 + reps/30), 1)`. +Ключ — `exercise_id` (uuid) строкой. Подходов с весом нет → +упражнение не включается; пусто → `{}`. + +### 3.5 hr_metrics + +- `workout.facts_json` непустой и содержит `hr`-данные → из фактов + (.fit, m06): `{"avg_hr", "peak_hr", "rest_between_s"}` (что есть — + то попадает; поля отсутствуют в фактах → не включаются). +- Фактов нет → `{"avg_hr": , "hr_recovery_s": + }` (null-поля пропускаются). +- Всё отсутствует → `{}`. + +### 3.6 red_flags — см. [03-red-flags.md](03-red-flags.md) + +## 4. Запись + +`metrics.Upsert(athleteId, ...)` — 1:1, повторный расчёт обновляет +строку (🛡2). `ComputedAt` — текущее время сервера. + +## 5. Детерминированность (🛡5) + +Тот же состояние БД → тот же результат; случайности и времени +в расчёте нет (только в `computed_at`). diff --git a/1-docs/4-modules/m04-metrics-engine/api_specs/03-red-flags.md b/1-docs/4-modules/m04-metrics-engine/api_specs/03-red-flags.md new file mode 100644 index 0000000..7249214 --- /dev/null +++ b/1-docs/4-modules/m04-metrics-engine/api_specs/03-red-flags.md @@ -0,0 +1,56 @@ +# Спецификация: красные флаги (red_flags) + +> v1 — документ метрик, правила 1 и 6; пороги — `MetricsConfig` +> ([01-metrics-config.md](01-metrics-config.md)). Выход — массив +> объектов в `workout_metrics.red_flags` (jsonb). + +## 1. Формат + +```json +"red_flags": [ + {"flag": "bp_over_150_90", "date": "2026-10-17"}, + {"flag": "hr_resting_up", "dates": ["2026-10-16", "2026-10-17"]} +] +``` + +Поле `date(s)` — даты, на которых условие выполнено; пороги в +`red_flags` не дублируются. Порядок — как в таблице ниже; пусто → `[]`. + +## 2. Флаги + +| # | flag | Условие | Данные | +|---|---|---|---| +| 1 | `bp_over_150_90` | у дня тренировки `bp_sys > BpCancelSys` **или** `bp_dias > BpCancelDias` | `daily_log` дня тренировки (нет записи или нет АД → флага нет) | +| 2 | `bp_elevated` | не сработал #1 И `bp_sys ≥ BpElevatedSys` или `bp_dias ≥ BpElevatedDias` | там же | +| 3 | `hr_resting_up` | `hr_resting ≥ базовый + HrRestingRise` в `HrRestingDays` дней, **включая день тренировки**, подряд (без дыр по календарю: сравниваются имеющиеся значения подряд идущих дат с записями) | `daily_log` окна; базовый = среднее `hr_resting` за `HrBaselineDays` **до** первого дня серии, значений < `HrBaselineMin` → флаг не ставится | +| 4 | `wellbeing_low` | `wellbeing ≤ WellbeingLow` в `WellbeingDays` дней подряд (как #3, без базового) | `daily_log` окна | +| 5 | `cancel_recommendation` | #3 или #4 держится ≥ `LightDayCount` дней подряд | следствие #3/#4 | +| 6 | `knee_pain_repeat` | в `KneeRepeatSessions` последних занятиях подряд (текущая + предыдущая по `started_at`) есть подход группы «ноги» с `knee_pain ≥ KneePainMin` | `workout_set.knee_pain` | + +Пояснения: + +- «группа ноги» — код группы упражнения == `legs` (справочник групп, + m01). +- Предыдущее занятие для #6 — ближайшая по `started_at` тренировка + атлета раньше текущей (статус любой). +- #3: серия считается по фактическим записям `daily_log`: идём от дня + тренировки назад; каждая запись с условием продлевает серию, запись + с `hr_resting` без условия её рвёт, день без записи её не рвёт + (пропуск ввода не карается), но и не удлиняет. +- #5: если серия #3/#4 ≥ `LightDayCount` — добавляется один флаг + `cancel_recommendation` с общим списком дат. +- Примечания (З-42 п. 6; поведение осознанное, тесты зелёные): + (а) серия в `ConditionRun` ограничена `lightDayCount` датами — + флаги #3/#4 (порог 2 дня) и #5 (порог 3 дня) это не искажает; + (б) `knee_pain_repeat` — предыдущие тренировки строго **до дня** + текущей (`DateTo = дата − 1`): две сессии в один день не считаются + «двумя занятиями подряд». + +## 3. Семантика потребителям + +- m05 (правила прогрессии) и телефон: `hr_resting_up`/`wellbeing_low` + без #5 → следующая тренировка помечается лёгкой (60–70 % весов, + 2 сета); с #5 → предложение отмены + совет к врачу (Ф-12.5). +- `knee_pain_repeat` → предложение замены упражнения из пары-замены + (Ф-12.5; атрибуты упражнения). +- m04 только фиксирует флаги; никаких записей в план (🛡3). diff --git a/1-docs/4-modules/m04-metrics-engine/api_specs/04-sync-wiring.md b/1-docs/4-modules/m04-metrics-engine/api_specs/04-sync-wiring.md new file mode 100644 index 0000000..f0163d5 --- /dev/null +++ b/1-docs/4-modules/m04-metrics-engine/api_specs/04-sync-wiring.md @@ -0,0 +1,33 @@ +# Спецификация: вызов MetricsEngine из sync (m02) + +> Ф-12.1: «после каждой синхронизированной тренировки MetricsEngine +> считает метрики». Точка вызова — `SyncService.Process` +> (Server.WatchBody.Web) после `scope.Complete()`. + +## 1. Поток + +1. `WebAppStartup`: `MetricsEngine` регистрируется синглтоном (те же + репозитории, что у sync); `MetricsConfig` — синглтоном из конфига. +2. `SyncService.Process`: собирает список уникальных `workout_id`, + затронутых пакетом (элемент `entity=workout`, успех = id из + `workout_insert`/повтор существующего). +3. После `scope.Complete()` (транзакция пакета зафиксирована) — + для каждого затронутого `workout_id`: + `metricsEngine.ComputeForWorkout(athleteId, workoutId)` в + `try/catch (Exception)`: + - успех — Debug-лог; + - исключение — Warning-лог с `workout_id` и сообщением, + обработка пакета продолжается (🛡4: sync остаётся принятым, + ответ 200 не меняется). +4. Пакет без `workout`-элементов → MetricsEngine не вызывается. + +## 2. Пересчёт + +Повторный sync того же пакета (идемпотентность) повторяет расчёт — +upsert обновляет строку, `computed_at` свежее (🛡2, приёмка №2). + +## 3. Что не меняется + +Контракт POST /api/v1/sync (spec m02 03-sync-post) — без изменений: +метрики в ответ sync не входят; телефон читает их отдельным +средством (workout card / v2 endpoint — вне m04). diff --git a/1-docs/4-modules/m04-metrics-engine/module_reqs.md b/1-docs/4-modules/m04-metrics-engine/module_reqs.md new file mode 100644 index 0000000..4386e5b --- /dev/null +++ b/1-docs/4-modules/m04-metrics-engine/module_reqs.md @@ -0,0 +1,88 @@ +# Требования к модулю: m04-metrics-engine + +> Модуль — сервис `MetricsEngine` в `Server.WatchBody.Core`: +> детерминированный расчёт метрик тренировки (adherence, тоннаж, +> оценочный 1ПМ, пульс-метрики, красные флаги) после каждой +> синхронизированной тренировки → `src.workout_metrics` (1:1, upsert). +> Источники: Ф-12.1/Ф-12.5, Ф-13.5, документ метрик +> (`..\..\..\Метрики_тренировок_и_самоконтроля.md`, п. 2.5, правила +> 1–6), архитектура (строка `MetricsEngine`), дизайн LLM-планирования +> разд. 4. Самодостаточная копия требований (d-dev-6). + +## 1. Назначение и границы + +**В границах модуля:** + +- класс `MetricsEngine` (`Server.WatchBody.Core`, каталог `Metrics\`): + расчёт метрик одной тренировки атлета по его данным и запись в + `workout_metrics` (upsert — пересчёт разрешён); +- вызов из m02: после успешного коммита пакета sync MetricsEngine + пересчитывает затронутые тренировки (Ф-12.1 «после каждой + синхронизированной тренировки»); сбой расчёта не откатывает sync + (метрики — производные данные, пересчитываются повторно); +- пороги красных флагов — из конфигурации `Safety.*` сервера + (Р3-А; умолчания — документ метрик, Ф-13.5); +- тесты сервиса (интеграционные, docker `watchbody-pg`). + +**Вне границ модуля:** правила прогрессии/делоада и создание +draft-версий (m05 — читает `workout_metrics` и `workout_set`); +парсинг .fit (m06 — кладёт факты в `workout.facts_json`/`avg_hr`/ +`hr_recovery_s` до расчёта); отображение метрик (телефон m09/m10, +AdminWeb m08 — читают `workout_metrics_select`); расчёт adherence +«за период» и еженедельные сводки (п. 2.5 документа метрик, не +названные в v1) — по потребности m05/m08; перерывы `auto` по +пропускам (Ф-14.1 — отдельная логика, не метрика). + +## 2. Состав метрик (контракт документа метрик, v1) + +| Поле `workout_metrics` | Расчёт (детерминированно) | +|---|---| +| `adherence` | % подходов «по плану» (`workout_set.in_plan`) от планового числа подходов сессии в активной версии программы (body, схема дизайна LLM-планирования разд. 2); план/сессию не удалось сопоставить → % `in_plan` среди подходов тренировки | +| `tonnage` | Σ (weight_kg × reps) по всем подходам с weight>0, reps>0 | +| `tonnage_by_group` | тот же Σ по группам упражнений (`exercise.group`), код группы → кг | +| `e1pm_by_exercise` | формула Эпли (ОВ-7): `w × (1 + reps/30)` по лучшему (максимальному весу) подходу каждого упражнения | +| `hr_metrics` | из `workout.facts_json` (.fit, если уже импортирован): средний/пиковый пульс, отдых между подходами; нет фактов → `{avg_hr, hr_recovery_s}` из `workout` (то, что прислал телефон/.fit-импорт) | +| `red_flags` | сработавшие красные флаги (см. разд. 3) | + +## 3. Красные флаги (v1 — документ метрик, правила 1, 6; Ф-12.5) + +| Флаг | Условие | Источник данных | +|---|---|---| +| `bp_over_150_90` | АД дня тренировки > 150/90 (пороги `Safety.BpCancelSys/Dias`) | `daily_log` дня тренировки | +| `bp_elevated` | АД в коридоре 140–150/90–95 | `daily_log` дня тренировки | +| `hr_resting_up` | пульс покоя ≥ базового +8 уд/мин (`Safety.HrRestingRise`) 2 дня подряд | `daily_log` (тренд за 2 дня до дня тренировки; базовый = среднее за предыдущие 14 дней, при <3 значениях базового флаг не ставится) | +| `wellbeing_low` | самочувствие ≤ 2 (`Safety.WellbeingLow`) 2 дня подряд | `daily_log` | +| `cancel_recommendation` | `hr_resting_up`/`wellbeing_low` держится 3+ дня → предложение отмены и совет к врачу | `daily_log` | +| `knee_pain_repeat` | боль в колене ≥ 1 (`Safety.KneePainMin`) в двух занятиях подряд (сеты группы «ноги») | `workout_set.knee_pain` предыдущей и текущей тренировки | + +Флаги — накопленный список фактов сработавших условий с датами; +значения порогов в `red_flags` не дублируются. 🛡 Пороги — только из +конфигурации, в коде не хардкодятся (Р3-А). + +## 4. Инварианты (🛡 — нарушать запрещено) + +1. **Изоляция атлета** — расчёт только по данным одного `athleteId`; + все входные выборки — по нему (репозитории m01). +2. **Метрики — производные данные** — upsert разрешён всегда + (пересчёт); sync атлета не может «сломать» метрики другого. +3. **Без побочных эффектов на план** — MetricsEngine ничего не пишет + в `workout`/`workout_set`/`program_version` (это m05); только + `workout_metrics`. +4. **Сбой расчёта не ломает sync** — исключение расчёта метрик + логируется, пакет sync остаётся принятым. +5. **Детерминированность** — тот же вход → тот же выход; никакого LLM. + +## 5. Критерии приёмки модуля + +1. После sync тренировки с подходами `workout_metrics` заполнен: + adherence/tonnage/tonnage_by_group/e1pm (Эпли проверяется вручную + посчитанным значением)/hr_metrics. +2. Повторный расчёт (повторный sync того же пакета) не создаёт + вторую строку (upsert 1:1). +3. Красные флаги: сценарии АД>150/90, пульс покоя +8 два дня, + самочувствие ≤2, колено ≥1 два занятия — флаги ставятся; чистые + данные — `red_flags` пустой. +4. Изоляция: расчёт атлета А не видит данные атлета Б. +5. Сбой расчёта метрик не мешает принятию пакета sync. +6. Тесты интеграционные против docker `watchbody-pg` — зелёные; + сборка решения без ошибок. diff --git a/1-docs/4-modules/m04-metrics-engine/readme.md b/1-docs/4-modules/m04-metrics-engine/readme.md new file mode 100644 index 0000000..eca0552 --- /dev/null +++ b/1-docs/4-modules/m04-metrics-engine/readme.md @@ -0,0 +1,51 @@ +# m04-metrics-engine — план работ (d-dev) + +> Модуль: MetricsEngine — расчёт метрик тренировки в +> `Server.WatchBody.Core` → `src.workout_metrics`. +> Алгоритм: `std1\_rules\d\d-dev.md` (итерационная разработка по +> модулям). Требования: [module_reqs.md](module_reqs.md). +> Зависимости: m01 (репозитории, DONE), m02 (sync — точка вызова, DONE), +> m03 (ProgramStore — активная версия для adherence, DONE). + +| Шаг | Что делаем | Статус | +|---|---|---| +| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-18 | +| 1 | Список единиц (классы/методы/конфиг/тесты) | ✅ 2026-10-18 | +| 1.3 | **Пользователь!** утверждение списка | ✅ 2026-10-18 | +| 2 | Спецификации: api_specs (контракт расчёта, пороги, флаги) | ✅ 2026-10-18 | +| 2.4 | **Пользователь!** утверждение спецификаций | ✅ 2026-10-18 | +| 3 | Реализация + тесты | ✅ 2026-10-18 (тесты Web 61/61 + Core 55/55 на момент закрытия модуля; после З-35 — 56/56) | +| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-18 | +| 4 | Завершение: план, примечания | ✅ 2026-10-18 — **Модуль завершён** (2026-10-18). Следующий по плану этапа 7 — m05-progression-rules (итерация 2). | + +Статусы: ⬜ не начат · 🔄 в работе · ✅ готов. + +## Примечания по ходу работ + +Errata m01 (найдено тестами m04, исправлено): + +1. **`DailyLogRepository`** (и break-часть файла): Npgsql возвращает + колонки `date` как `DateOnly`, а не `DateTime` — каст через + `Convert` падал `InvalidCastException`. Заменено на + `DailyLogRepository.AsDate(object)`. +2. **`WorkoutRepository.ParseSets`**: ключи агрегата `workout_select` + — snake_case (`in_plan`, `weight_kg`); без + `JsonNamingPolicy.SnakeCaseLower` поля `WorkoutSet` молча не + заполнялись (case-insensitive подчёркивания не игнорирует). + +Errata m02 (интеграция метрик в sync): + +3. **TransactionScope живёт до конца метода**: пересчёт метрик внутри + `using var scope` падал «TransactionScope is already complete» + (соединения пристраиваются к завершённой ambient-транзакции). + Запись пакета вынесена в `ApplyAll` — scope закрывается до + пересчёта. +4. **Kit.Helpers.Log.Logger не настроен** (`_pathLogFolder = null` → + `Path.Combine` бросает) — в `SyncService` использован стандартный + `ILogger`. +5. В DI добавлены `IWorkoutMetricsRepository`, `ProgramStore`, + `MetricsEngine`, `MetricsConfig` (секция `Safety` в appsettings). + +Решение по порогам АД: коридоры документа метрик перекрываются +(отмена >150/**90**, «только кардио» 140–150/**90–95**) — проверка +валидации `bpElevatedDias ≤ bpCancelDias` не применяется (spec 01). diff --git a/1-docs/4-modules/m05-progression-rules/SKILL.md b/1-docs/4-modules/m05-progression-rules/SKILL.md new file mode 100644 index 0000000..6c0c1a3 --- /dev/null +++ b/1-docs/4-modules/m05-progression-rules/SKILL.md @@ -0,0 +1,18 @@ +--- +name: m05-progression-rules +description: ProgressionRules + BreakEngine — правила прогрессии, делоада и перерывов → draft-версии программ author=rules +--- + +# m05-progression-rules + +Сервисы `ProgressionRules` и `BreakEngine` в `Server.WatchBody.Core` +(`Programs\`): детерминированные правила микроправки весов (Ф-12.2), +делоада (Ф-12.4), авто-детект перерывов и формула возврата (Ф-14). +Результат — предложение draft-версии через ProgramStore (m03, +`author=rules`); активация — только человеком (🛡 vision 2.4). +Вызываются из sync (m02) после пересчёта метрик (m04); сбой правил +не ломает sync. Пороги — конфиг `Progression.*`/`Deload.*`. + +- Требования: [module_reqs.md](module_reqs.md) +- План работ: [readme.md](readme.md) +- Спецификации: [api_specs/](api_specs/) diff --git a/1-docs/4-modules/m05-progression-rules/api_specs/01-config.md b/1-docs/4-modules/m05-progression-rules/api_specs/01-config.md new file mode 100644 index 0000000..1e86cdb --- /dev/null +++ b/1-docs/4-modules/m05-progression-rules/api_specs/01-config.md @@ -0,0 +1,37 @@ +# Спецификация 01: конфиг ProgressionConfig + +Источники: requirements разд. 3 (`Progression.*`, `Deload.*`); +умолчания там же. По образцу `MetricsConfig` (m04, spec 01). + +## 1. Поля (секция `Progression` в appsettings.json) + +| Поле | Ключ | Умолчание | Семантика | +|---|---|---|---| +| `BaseStepKg` | `progression.baseStepKg` | `2.5` | шаг прогрессии базовых (правило 1) | +| `IsoStepKg` | `progression.isoStepKg` | `1.0` | шаг прогрессии изоляции (валидация 0.25–5, О-3-А) | +| `BackoffPercent` | `progression.backoffPercent` | `5` | снижение при недовыполнении, % | +| `DeloadMaxWeeks` | `progression.deloadMaxWeeks` | `6` | недель между делоадами | +| `MissDaysForBreak` | `progression.missDaysForBreak` | `4` | дней пропуска всех сессий → break (Ф-14.1, Жижин) | +| `ReturnMinFactor` | `progression.returnMinFactor` | `0.6` | нижняя граница коэффициента возврата (−40 %) | +| `ReturnMaxFactor` | `progression.returnMaxFactor` | `0.9` | верхняя граница снижения простоя 7–13 дней (−10 %) | + +Загрузка — `ProgressionConfig.Load(JsonElement root)`: секция +`progression`, reflection по свойствам, camelCase → PascalCase, +нечисловое значение → исключение (упадёт при старте `serve`, как у +`MetricsConfig`). Регистрация в DI: `AddSingleton(config.Progression)`. + +## 2. Валидация `Validate()` + +- `IsoStepKg` вне [0.25; 5] → ошибка (коридор метрик «+0,5–1 кг», О-3-А); +- `BaseStepKg` вне [0.5; 10] → ошибка; +- `BackoffPercent` вне [1; 20] → ошибка (утверждённый диапазон, З-37 п. 4); +- `DeloadMaxWeeks` вне [3; 12], `MissDaysForBreak` < 2 → ошибка (диапазон делаода — З-37 п. 4); +- `ReturnMinFactor` вне [0.3; 0.9], `ReturnMaxFactor` вне (0.5; 1], + `ReturnMinFactor > ReturnMaxFactor` → ошибка. + +## 3. Инвариант 🛡 + +Пороги правил — только из конфига; в коде не хардкодятся (Р3-А). +Коэффициенты возврата по длительности простоя — константы таблицы +документа перерывов (разд. 3.2), фактор ограничен конфигом +`ReturnMinFactor`/`ReturnMaxFactor`. diff --git a/1-docs/4-modules/m05-progression-rules/api_specs/02-micro-adjust.md b/1-docs/4-modules/m05-progression-rules/api_specs/02-micro-adjust.md new file mode 100644 index 0000000..71f8ff7 --- /dev/null +++ b/1-docs/4-modules/m05-progression-rules/api_specs/02-micro-adjust.md @@ -0,0 +1,76 @@ +# Спецификация 02: микроправка весов — ProgressionRules.EvaluateAfterWorkout + +Источники: Ф-12.2–12.3, документ метрик правила 3–5, ограничения — +решение гейта 1.3 (module_reqs разд. 2.4). + +## 1. Сигнатура + +```csharp +public ProgramVersion? EvaluateAfterWorkout(int athleteId, Guid workoutId) +``` + +Возврат: созданная draft-версия или null (изменений нет / черновик +уже существует). Дедупликация: rules-draft со статусом `draft` и +`based_on_version ≥` номера активной версии блокирует новые +предложения (покрывает и пользовательские draft поверх активной). + +## 2. Входные данные + +- тренировка `workout` + подходы `workout_set` (репозиторий m01); +- предыдущие тренировки атлета (`WorkoutFilter`, по убыванию + `started_at`) — для правила 1 («2 тренировки подряд»); +- активная версия `ProgramStore.GetActive(athleteId)` — нет → null; +- библиотека атлета `IExerciseRepository.Select` (с `ReplacementIds`) + — сопоставление и ограничения. + +Сопоставление план↔факт: `body.exercises[].exercise_id` == +`src.exercise.name` (exact, Ordinal); упражнение не найдено → решение +не генерируется, пометка в rationale. `workout_set.exercise_id` +(uuid) → `src.exercise` → имя → элемент body. + +## 3. Решения по упражнениям (проверяются по порядку, срабатывает первое) + +По каждому упражнению тренировки, присутствующему в активной версии +и активному (`is_active=true`): + +| # | Условие | Действие | +|---|---|---| +| 1 | в двух последних тренировках **этого упражнения** все плановые подходы `in_plan=true` и `rpe ≤ 7` (rpe во всех подходах указан) | плановый вес `+BaseStepKg` (`is_base=true`) / `+IsoStepKg` (`is_base=false`) | +| 2 | в последней тренировке упражнения `rpe ≥ 9` в ≥ 2 подходах | плановый вес `× (1 − BackoffPercent/100)` | +| 3 | все плановые подходы закрыты «по плану» и плановые повторения ≥ 15 (верхняя граница диапазона 15–20) | вес `+шаг прогрессии`, плановые повторения → 12 | +| 4 | в последней тренировке ≥ 2 подходов с `reps` < плановых | плановый вес `× (1 − BackoffPercent/100)` | +| 5 | иначе | без изменения | + +- «Предыдущая тренировка упражнения» — предыдущая по `started_at` + тренировка атлета, содержащая подходы этого упражнения. +- Результат веса округляется к 0.5 кг (`Math.Round(x*2)/2`). +- Подходы `source=edit` в расчёт не берутся (правка = новая запись, + история: берём исходные voice/watch записи). +- Вес плана `null` (калибровка) → решение не генерируется. + +## 4. Ограничения (module_reqs разд. 2.4) + +- `is_active=false` → не попадает в draft; если в активной версии + сессия состоит только из неактивных — сессия исключается; +- у исключаемого упражнения есть активная пара-замена + (`ReplacementIds`, замена активна, та же группа) → подставляется + замена (имя замены в `exercise_id` body, веса исходного плана, + пометка в rationale). Примечание (З-40): «та же группа» гарантируется + `exercise_replacement_insert` (проверка на вставке пары); повторная + runtime-проверка в `ApplyExclusion` не дублируется; +- `body.profile.limitations` переносится без изменений. + +## 5. Формирование draft + +1. Нет ни одного решения с изменением и ни одного исключения/замены → + `null` (черновик не нужен). +2. Deep-copy активного body (JsonNode), применение решений к week 1+ + (все недели: v1 ротации нет — одно упражнение = один плановый вес). +3. Дедупликация: существует rules-draft с `based_on_version` == номер + активной версии → `null` (черновик не плодится между sync). +4. `ProgramStore.CreateDraft(athleteId, newBody, "rules", rationale)`; + rationale — человекочитаемо: по упражнению строка + «жим: 100 → 102.5 (+2.5: 2 тренировки подряд закрыты при RPE≤7)», + «присед: исключено (is_active=false), замена — жим ногами». + +🛡 Активная версия не меняется; активации нет (vision 2.4). diff --git a/1-docs/4-modules/m05-progression-rules/api_specs/03-deload.md b/1-docs/4-modules/m05-progression-rules/api_specs/03-deload.md new file mode 100644 index 0000000..281a2c3 --- /dev/null +++ b/1-docs/4-modules/m05-progression-rules/api_specs/03-deload.md @@ -0,0 +1,37 @@ +# Спецификация 03: делоад — ProgressionRules.CheckDeload + +Источники: Ф-12.4, документ метрик правило 10, дизайн LLM-планирования +разд. 6 («делоад не реже чем каждые 6 недель, объём −40–50 %»). + +## 1. Сигнатура + +```csharp +public ProgramVersion? CheckDeload(int athleteId, DateOnly today) +``` + +## 2. Условие + +1. Активная версия есть; тренировок достаточно: последняя тренировка + атлета старше `DeloadMaxWeeks` недель назад от «сегодня» → нет + делаода (атлет не тренируется — это break-домен, не делоад). +2. Опорная точка — последнее делоад-событие (v1, З-42 п. 5): + - тренировка с `session_kind=deload` (максимальная `started_at`), или + - начало активной версии (нет истории) — дата создания версии. + Вариант «rules-draft с `session_kind: deload` в week 1» как опорная + точка **не реализован** (черновик ещё не подтверждён, тренировок + с kind=deload нет) — кандидат на доработку в m08. +3. `сегодня − опорная ≥ DeloadMaxWeeks × 7 дней` → предложение делаода. +4. Дедупликация: та же, что в spec 02 (rules-draft на активную версию + существует → null). + +## 3. Формирование draft + +Deep-copy активного body; week 1: + +- веса `× 0.6` (середина коридора −40–50 %), округление к 0.5 кг; +- число подходов `−1` (минимум 1); +- всем сессиям week 1 → `"session_kind": "deload"`; +- `ProgramStore.CreateDraft(..., "rules", rationale)`; rationale — + «делоад: N недель без лёгкой недели (порог M)». + +🛡 Активация — только человеком; авто-делоада активной версии нет. diff --git a/1-docs/4-modules/m05-progression-rules/api_specs/04-breaks.md b/1-docs/4-modules/m05-progression-rules/api_specs/04-breaks.md new file mode 100644 index 0000000..3ef645a --- /dev/null +++ b/1-docs/4-modules/m05-progression-rules/api_specs/04-breaks.md @@ -0,0 +1,68 @@ +# Спецификация 04: перерывы — BreakEngine (Ф-14, документ перерывов 3.1–3.3) + +## 1. Сигнатуры + +```csharp +public bool DetectBreak(int athleteId, DateOnly today); // true — break открыт +public ProgramVersion? CloseOnReturn(int athleteId, Guid workoutId, DateOnly today); +``` + +(`today` — явный параметр для тестируемости; sync передаёт текущую +дату UTC. `CloseOnReturn` сам читает тренировку по id — в sync есть +только идентификатор.) + +Вызов из sync (spec 05): `DetectBreak` — до записи пакета не нужен; +вызывается после коммита пакета вместе с правилами. `CloseOnReturn` — +когда пакет содержит `workout` (первая сессия после break). + +## 2. DetectBreak — авто-детект (Ф-14.1) + +1. Открытый break есть (`BreakRepository.Select`, `ended_on = NULL`, + последний по `started_on`) → false. +2. Активной версии нет → false. +3. Окно `[today − MissDaysForBreak + 1 .. today]` (умолчание 4 дня). + Плановые дни недели в окне — по `body.weeks[].sessions[].day` + (v1 все недели одинаковы). День плановый и по нему нет тренировки + (`workout` с датой `started_at::date` = день) — пропуск. +4. Пропущены **все** плановые дни окна → `BreakRepository.Insert` + (source=`auto`, kind=NULL, started_on = первый пропущенный день, + note «авто: пропущены все сессии за N дней»). → true. +5. Есть хотя бы одна тренировка в окне → false. + +Атлет может объявить break заранее — телефон вставляет +source=`manual` (m09); авто-детект видит открытый manual и не дублирует. + +## 3. CloseOnReturn — закрытие и формула возврата (Ф-14.3–14.5, правило 17) + +Первый `workout` в пакете при открытом break: + +1. `break_update`: `ended_on` = дата тренировки (kind/note не трогаем + — ответ атлета придёт отдельным каналом, m08/m09). +2. Длительность простоя `days = ended_on − started_on`; коэффициент + по таблице документа перерывов (разд. 3.2), фактор зажат конфигом + `[ReturnMinFactor; ReturnMaxFactor]`: + +| Простой | Коэффициент | Режим | +|---|---|---| +| kind=illness (любой длительности) | 0.6 (нижняя граница) | перезапуск, rationale «болезнь» | +| 4–6 дней | 1.0 (веса без снижения) | первая сессия: подходы −1, target_rpe 5 | +| 7–13 | 0.9 | возврат 1–2 недели | +| 14–27 | 0.85 | 1 вводная неделя | +| 28–59 | 0.75 | 2 вводные недели | +| 60+ | ReturnMinFactor (0.6) | перезапуск цикла | + +3. Draft-версия возврата (одна на break; повторный вызов того же + break — дедупликация по rules-draft на активную версию, как в + spec 02/03): deep-copy body, week 1: + - веса `× коэффициент`, округление к 0.5 кг; вес `null` не трогаем; + - `session_kind`: `days < 14` → `"return"`, `days ≥ 14` → `"test"` + (правило 17), для illness → `"return"`; + - simple 4–6 дней: подходы `−1` (мин 1), `target_rpe` = 5; + - `cardio_after_min` не меняется (кардио возвращается со 100 %, + Аттия); + - rationale — «возврат после break N дней (тип T): веса ×K». +4. `SessionKinds` (domain Workout) — константы normal/return/deload/test; + в body week 1 sessions пишется `"session_kind"`; телефон при старте + тренировки проставит `workout.session_kind` из активной версии. + +🛡 Версии неизменяемы; активация — только человеком (Ф-14.4). diff --git a/1-docs/4-modules/m05-progression-rules/api_specs/05-sync-wiring.md b/1-docs/4-modules/m05-progression-rules/api_specs/05-sync-wiring.md new file mode 100644 index 0000000..7b24199 --- /dev/null +++ b/1-docs/4-modules/m05-progression-rules/api_specs/05-sync-wiring.md @@ -0,0 +1,65 @@ +# Спецификация 05: обвязка sync и DI + +Источники: Ф-12.1 («после каждой синхронизированной тренировки»), +Ф-14.6 (офлайн-режим: правила — при следующем sync), образец — m04 +spec 04. + +## 1. Порядок в SyncService (после пересчёта метрик m04) + +Для каждого workout из пакета, после `ComputeForWorkout`: + +```csharp +try { breakEngine.CloseOnReturn(athleteId, workout, today); } +catch (Exception ex) { logger.LogWarning(ex, "rules: возврат не удался ({WorkoutId})", id); } + +try { progressionRules.EvaluateAfterWorkout(athleteId, workoutId); } +catch (Exception ex) { logger.LogWarning(ex, "rules: микроправка не удалась ({WorkoutId})", id); } +``` + +Один раз на пакет (не на workout): + +```csharp +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: делоад не удался"); } +``` + +- 🛡 сбой правил не влияет на ответ sync (200, как у метрик); +- всё после `scope.Complete()` (errata m04 №3 — TransactionScope); +- логирование — `ILogger` (errata m04 №4). + +## 2. DI (WebAppStartup) + +``` +AddSingleton(config.Progression); // ProgressionConfig +AddSingleton(); +AddSingleton(); +``` + +Конструкторы (по факту, З-42 п. 3; все зависимости уже +зарегистрированы m01–m04): + +```csharp +ProgressionRules(IWorkoutRepository, IExerciseRepository, + ProgramStore, ProgressionConfig) +BreakEngine(IWorkoutRepository, IBreakRepository, + ProgramStore, ProgressionConfig) +``` + +(Ранняя редакция с `IWorkoutMetricsRepository` не реализована: +опорная точка делаода читается через репозиторий тренировок — +`session_kind=deload`, иначе `CreatedAt` активной версии, см. spec 03.) + +## 3. Конфиг (ServerConfig) + +`ServerConfig.Progression` (`ProgressionConfig`), `Load` из секции +`progression`, `Validate` → ошибки старта. Секция добавляется в +`appsettings.json` и `appsettings.test.json`. + +## 4. Инварианты 🛡 + +- правила вызываются только после коммита пакета и метрик; +- сбой любого шага — Warning, sync успешен (module_reqs 🛡5); +- правила ничего не пишут кроме `program_version` (draft через + ProgramStore) и `breaks` (🛡4). diff --git a/1-docs/4-modules/m05-progression-rules/module_reqs.md b/1-docs/4-modules/m05-progression-rules/module_reqs.md new file mode 100644 index 0000000..9a79b4d --- /dev/null +++ b/1-docs/4-modules/m05-progression-rules/module_reqs.md @@ -0,0 +1,132 @@ +# Требования к модулю: m05-progression-rules + +> Модуль — сервисы `ProgressionRules` и `BreakEngine` в +> `Server.WatchBody.Core`: детерминированные правила прогрессии и +> делоада (Ф-12.2–12.4, правила 3–5 и 10 документа метрик), перерывы +> и возврат (Ф-14, правила 12–17 документа перерывов). Результат +> работы — предложение draft-версии программы (`author=rules`) через +> ProgramStore (m03). Самодостаточная копия требований (d-dev-6). + +## 1. Назначение и границы + +**В границах модуля:** + +- класс `ProgressionRules` (`Server.WatchBody.Core`, каталог + `Programs\`): микроправка весов на следующее занятие после каждой + синхронизированной тренировки (Ф-12.2) и делоад (Ф-12.4) — + предложение draft-версии (`author=rules`, `rationale`); +- класс `BreakEngine` (тот же каталог): авто-детект перерыва по + пропускам (Ф-14.1, `break_insert` source=auto), закрытие break + первой сессией (`ended_on`), формула возврата (Ф-14.3, документ + перерывов разд. 3.2) → draft-версия с `session_kind=return` + (Ф-14.4–14.5), учёт правила 17 (break ≥ 14 дней → `session_kind=test`); +- вызов из m02: после пересчёта метрик (m04) sync вызывает правила; + сбой расчёта не откатывает sync (как у метрик); +- пороги — из конфигурации сервера `Progression.*`/`Deload.*` + (умолчания — requirements разд. 3); коэффициенты возврата — + документ перерывов разд. 3.2; +- тесты сервисов (интеграционные, docker `watchbody-pg`). + +**Вне границ модуля:** показ диффа и подтверждение предложения +(телефон m09/m10, веб-консоль m08 — Ф-12.3); активация draft-версии +(только явным действием атлета/владельца, 🛡 vision 2.4); пометка +следующей тренировки лёгкой — телефон по Ф-13.2 (обнаружение — +`red_flags` m04); правила координации 7–9 (вес тела/дефицит) и +11 (VO2max) — v2/v3 (документ метрик п. 5); правила 13/14 (средняя +тренировка при аврале, домашний комплекс) — телефон; правило 15 +(ужесточение порогов АД после break ≥ 14 дней) — кандидат v2 (меняет +пороги m04, отдельное решение); свободный текст ограничений +(`body.profile.limitations`) правила не интерпретируют — учёт +ограничений детерминированный, через персональную библиотеку атлета +(см. разд. 2.4); интерпретация текста — LLM-контур A (v2). + +## 2.4. Учёт индивидуальных ограничений атлета (решение гейта 1.3) + +Ограничения атлета (травмы, недоступные тренажёры, противопоказания) +влияют на набор упражнений в предложениях правил детерминированно, +через персональную библиотеку (🛡 библиотека принадлежит атлету, +Ф-4.4; ведут атлет/владелец по своим ограничениям): + +1. `src.exercise.is_active=false` — «противопоказано/исключено»: + упражнение не попадает в draft-версию; микроправка для него + решений не генерирует; сессия только из неактивных упражнений + исключается из плана целиком. +2. Исключаемое упражнение с парой-заменой в `src.exercise_replacement` + (замена активна, та же группа) → в draft подставляется замена + (веса исходного плана; пометка в `rationale`). +3. Сопоставление плана и факта: `body.exercises[].exercise_id` + == `src.exercise.name` (так создаёт импорт пакета, m06); + упражнение библиотеки не найдено → решение не генерируется, + пометка в `rationale`. +4. Свободный текст `body.profile.limitations` переносится между + версиями без изменений (не интерпретируется). + +## 2. Правила (v1 — детерминированные) + +### 2.1. Микроправка весов (Ф-12.2, документ метрик правила 3–5) + +По каждому упражнению тренировки, по факту `workout_set`: + +| # | Условие | Действие | +|---|---|---| +| 1 | все плановые подходы закрыты «по плану» при RPE ≤ 7 в 2 тренировках подряд | вес + `Progression.BaseStepKg` (базовые) / + `Progression.IsoStepKg` (изоляция) | +| 2 | RPE ≥ 9 в 2+ сетах | вес − `Progression.BackoffPercent` % | +| 3 | повторения достигли верхней границы диапазона (15–20) | вес ↑ (шаг прогрессии), повторения к 12 | +| 4 | ≥ 2 подходов не дотянуты по повторениям | вес − `Progression.BackoffPercent` % | +| 5 | иначе (частичное выполнение) | вес без изменения | + +Правила проверяются по порядку, срабатывает первое; решения по всем +упражнениям собираются в одну draft-версию. + +### 2.2. Делоад (Ф-12.4, правило 10) + +`Deload.MaxWeeks` недель подряд без делоада → draft-версия с объёмом +−40–50 % (веса × 0.6, подходы −1), `session_kind=deload`. + +### 2.3. Перерывы и возврат (Ф-14, документ перерывов разд. 3.2) + +- авто-детект: все запланированные сессии за 4 дня подряд пропущены → + `break_insert` (source=auto); break закрывается первой сессией + (`ended_on`); +- формула возврата по длительности простоя: 4–6 дней → 100 % (первая + сессия −1 подход, RPE 5–6); 7–13 → −10 %; 14–27 → −15 % (1 вводная + неделя); 28–59 → −25 % (2 вводные); 60+ / болезнь с температурой → + −30…−40 % (перезапуск). Кардио возвращается со 100 % сразу; +- первая сессия после break → draft-версия с формульными весами, + `session_kind=return` (после break ≥ 14 дней — `test`), rationale — + причина снижения. Применение — подтверждением атлета/владельца + (Ф-12.3), 🛡 автоматической смены активной версии нет. + +## 3. Инварианты (🛡 — нарушать запрещено) + +1. **Изоляция атлета** — все выборки и записи по одному `athleteId`. +2. **Только предложение, не смена** — правила создают draft-версии + (`author=rules`); активация — только человеком (Ф-12.3, vision 2.4). +3. **Неизменяемость версий** — правила ничего не переписывают в + существующих версиях; новая версия — через ProgramStore.CreateDraft + (m03); ссылки тренировок на версии не трогаются. +4. **Без записи в факт** — правила ничего не пишут в + `workout`/`workout_set`/`workout_metrics`/`daily_log`; запись + разрешена только в `breaks` (детект/закрытие перерыва). +5. **Сбой правил не ломает sync** — исключение логируется, пакет + остаётся принятым (как у метрик m04). +6. **Детерминированность** — тот же вход → тот же выход; никакого LLM. +7. **Пороги — только из конфига** (`Progression.*`, `Deload.*`; + коэффициенты возврата — константы документа перерывов). + +## 4. Критерии приёмки модуля + +1. Микроправка: сценарии правил 1–4 из разд. 2.1 дают ожидаемые + решения; правило 5 — вес не меняется. +2. Draft-версия создаётся через ProgramStore (`author=rules`, + `based_on_version` = активная, `rationale` заполнен), активная + версия не меняется; повторный вызов до подтверждения не плодит + черновики (одна rules-черновая на активную версию). +3. Делоад: `Deload.MaxWeeks` без делоада → draft с `session_kind=deload`. +4. Перерыв: пропуски 4 дней → строка `breaks` (source=auto); первая + сессия закрывает break (`ended_on`); draft возврата с + `session_kind=return|test` и коэффициентом по таблице. +5. Изоляция: данные атлета Б не влияют на решения атлета А. +6. Сбой правил не мешает принятию пакета sync. +7. Тесты интеграционные против docker `watchbody-pg` — зелёные; + сборка решения без ошибок. diff --git a/1-docs/4-modules/m05-progression-rules/readme.md b/1-docs/4-modules/m05-progression-rules/readme.md new file mode 100644 index 0000000..8542092 --- /dev/null +++ b/1-docs/4-modules/m05-progression-rules/readme.md @@ -0,0 +1,51 @@ +# m05-progression-rules — план работ (d-dev) + +> Модуль: ProgressionRules + BreakEngine — детерминированные правила +> прогрессии/делоада и перерывов в `Server.WatchBody.Core` → +> draft-версии программ (`author=rules`). +> Алгоритм: `std1\_rules\d\d-dev.md` (итерационная разработка по +> модулям). Требования: [module_reqs.md](module_reqs.md). +> Зависимости: m01 (репозитории, DONE), m02 (sync — точка вызова, +> DONE), m03 (ProgramStore — создание draft, DONE), m04 (метрики и +> красные флаги — источник данных, DONE). + +| Шаг | Что делаем | Статус | +|---|---|---| +| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-18 | +| 1 | Список единиц (классы/методы/конфиг/тесты) | ✅ 2026-10-18 | +| 1.3 | **Пользователь!** утверждение списка | ✅ 2026-10-18 (утверждено; + фильтр ограничений — module_reqs 2.4) | +| 2 | Спецификации: api_specs (контракт правил, формула возврата) | ✅ 2026-10-18 | +| 2.4 | **Пользователь!** утверждение спецификаций | ✅ 2026-10-18 | +| 3 | Реализация + тесты | ✅ 2026-10-18 (тесты Web 80/80, из них m05 — 19; Core 56/56) | +| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-18 (утверждено вместе с пакетом З-36/37/39/41, волна 2) | +| 4 | Завершение: план, примечания | ✅ 2026-10-18 — **Модуль завершён** (2026-10-18). Следующий по плану этапа 7 — m06-fit-import (итерация 2). | + +Статусы: ⬜ не начат · 🔄 в работе · ✅ готов. + +## Примечания по ходу работ + +1. **`is_active` задаётся только `exercise_delete`** (З-34): insert/update + упражнения параметр `p_is_active` не принимают — это дизайн m01, не + errata. Ограничения атлета ведутся логическим удалением упражнения; + `exercise_select` при NULL-фильтре возвращает и неактивные — правила + видят их для подбора пары-замены. Серверный контур замкнут З-41(а) + (после волны 2): sync при `is_active=false` вызывает `exercise_delete` + — телефон может исключать упражнения без веб-консоли (m08). +2. Подписи в spec 02–04 синхронизированы с кодом: `today` — явный + параметр (тестируемость), `CloseOnReturn` принимает `workoutId` + (в sync есть только идентификатор). +3. Материализация `Weeks/Sessions` обязательна: удаление узлов body + (исключение упражнений/сессий) во время ленивого обхода JsonNode + ломает перечисление. +4. Известное ограничение v1: если атлет отклонил rules-черновик и + активная версия не менялась, новые предложения не создаются, пока + черновик не исчезнет (активация/архивация). Кандидат на доработку + в m08 (кнопка «отклонить» → архивация черновика). +5. Decimal в интерполяциях rationale — культура сервера; тесты + сравнивают веса из body (System.Text.Json пишет инвариантно). +6. Опорная точка делаода в v1 (З-42 п. 5): только факт-тренировка с + `session_kind=deload`, иначе `CreatedAt` активной версии; вариант + «rules-draft с deload в week 1» из ранней редакции spec 03 не + реализован — кандидат m08 (согласовано со spec 03). +7. Число Core-тестов 56 (не 55): тест `Insert_2СExternalId` добавлен + в m01 по З-35 после закрытия шага 3 (см. readme m01). diff --git a/2-db/migrations/001_schemas_and_ref.sql b/2-db/migrations/001_schemas_and_ref.sql new file mode 100644 index 0000000..9cbdc2f --- /dev/null +++ b/2-db/migrations/001_schemas_and_ref.sql @@ -0,0 +1,19 @@ +-- 001_schemas_and_ref.sql: схемы-слои + справочник двигательных групп. +-- Идемпотентно (IF NOT EXISTS / ON CONFLICT DO NOTHING). + +CREATE SCHEMA IF NOT EXISTS src; +CREATE SCHEMA IF NOT EXISTS ref; + +CREATE TABLE IF NOT EXISTS ref.exercise_group ( + id int PRIMARY KEY, + code text NOT NULL UNIQUE, + title text NOT NULL +); + +INSERT INTO ref.exercise_group (id, code, title) VALUES + (1, 'push', 'Жимовые'), + (2, 'pull', 'Тяговые'), + (3, 'legs', 'Ноги'), + (4, 'core', 'Кор'), + (5, 'cardio', 'Кардио') +ON CONFLICT (code) DO NOTHING; diff --git a/2-db/migrations/002_src_tables.sql b/2-db/migrations/002_src_tables.sql new file mode 100644 index 0000000..007f629 --- /dev/null +++ b/2-db/migrations/002_src_tables.sql @@ -0,0 +1,148 @@ +-- 002_src_tables.sql: таблицы слоя src (порядок — FK-зависимости). +-- Идемпотентно (IF NOT EXISTS); состав — 1-docs\2-db\src\tables.md. + +CREATE TABLE IF NOT EXISTS src.athlete ( + id serial PRIMARY KEY, + name text NOT NULL, + api_key_hash text NOT NULL UNIQUE, + is_blocked boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS src.athlete_profile ( + id serial PRIMARY KEY, + athlete_id int NOT NULL UNIQUE REFERENCES src.athlete(id), + sex text CHECK (sex IN ('m', 'f')), + birth_year int, + weight_kg numeric(5,1), + hr_max int, + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS src.exercise ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + athlete_id int NOT NULL REFERENCES src.athlete(id), + name text NOT NULL, + group_id int NOT NULL REFERENCES ref.exercise_group(id), + is_base boolean NOT NULL DEFAULT true, + is_active boolean NOT NULL DEFAULT true, + UNIQUE (athlete_id, name) +); + +CREATE TABLE IF NOT EXISTS src.exercise_replacement ( + exercise_id uuid NOT NULL REFERENCES src.exercise(id), + replacement_id uuid NOT NULL REFERENCES src.exercise(id), + CHECK (exercise_id <> replacement_id), + PRIMARY KEY (exercise_id, replacement_id) +); + +CREATE TABLE IF NOT EXISTS src.program ( + id serial PRIMARY KEY, + athlete_id int NOT NULL UNIQUE REFERENCES src.athlete(id), + title text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS src.program_version ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + program_id int NOT NULL REFERENCES src.program(id), + version int NOT NULL, + based_on_version int, + status text NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'active', 'archived')), + author text NOT NULL CHECK (author IN ('user', 'admin', 'rules', 'llm')), + body jsonb NOT NULL, + rationale text, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (program_id, version) +); + +-- 🛡 активная версия — ровно одна на программу +CREATE UNIQUE INDEX IF NOT EXISTS uq_program_version_active + ON src.program_version (program_id) WHERE status = 'active'; + +CREATE TABLE IF NOT EXISTS src.workout ( + id uuid PRIMARY KEY, + athlete_id int NOT NULL REFERENCES src.athlete(id), + program_version_id uuid REFERENCES src.program_version(id), + started_at timestamptz NOT NULL, + ended_at timestamptz, + status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'finished', 'cancelled')), + session_kind text NOT NULL DEFAULT 'normal' + CHECK (session_kind IN ('normal', 'return', 'deload', 'test')), + avg_hr int, + facts_json jsonb, + fit_file_id text, + hr_recovery_s int, + UNIQUE (athlete_id, fit_file_id) +); + +CREATE TABLE IF NOT EXISTS src.workout_set ( + id uuid PRIMARY KEY, + workout_id uuid NOT NULL REFERENCES src.workout(id), + athlete_id int NOT NULL REFERENCES src.athlete(id), + exercise_id uuid REFERENCES src.exercise(id), + seq int NOT NULL, + weight_kg numeric(6,2) NOT NULL, + reps int NOT NULL, + done_at timestamptz NOT NULL, + source text NOT NULL CHECK (source IN ('voice', 'watch', 'edit')), + in_plan boolean NOT NULL, + rpe int CHECK (rpe BETWEEN 1 AND 10), + knee_pain int CHECK (knee_pain BETWEEN 0 AND 10) +); + +CREATE TABLE IF NOT EXISTS src.command_log ( + id uuid PRIMARY KEY, + workout_id uuid NOT NULL REFERENCES src.workout(id), + t_offset_s int NOT NULL, + recognized_text text NOT NULL, + semantics jsonb NOT NULL, + accepted boolean NOT NULL +); + +CREATE TABLE IF NOT EXISTS src.daily_log ( + id uuid PRIMARY KEY, + athlete_id int NOT NULL REFERENCES src.athlete(id), + date date NOT NULL, + bp_sys int, + bp_dias int, + weight_kg numeric(5,1), + hr_resting int, + waist_cm numeric(5,1), + sleep_h numeric(3,1), + wellbeing int CHECK (wellbeing BETWEEN 1 AND 5), + UNIQUE (athlete_id, date) +); + +CREATE TABLE IF NOT EXISTS src.workout_metrics ( + id serial PRIMARY KEY, + workout_id uuid NOT NULL UNIQUE REFERENCES src.workout(id), + athlete_id int NOT NULL REFERENCES src.athlete(id), + adherence numeric(4,1), + tonnage numeric(9,1), + tonnage_by_group jsonb, + e1pm_by_exercise jsonb, + hr_metrics jsonb, + red_flags jsonb, + computed_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS src.breaks ( + id uuid PRIMARY KEY, + athlete_id int NOT NULL REFERENCES src.athlete(id), + kind text CHECK (kind IN + ('illness', 'vacation', 'workload', 'injury', 'other')), + source text NOT NULL DEFAULT 'auto' CHECK (source IN ('auto', 'manual')), + started_on date NOT NULL, + planned_return date, + ended_on date, + note text +); + +CREATE TABLE IF NOT EXISTS src.migration ( + id serial PRIMARY KEY, + filename text NOT NULL UNIQUE, + applied_at timestamptz NOT NULL DEFAULT now() +); diff --git a/2-db/migrations/003_program_external_id.sql b/2-db/migrations/003_program_external_id.sql new file mode 100644 index 0000000..47ff47a --- /dev/null +++ b/2-db/migrations/003_program_external_id.sql @@ -0,0 +1,7 @@ +-- 003_program_external_id.sql: колонка external_id в src.program (З-31, +-- доведено до БД по З-35, ревью этапа 7 2026-10-18). +-- Идемпотентно; NULL не ограничивается (программа из sync создаётся без пакета). + +ALTER TABLE src.program ADD COLUMN IF NOT EXISTS external_id text; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_program_external_id ON src.program (external_id); diff --git a/2-db/ref/functions/exercise_group_select.psql b/2-db/ref/functions/exercise_group_select.psql new file mode 100644 index 0000000..4aaecce --- /dev/null +++ b/2-db/ref/functions/exercise_group_select.psql @@ -0,0 +1,15 @@ +-- ref.exercise_group_select: выбор всех двигательных групп (справочник неизменяем). +-- На основе: новая. +-- Параметры: нет. +-- Возвращает: TABLE — строки ref.exercise_group, порядок по id. + +CREATE OR REPLACE FUNCTION ref.exercise_group_select() +RETURNS TABLE ( + id int, + code text, + title text +) LANGUAGE sql AS $$ + SELECT g.id, g.code, g.title + FROM ref.exercise_group g + ORDER BY g.id; +$$; diff --git a/2-db/ref/tests/test_exercise_group_select.psql b/2-db/ref/tests/test_exercise_group_select.psql new file mode 100644 index 0000000..5af72ce --- /dev/null +++ b/2-db/ref/tests/test_exercise_group_select.psql @@ -0,0 +1,34 @@ +-- test_exercise_group_select: тесты ref.exercise_group_select (спецификация — func_specs\exercise_group_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. +-- Тест #2 требует пустого справочника — выполняется на тестовой базе до seed +-- либо с временно пустой таблицей (здесь: проверка состава после seed). + +BEGIN; + +DO $$ +DECLARE + v_count int; + v_codes text; +BEGIN + -- #2 граничный случай: пустой справочник (до seed) — проверяем на очищенной таблице + CREATE TEMP TABLE ref_exercise_group_bak ON COMMIT DROP AS + SELECT * FROM ref.exercise_group; + DELETE FROM ref.exercise_group; + SELECT count(*) INTO v_count FROM ref.exercise_group_select(); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL ref.exercise_group_select #2: ожидалось 0 строк, got %', v_count; + END IF; + INSERT INTO ref.exercise_group SELECT * FROM ref_exercise_group_bak; + RAISE NOTICE 'PASS ref.exercise_group_select #2'; + + -- #1 основной сценарий: seed из миграции 001 + SELECT count(*), string_agg(code, ',' ORDER BY id) INTO v_count, v_codes + FROM ref.exercise_group_select(); + IF v_count <> 5 OR v_codes <> 'push,pull,legs,core,cardio' THEN + RAISE EXCEPTION 'FAIL ref.exercise_group_select #1: got % / %', v_count, v_codes; + END IF; + RAISE NOTICE 'PASS ref.exercise_group_select #1'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/functions/athlete_get.psql b/2-db/src/functions/athlete_get.psql new file mode 100644 index 0000000..ce92fbe --- /dev/null +++ b/2-db/src/functions/athlete_get.psql @@ -0,0 +1,19 @@ +-- src.athlete_get: одна строка атлета по id. +-- На основе: новая. +-- Параметры: p_id — атлет. +-- Возвращает: TABLE — строка src.athlete; не найден — пустая таблица. + +CREATE OR REPLACE FUNCTION src.athlete_get( + p_id int +) +RETURNS TABLE ( + id int, + name text, + api_key_hash text, + is_blocked boolean, + created_at timestamptz +) LANGUAGE sql AS $$ + SELECT a.id, a.name, a.api_key_hash, a.is_blocked, a.created_at + FROM src.athlete a + WHERE a.id = p_id; +$$; diff --git a/2-db/src/functions/athlete_insert.psql b/2-db/src/functions/athlete_insert.psql new file mode 100644 index 0000000..b8270c4 --- /dev/null +++ b/2-db/src/functions/athlete_insert.psql @@ -0,0 +1,19 @@ +-- src.athlete_insert: регистрация атлета (владелец/AdminWeb). +-- На основе: новая. +-- Параметры: p_name — имя; p_api_key_hash — SHA-256 ключа. +-- Возвращает: int — id атлета; дубликат hash — исключение UNIQUE. + +CREATE OR REPLACE FUNCTION src.athlete_insert( + p_name text, + p_api_key_hash text +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_id int; +BEGIN + INSERT INTO src.athlete (name, api_key_hash) + VALUES (p_name, p_api_key_hash) + RETURNING id INTO v_id; + RETURN v_id; +END; +$$; diff --git a/2-db/src/functions/athlete_profile_get.psql b/2-db/src/functions/athlete_profile_get.psql new file mode 100644 index 0000000..a8db8e7 --- /dev/null +++ b/2-db/src/functions/athlete_profile_get.psql @@ -0,0 +1,22 @@ +-- src.athlete_profile_get: профиль атлета по athlete_id. +-- На основе: новая. +-- Параметры: p_athlete_id (🛡 изоляция). +-- Возвращает: TABLE — строка src.athlete_profile; профиля нет — пустая таблица. + +CREATE OR REPLACE FUNCTION src.athlete_profile_get( + p_athlete_id int +) +RETURNS TABLE ( + id int, + athlete_id int, + sex text, + birth_year int, + weight_kg numeric, + hr_max int, + updated_at timestamptz +) LANGUAGE sql AS $$ + SELECT ap.id, ap.athlete_id, ap.sex, ap.birth_year, + ap.weight_kg, ap.hr_max, ap.updated_at + FROM src.athlete_profile ap + WHERE ap.athlete_id = p_athlete_id; +$$; diff --git a/2-db/src/functions/athlete_profile_upsert.psql b/2-db/src/functions/athlete_profile_upsert.psql new file mode 100644 index 0000000..1bb7ebf --- /dev/null +++ b/2-db/src/functions/athlete_profile_upsert.psql @@ -0,0 +1,30 @@ +-- src.athlete_profile_upsert: сохранение профиля атлета (1:1, ON CONFLICT). +-- На основе: новая. +-- Параметры: p_athlete_id; p_sex; p_birth_year; p_weight_kg; p_hr_max (NULL → Танака). +-- Возвращает: int — id записи профиля; updated_at ставится в обоих путях. + +CREATE OR REPLACE FUNCTION src.athlete_profile_upsert( + p_athlete_id int, + p_sex text DEFAULT NULL, + p_birth_year int DEFAULT NULL, + p_weight_kg numeric DEFAULT NULL, + p_hr_max int DEFAULT NULL +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_id int; +BEGIN + INSERT INTO src.athlete_profile + (athlete_id, sex, birth_year, weight_kg, hr_max, updated_at) + VALUES + (p_athlete_id, p_sex, p_birth_year, p_weight_kg, p_hr_max, now()) + ON CONFLICT (athlete_id) DO UPDATE + SET sex = EXCLUDED.sex, + birth_year = EXCLUDED.birth_year, + weight_kg = EXCLUDED.weight_kg, + hr_max = EXCLUDED.hr_max, + updated_at = now() + RETURNING id INTO v_id; + RETURN v_id; +END; +$$; diff --git a/2-db/src/functions/athlete_select.psql b/2-db/src/functions/athlete_select.psql new file mode 100644 index 0000000..9ee54a5 --- /dev/null +++ b/2-db/src/functions/athlete_select.psql @@ -0,0 +1,22 @@ +-- src.athlete_select: выбор атлетов с фильтрами (middleware, AdminWeb). +-- На основе: новая. +-- Параметры: p_api_key_hash, p_is_blocked — NULL = фильтр не применяется. +-- Возвращает: TABLE — строки src.athlete, порядок по name. + +CREATE OR REPLACE FUNCTION src.athlete_select( + p_api_key_hash text DEFAULT NULL, + p_is_blocked boolean DEFAULT NULL +) +RETURNS TABLE ( + id int, + name text, + api_key_hash text, + is_blocked boolean, + created_at timestamptz +) LANGUAGE sql AS $$ + SELECT a.id, a.name, a.api_key_hash, a.is_blocked, a.created_at + FROM src.athlete a + WHERE (p_api_key_hash IS NULL OR a.api_key_hash = p_api_key_hash) + AND (p_is_blocked IS NULL OR a.is_blocked = p_is_blocked) + ORDER BY a.name; +$$; diff --git a/2-db/src/functions/athlete_update.psql b/2-db/src/functions/athlete_update.psql new file mode 100644 index 0000000..6c556a9 --- /dev/null +++ b/2-db/src/functions/athlete_update.psql @@ -0,0 +1,24 @@ +-- src.athlete_update: правка атлета — имя, хэш ключа, блокировка. +-- На основе: новая. +-- Параметры: p_id; p_name; p_api_key_hash; p_is_blocked. NULL-семантики нет (db-m-8). +-- Возвращает: int — число изменённых строк (несуществующий id → 0). + +CREATE OR REPLACE FUNCTION src.athlete_update( + p_id int, + p_name text, + p_api_key_hash text, + p_is_blocked boolean +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_count int; +BEGIN + UPDATE src.athlete a + SET name = p_name, + api_key_hash = p_api_key_hash, + is_blocked = p_is_blocked + WHERE a.id = p_id; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; diff --git a/2-db/src/functions/break_insert.psql b/2-db/src/functions/break_insert.psql new file mode 100644 index 0000000..12fe022 --- /dev/null +++ b/2-db/src/functions/break_insert.psql @@ -0,0 +1,32 @@ +-- src.break_insert: открытие перерыва (auto — сервер, manual — телефон, Ф-14). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id; p_kind (NULL — ответа нет); p_source; +-- p_started_on; p_planned_return; p_note. +-- Возвращает: uuid — id перерыва; повтор p_id — существующий id. + +CREATE OR REPLACE FUNCTION src.break_insert( + p_athlete_id int, + p_id uuid, + p_kind text, + p_source text, + p_started_on date, + p_planned_return date DEFAULT NULL, + p_note text DEFAULT '' +) +RETURNS uuid LANGUAGE plpgsql AS $$ +DECLARE + v_id uuid; +BEGIN + SELECT b.id INTO v_id FROM src.breaks b + WHERE b.id = p_id AND b.athlete_id = p_athlete_id; + IF v_id IS NOT NULL THEN + RETURN v_id; + END IF; + + INSERT INTO src.breaks + (id, athlete_id, kind, source, started_on, planned_return, note) + VALUES + (p_id, p_athlete_id, p_kind, p_source, p_started_on, p_planned_return, p_note); + RETURN p_id; +END; +$$; diff --git a/2-db/src/functions/break_select.psql b/2-db/src/functions/break_select.psql new file mode 100644 index 0000000..928ad5d --- /dev/null +++ b/2-db/src/functions/break_select.psql @@ -0,0 +1,30 @@ +-- src.break_select: журнал перерывов атлета (правила 12–17, CSV). +-- На основе: новая. +-- Параметры: p_athlete_id; p_kind/p_date_from/p_date_to — NULL = не применяется. +-- Возвращает: TABLE — строки breaks, порядок по started_on (убывание). + +CREATE OR REPLACE FUNCTION src.break_select( + p_athlete_id int, + p_kind text DEFAULT NULL, + p_date_from date DEFAULT NULL, + p_date_to date DEFAULT NULL +) +RETURNS TABLE ( + id uuid, + athlete_id int, + kind text, + source text, + started_on date, + planned_return date, + ended_on date, + note text +) LANGUAGE sql AS $$ + SELECT b.id, b.athlete_id, b.kind, b.source, b.started_on, + b.planned_return, b.ended_on, b.note + FROM src.breaks b + WHERE b.athlete_id = p_athlete_id + AND (p_kind IS NULL OR b.kind = p_kind) + AND (p_date_from IS NULL OR b.started_on >= p_date_from) + AND (p_date_to IS NULL OR b.started_on <= p_date_to) + ORDER BY b.started_on DESC; +$$; diff --git a/2-db/src/functions/break_update.psql b/2-db/src/functions/break_update.psql new file mode 100644 index 0000000..c134211 --- /dev/null +++ b/2-db/src/functions/break_update.psql @@ -0,0 +1,28 @@ +-- src.break_update: ответ атлета (Ф-14.2) и закрытие перерыва. +-- На основе: новая. +-- Параметры: p_athlete_id; p_id; p_kind; p_note; p_planned_return; p_ended_on. +-- NULL-семантики нет (db-m-8); kind NULL валиден (ответа ещё нет). +-- Возвращает: int — число изменённых строк (чужой/нет → 0). + +CREATE OR REPLACE FUNCTION src.break_update( + p_athlete_id int, + p_id uuid, + p_kind text DEFAULT NULL, + p_note text DEFAULT '', + p_planned_return date DEFAULT NULL, + p_ended_on date DEFAULT NULL +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_count int; +BEGIN + UPDATE src.breaks b + SET kind = p_kind, + note = p_note, + planned_return = p_planned_return, + ended_on = p_ended_on + WHERE b.id = p_id AND b.athlete_id = p_athlete_id; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; diff --git a/2-db/src/functions/command_log_insert.psql b/2-db/src/functions/command_log_insert.psql new file mode 100644 index 0000000..9164a59 --- /dev/null +++ b/2-db/src/functions/command_log_insert.psql @@ -0,0 +1,43 @@ +-- src.command_log_insert: запись голосовой команды (🛡 журнал не редактируется). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id; p_workout_id; p_t_offset_s; p_recognized_text; +-- p_semantics; p_accepted. +-- Возвращает: uuid — id записи; повтор p_id — существующий id. + +CREATE OR REPLACE FUNCTION src.command_log_insert( + p_athlete_id int, + p_id uuid, + p_workout_id uuid, + p_t_offset_s int, + p_recognized_text text, + p_semantics text, -- errata m02: text, каста в теле + p_accepted boolean +) +RETURNS uuid LANGUAGE plpgsql AS $$ +DECLARE + v_id uuid; +BEGIN + SELECT c.id INTO v_id FROM src.command_log c + WHERE c.id = p_id AND EXISTS ( + SELECT 1 FROM src.workout w + WHERE w.id = c.workout_id AND w.athlete_id = p_athlete_id + ); + IF v_id IS NOT NULL THEN + RETURN v_id; + END IF; + + -- тренировка должна принадлежать атлету (изоляция 🛡; FK чужую не отсекает) + IF NOT EXISTS ( + SELECT 1 FROM src.workout w + WHERE w.id = p_workout_id AND w.athlete_id = p_athlete_id + ) THEN + RAISE EXCEPTION 'тренировка % не принадлежит атлету %', p_workout_id, p_athlete_id; + END IF; + + INSERT INTO src.command_log + (id, workout_id, t_offset_s, recognized_text, semantics, accepted) + VALUES + (p_id, p_workout_id, p_t_offset_s, p_recognized_text, p_semantics::jsonb, p_accepted); + RETURN p_id; +END; +$$; diff --git a/2-db/src/functions/command_log_select.psql b/2-db/src/functions/command_log_select.psql new file mode 100644 index 0000000..c4ed090 --- /dev/null +++ b/2-db/src/functions/command_log_select.psql @@ -0,0 +1,24 @@ +-- src.command_log_select: журнал команд тренировки (сопоставление с .fit). +-- На основе: новая. +-- Параметры: p_athlete_id; p_workout_id. +-- Возвращает: TABLE — строки command_log, порядок по t_offset_s. + +CREATE OR REPLACE FUNCTION src.command_log_select( + p_athlete_id int, + p_workout_id uuid +) +RETURNS TABLE ( + id uuid, + workout_id uuid, + t_offset_s int, + recognized_text text, + semantics jsonb, + accepted boolean +) LANGUAGE sql AS $$ + SELECT c.id, c.workout_id, c.t_offset_s, c.recognized_text, + c.semantics, c.accepted + FROM src.command_log c + JOIN src.workout w ON w.id = c.workout_id + WHERE w.athlete_id = p_athlete_id AND c.workout_id = p_workout_id + ORDER BY c.t_offset_s; +$$; diff --git a/2-db/src/functions/daily_log_select.psql b/2-db/src/functions/daily_log_select.psql new file mode 100644 index 0000000..6ccce23 --- /dev/null +++ b/2-db/src/functions/daily_log_select.psql @@ -0,0 +1,30 @@ +-- src.daily_log_select: дневник атлета за период. +-- На основе: новая. +-- Параметры: p_athlete_id; p_date_from/p_date_to — NULL = фильтр не применяется. +-- Возвращает: TABLE — строки daily_log, порядок по date. + +CREATE OR REPLACE FUNCTION src.daily_log_select( + p_athlete_id int, + p_date_from date DEFAULT NULL, + p_date_to date DEFAULT NULL +) +RETURNS TABLE ( + id uuid, + athlete_id int, + date date, + bp_sys int, + bp_dias int, + weight_kg numeric, + hr_resting int, + waist_cm numeric, + sleep_h numeric, + wellbeing int +) LANGUAGE sql AS $$ + SELECT d.id, d.athlete_id, d.date, d.bp_sys, d.bp_dias, d.weight_kg, + d.hr_resting, d.waist_cm, d.sleep_h, d.wellbeing + FROM src.daily_log d + WHERE d.athlete_id = p_athlete_id + AND (p_date_from IS NULL OR d.date >= p_date_from) + AND (p_date_to IS NULL OR d.date <= p_date_to) + ORDER BY d.date; +$$; diff --git a/2-db/src/functions/daily_log_upsert.psql b/2-db/src/functions/daily_log_upsert.psql new file mode 100644 index 0000000..de40b20 --- /dev/null +++ b/2-db/src/functions/daily_log_upsert.psql @@ -0,0 +1,35 @@ +-- src.daily_log_upsert: сохранение дня дневника (UNIQUE(athlete_id, date)). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id; p_date; p_bp_sys; p_bp_dias; p_weight_kg; +-- p_hr_resting; p_wellbeing. +-- Возвращает: uuid — id записи (повтор дня — прежний id). + +CREATE OR REPLACE FUNCTION src.daily_log_upsert( + p_athlete_id int, + p_id uuid, + p_date date, + p_bp_sys int DEFAULT NULL, + p_bp_dias int DEFAULT NULL, + p_weight_kg numeric DEFAULT NULL, + p_hr_resting int DEFAULT NULL, + p_wellbeing int DEFAULT NULL +) +RETURNS uuid LANGUAGE plpgsql AS $$ +DECLARE + v_id uuid; +BEGIN + INSERT INTO src.daily_log + (id, athlete_id, date, bp_sys, bp_dias, weight_kg, hr_resting, wellbeing) + VALUES + (p_id, p_athlete_id, p_date, p_bp_sys, p_bp_dias, p_weight_kg, + p_hr_resting, p_wellbeing) + ON CONFLICT (athlete_id, date) DO UPDATE + SET bp_sys = EXCLUDED.bp_sys, + bp_dias = EXCLUDED.bp_dias, + weight_kg = EXCLUDED.weight_kg, + hr_resting = EXCLUDED.hr_resting, + wellbeing = EXCLUDED.wellbeing + RETURNING id INTO v_id; + RETURN v_id; +END; +$$; diff --git a/2-db/src/functions/exercise_delete.psql b/2-db/src/functions/exercise_delete.psql new file mode 100644 index 0000000..1d706cb --- /dev/null +++ b/2-db/src/functions/exercise_delete.psql @@ -0,0 +1,20 @@ +-- src.exercise_delete: логическое удаление упражнения (is_active = false, З-34). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id. +-- Возвращает: int — число изменённых строк (повтор → 0, строка не удаляется 🛡). + +CREATE OR REPLACE FUNCTION src.exercise_delete( + p_athlete_id int, + p_id uuid +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_count int; +BEGIN + UPDATE src.exercise e + SET is_active = false + WHERE e.id = p_id AND e.athlete_id = p_athlete_id AND e.is_active; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; diff --git a/2-db/src/functions/exercise_get.psql b/2-db/src/functions/exercise_get.psql new file mode 100644 index 0000000..0926f38 --- /dev/null +++ b/2-db/src/functions/exercise_get.psql @@ -0,0 +1,27 @@ +-- src.exercise_get: одно упражнение атлета + пары замен. +-- На основе: новая. +-- Параметры: p_athlete_id; p_id. +-- Возвращает: TABLE — строка exercise + replacement_ids; чужое/нет — пустая таблица. + +CREATE OR REPLACE FUNCTION src.exercise_get( + p_athlete_id int, + p_id uuid +) +RETURNS TABLE ( + id uuid, + athlete_id int, + name text, + group_id int, + is_base boolean, + is_active boolean, + replacement_ids jsonb +) LANGUAGE sql AS $$ + SELECT e.id, e.athlete_id, e.name, e.group_id, e.is_base, e.is_active, + COALESCE(( + SELECT jsonb_agg(er.replacement_id ORDER BY er.replacement_id) + FROM src.exercise_replacement er + WHERE er.exercise_id = e.id + ), '[]'::jsonb) AS replacement_ids + FROM src.exercise e + WHERE e.athlete_id = p_athlete_id AND e.id = p_id; +$$; diff --git a/2-db/src/functions/exercise_insert.psql b/2-db/src/functions/exercise_insert.psql new file mode 100644 index 0000000..7105f79 --- /dev/null +++ b/2-db/src/functions/exercise_insert.psql @@ -0,0 +1,28 @@ +-- src.exercise_insert: добавление упражнения в библиотеку атлета. +-- На основе: новая. +-- Параметры: p_athlete_id; p_id — uuid телефона; p_name; p_group_id; p_is_base. +-- Возвращает: uuid — id упражнения; повтор p_id — существующий id (идемпотентность 🛡). + +CREATE OR REPLACE FUNCTION src.exercise_insert( + p_athlete_id int, + p_id uuid, + p_name text, + p_group_id int, + p_is_base boolean +) +RETURNS uuid LANGUAGE plpgsql AS $$ +DECLARE + v_id uuid; +BEGIN + -- повтор p_id (ретрай sync) возвращает существующий id без ошибки + SELECT e.id INTO v_id FROM src.exercise e + WHERE e.id = p_id AND e.athlete_id = p_athlete_id; + IF v_id IS NOT NULL THEN + RETURN v_id; + END IF; + + INSERT INTO src.exercise (id, athlete_id, name, group_id, is_base) + VALUES (p_id, p_athlete_id, p_name, p_group_id, p_is_base); + RETURN p_id; +END; +$$; diff --git a/2-db/src/functions/exercise_replacement_delete.psql b/2-db/src/functions/exercise_replacement_delete.psql new file mode 100644 index 0000000..581ca0e --- /dev/null +++ b/2-db/src/functions/exercise_replacement_delete.psql @@ -0,0 +1,25 @@ +-- src.exercise_replacement_delete: удаление пары-замены. +-- На основе: новая. +-- Параметры: p_athlete_id; p_exercise_id; p_replacement_id. +-- Возвращает: int — число удалённых строк (чужая пара → 0). + +CREATE OR REPLACE FUNCTION src.exercise_replacement_delete( + p_athlete_id int, + p_exercise_id uuid, + p_replacement_id uuid +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_count int; +BEGIN + DELETE FROM src.exercise_replacement er + WHERE er.exercise_id = p_exercise_id + AND er.replacement_id = p_replacement_id + AND EXISTS ( + SELECT 1 FROM src.exercise e + WHERE e.id = er.exercise_id AND e.athlete_id = p_athlete_id + ); + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; diff --git a/2-db/src/functions/exercise_replacement_insert.psql b/2-db/src/functions/exercise_replacement_insert.psql new file mode 100644 index 0000000..f8e79dc --- /dev/null +++ b/2-db/src/functions/exercise_replacement_insert.psql @@ -0,0 +1,33 @@ +-- src.exercise_replacement_insert: добавление пары-замены (правило 6 — в пределах группы). +-- На основе: новая. +-- Параметры: p_athlete_id; p_exercise_id; p_replacement_id. +-- Возвращает: int — число вставленных строк; проверка группы и принадлежности — в функции. + +CREATE OR REPLACE FUNCTION src.exercise_replacement_insert( + p_athlete_id int, + p_exercise_id uuid, + p_replacement_id uuid +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_count int; +BEGIN + -- оба упражнения — атлета и в одной группе (правило 6); иначе исключение + IF NOT EXISTS ( + SELECT 1 FROM src.exercise a + JOIN src.exercise b ON b.id = p_replacement_id + WHERE a.id = p_exercise_id + AND a.athlete_id = p_athlete_id + AND b.athlete_id = p_athlete_id + AND a.group_id = b.group_id + ) THEN + RAISE EXCEPTION 'замена допустима только внутри группы того же атлета (exercise=%, replacement=%)', + p_exercise_id, p_replacement_id; + END IF; + + INSERT INTO src.exercise_replacement (exercise_id, replacement_id) + VALUES (p_exercise_id, p_replacement_id); + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; diff --git a/2-db/src/functions/exercise_select.psql b/2-db/src/functions/exercise_select.psql new file mode 100644 index 0000000..8e308c3 --- /dev/null +++ b/2-db/src/functions/exercise_select.psql @@ -0,0 +1,35 @@ +-- src.exercise_select: выбор упражнений атлета с фильтрами + пары замен. +-- На основе: новая. +-- Параметры: p_athlete_id; p_name/p_group_id/p_is_base/p_is_active — NULL = фильтр не применяется. +-- Возвращает: TABLE — строки exercise + replacement_ids jsonb (денормализация ММ). + +CREATE OR REPLACE FUNCTION src.exercise_select( + p_athlete_id int, + p_name text DEFAULT NULL, + p_group_id int DEFAULT NULL, + p_is_base boolean DEFAULT NULL, + p_is_active boolean DEFAULT NULL +) +RETURNS TABLE ( + id uuid, + athlete_id int, + name text, + group_id int, + is_base boolean, + is_active boolean, + replacement_ids jsonb +) LANGUAGE sql AS $$ + SELECT e.id, e.athlete_id, e.name, e.group_id, e.is_base, e.is_active, + COALESCE(( + SELECT jsonb_agg(er.replacement_id ORDER BY er.replacement_id) + FROM src.exercise_replacement er + WHERE er.exercise_id = e.id + ), '[]'::jsonb) AS replacement_ids + FROM src.exercise e + WHERE e.athlete_id = p_athlete_id + AND (p_name IS NULL OR e.name = p_name) + AND (p_group_id IS NULL OR e.group_id = p_group_id) + AND (p_is_base IS NULL OR e.is_base = p_is_base) + AND (p_is_active IS NULL OR e.is_active = p_is_active) + ORDER BY e.name; +$$; diff --git a/2-db/src/functions/exercise_update.psql b/2-db/src/functions/exercise_update.psql new file mode 100644 index 0000000..7a78fea --- /dev/null +++ b/2-db/src/functions/exercise_update.psql @@ -0,0 +1,25 @@ +-- src.exercise_update: правка упражнения — название, группа, «базовое». +-- На основе: новая. +-- Параметры: p_athlete_id; p_id; p_name; p_group_id; p_is_base. NULL-семантики нет (db-m-8). +-- Возвращает: int — число изменённых строк; exercise_id не меняется (Ф-4.3, 🛡). + +CREATE OR REPLACE FUNCTION src.exercise_update( + p_athlete_id int, + p_id uuid, + p_name text, + p_group_id int, + p_is_base boolean +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_count int; +BEGIN + UPDATE src.exercise e + SET name = p_name, + group_id = p_group_id, + is_base = p_is_base + WHERE e.id = p_id AND e.athlete_id = p_athlete_id; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; diff --git a/2-db/src/functions/migration_insert.psql b/2-db/src/functions/migration_insert.psql new file mode 100644 index 0000000..3e6dac3 --- /dev/null +++ b/2-db/src/functions/migration_insert.psql @@ -0,0 +1,18 @@ +-- src.migration_insert: фиксация применённой миграции в журнале. +-- На основе: новая. +-- Параметры: p_filename — имя применённого psql-файла из 2-db\migrations\. +-- Возвращает: int — id записи журнала. + +CREATE OR REPLACE FUNCTION src.migration_insert( + p_filename text +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_id int; +BEGIN + INSERT INTO src.migration (filename) + VALUES (p_filename) + RETURNING id INTO v_id; + RETURN v_id; +END; +$$; diff --git a/2-db/src/functions/migration_select.psql b/2-db/src/functions/migration_select.psql new file mode 100644 index 0000000..50e94ac --- /dev/null +++ b/2-db/src/functions/migration_select.psql @@ -0,0 +1,17 @@ +-- src.migration_select: проверка «применена ли миграция». +-- На основе: новая. +-- Параметры: p_filename — имя файла миграции. +-- Возвращает: TABLE — строка журнала; не найдена — пустая таблица. + +CREATE OR REPLACE FUNCTION src.migration_select( + p_filename text +) +RETURNS TABLE ( + id int, + filename text, + applied_at timestamptz +) LANGUAGE sql AS $$ + SELECT m.id, m.filename, m.applied_at + FROM src.migration m + WHERE m.filename = p_filename; +$$; diff --git a/2-db/src/functions/program_get.psql b/2-db/src/functions/program_get.psql new file mode 100644 index 0000000..bd41c12 --- /dev/null +++ b/2-db/src/functions/program_get.psql @@ -0,0 +1,24 @@ +-- src.program_get: одна строка программы атлета (+ активная версия). +-- На основе: новая. +-- Параметры: p_athlete_id; p_program_id. +-- Возвращает: TABLE — строка program + денормализация; чужая — пустая таблица. + +CREATE OR REPLACE FUNCTION src.program_get( + p_athlete_id int, + p_program_id int +) +RETURNS TABLE ( + id int, + athlete_id int, + title text, + created_at timestamptz, + active_version_id uuid, + active_version int +) LANGUAGE sql AS $$ + SELECT p.id, p.athlete_id, p.title, p.created_at, + v.id AS active_version_id, v.version AS active_version + FROM src.program p + LEFT JOIN src.program_version v + ON v.program_id = p.id AND v.status = 'active' + WHERE p.athlete_id = p_athlete_id AND p.id = p_program_id; +$$; diff --git a/2-db/src/functions/program_insert.psql b/2-db/src/functions/program_insert.psql new file mode 100644 index 0000000..721f151 --- /dev/null +++ b/2-db/src/functions/program_insert.psql @@ -0,0 +1,25 @@ +-- src.program_insert: создание программы атлета (контейнер версий). +-- На основе: новая (rev. 2 — З-31/З-35: p_external_id, идентификатор пакета program-import.json). +-- Параметры: p_athlete_id; p_title; p_external_id (NULL — программа не из пакета). +-- Возвращает: int — id программы; вторая программа атлета — исключение UNIQUE; +-- повтор p_external_id — исключение UNIQUE (новая версия пакета — путь импорта, m06). + +-- прежняя сигнатура без p_external_id снимается: иначе вызовы с +-- умолчанием неоднозначны (два оверлода с DEFAULT не допускаются) +DROP FUNCTION IF EXISTS src.program_insert(int, text); + +CREATE OR REPLACE FUNCTION src.program_insert( + p_athlete_id int, + p_title text, + p_external_id text DEFAULT NULL +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_id int; +BEGIN + INSERT INTO src.program (athlete_id, title, external_id) + VALUES (p_athlete_id, p_title, p_external_id) + RETURNING id INTO v_id; + RETURN v_id; +END; +$$; diff --git a/2-db/src/functions/program_select.psql b/2-db/src/functions/program_select.psql new file mode 100644 index 0000000..3cffc4c --- /dev/null +++ b/2-db/src/functions/program_select.psql @@ -0,0 +1,24 @@ +-- src.program_select: программы атлета + активная версия (денормализация, d-db-3). +-- На основе: новая. +-- Параметры: p_athlete_id. +-- Возвращает: TABLE — строки program + active_version_id/active_version (NULL — активной нет). + +CREATE OR REPLACE FUNCTION src.program_select( + p_athlete_id int +) +RETURNS TABLE ( + id int, + athlete_id int, + title text, + created_at timestamptz, + active_version_id uuid, + active_version int +) LANGUAGE sql AS $$ + SELECT p.id, p.athlete_id, p.title, p.created_at, + v.id AS active_version_id, v.version AS active_version + FROM src.program p + LEFT JOIN src.program_version v + ON v.program_id = p.id AND v.status = 'active' + WHERE p.athlete_id = p_athlete_id + ORDER BY p.id; +$$; diff --git a/2-db/src/functions/program_version_get.psql b/2-db/src/functions/program_version_get.psql new file mode 100644 index 0000000..65b92fb --- /dev/null +++ b/2-db/src/functions/program_version_get.psql @@ -0,0 +1,26 @@ +-- src.program_version_get: одна версия программы с телом (jsonb). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id. +-- Возвращает: TABLE — все колонки program_version (включая body); чужая — пустая таблица. + +CREATE OR REPLACE FUNCTION src.program_version_get( + p_athlete_id int, + p_id uuid +) +RETURNS TABLE ( + id uuid, + program_id int, + version int, + based_on_version int, + status text, + author text, + body jsonb, + rationale text, + created_at timestamptz +) LANGUAGE sql AS $$ + SELECT pv.id, pv.program_id, pv.version, pv.based_on_version, + pv.status, pv.author, pv.body, pv.rationale, pv.created_at + FROM src.program_version pv + JOIN src.program p ON p.id = pv.program_id + WHERE p.athlete_id = p_athlete_id AND pv.id = p_id; +$$; diff --git a/2-db/src/functions/program_version_insert.psql b/2-db/src/functions/program_version_insert.psql new file mode 100644 index 0000000..a9b56cf --- /dev/null +++ b/2-db/src/functions/program_version_insert.psql @@ -0,0 +1,42 @@ +-- src.program_version_insert: создание новой версии программы (всегда draft). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id (uuid телефона); p_program_id; p_based_on_version; p_author; p_body; p_rationale. +-- Возвращает: uuid — id версии; version = max+1; тело после создания не меняется (🛡). + +CREATE OR REPLACE FUNCTION src.program_version_insert( + p_athlete_id int, + p_id uuid, + p_program_id int, + p_based_on_version int, + p_author text, + p_body text, -- errata m02: Npgsql передаёт строку как text; каста в теле + p_rationale text DEFAULT NULL +) +RETURNS uuid LANGUAGE plpgsql AS $$ +DECLARE + v_version int; + v_existing uuid; +BEGIN + -- 🛡 идемпотентность: повтор client_guid возвращает существующий id (errata m02) + SELECT id INTO v_existing FROM src.program_version + WHERE id = p_id AND program_id = p_program_id; + IF v_existing IS NOT NULL THEN + RETURN v_existing; + END IF; + + -- программа должна принадлежать атлету (изоляция 🛡) — иначе FK/пусто + SELECT COALESCE(max(pv.version), 0) + 1 INTO v_version + FROM src.program_version pv + JOIN src.program p ON p.id = pv.program_id + WHERE pv.program_id = p_program_id + AND p.athlete_id = p_athlete_id + AND p.id = p_program_id; + + INSERT INTO src.program_version + (id, program_id, version, based_on_version, status, author, body, rationale) + VALUES + (p_id, p_program_id, v_version, p_based_on_version, 'draft', + p_author, p_body::jsonb, p_rationale); + RETURN p_id; +END; +$$; diff --git a/2-db/src/functions/program_version_select.psql b/2-db/src/functions/program_version_select.psql new file mode 100644 index 0000000..86b3d16 --- /dev/null +++ b/2-db/src/functions/program_version_select.psql @@ -0,0 +1,31 @@ +-- src.program_version_select: список версий программы (без тела — body отдельным get). +-- На основе: новая. +-- Параметры: p_athlete_id; p_program_id/p_status/p_author — NULL = фильтр не применяется. +-- Возвращает: TABLE — колонки program_version без body, порядок по version. + +CREATE OR REPLACE FUNCTION src.program_version_select( + p_athlete_id int, + p_program_id int DEFAULT NULL, + p_status text DEFAULT NULL, + p_author text DEFAULT NULL +) +RETURNS TABLE ( + id uuid, + program_id int, + version int, + based_on_version int, + status text, + author text, + rationale text, + created_at timestamptz +) LANGUAGE sql AS $$ + SELECT pv.id, pv.program_id, pv.version, pv.based_on_version, + pv.status, pv.author, pv.rationale, pv.created_at + FROM src.program_version pv + JOIN src.program p ON p.id = pv.program_id + WHERE p.athlete_id = p_athlete_id + AND (p_program_id IS NULL OR pv.program_id = p_program_id) + AND (p_status IS NULL OR pv.status = p_status) + AND (p_author IS NULL OR pv.author = p_author) + ORDER BY pv.version; +$$; diff --git a/2-db/src/functions/program_version_update.psql b/2-db/src/functions/program_version_update.psql new file mode 100644 index 0000000..c11fca3 --- /dev/null +++ b/2-db/src/functions/program_version_update.psql @@ -0,0 +1,45 @@ +-- src.program_version_update: смена статуса версии (единственное изменение версии). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id; p_status (draft/active/archived). +-- Возвращает: int — число изменённых строк. Переходы draft→active→archived; +-- при active прежняя активная сдвигается в archived (🛡 активная — ровно одна). + +CREATE OR REPLACE FUNCTION src.program_version_update( + p_athlete_id int, + p_id uuid, + p_status text +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_program_id int; + v_current text; +BEGIN + SELECT pv.program_id, pv.status INTO v_program_id, v_current + FROM src.program_version pv + JOIN src.program p ON p.id = pv.program_id + WHERE pv.id = p_id AND p.athlete_id = p_athlete_id; + + IF v_program_id IS NULL THEN + RETURN 0; -- чужая/несуществующая версия — изоляция 🛡 + END IF; + + -- допустимые переходы: draft→active, active→archived; обратные запрещены + IF NOT (v_current = 'draft' AND p_status = 'active') + AND NOT (v_current = 'active' AND p_status = 'archived') THEN + RAISE EXCEPTION 'недопустимый переход статуса % → % (версия %)', + v_current, p_status, p_id; + END IF; + + IF p_status = 'active' THEN + -- прежняя активная версия программы уходит в archived + UPDATE src.program_version pv + SET status = 'archived' + WHERE pv.program_id = v_program_id AND pv.status = 'active'; + END IF; + + UPDATE src.program_version pv + SET status = p_status + WHERE pv.id = p_id; + RETURN 1; +END; +$$; diff --git a/2-db/src/functions/workout_get.psql b/2-db/src/functions/workout_get.psql new file mode 100644 index 0000000..1c9af65 --- /dev/null +++ b/2-db/src/functions/workout_get.psql @@ -0,0 +1,52 @@ +-- src.workout_get: полная карточка тренировки — строка + подходы + команды (Ф-9.2). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id. +-- Возвращает: TABLE — колонки workout + sets jsonb + commands jsonb. + +CREATE OR REPLACE FUNCTION src.workout_get( + p_athlete_id int, + p_id uuid +) +RETURNS TABLE ( + id uuid, + athlete_id int, + program_version_id uuid, + started_at timestamptz, + ended_at timestamptz, + status text, + session_kind text, + avg_hr int, + facts_json jsonb, + fit_file_id text, + hr_recovery_s int, + sets jsonb, + commands jsonb +) LANGUAGE sql AS $$ + SELECT w.id, w.athlete_id, w.program_version_id, w.started_at, w.ended_at, + w.status, w.session_kind, w.avg_hr, w.facts_json, w.fit_file_id, + w.hr_recovery_s, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'id', s.id, 'workout_id', s.workout_id, + 'exercise_id', s.exercise_id, 'seq', s.seq, + 'weight_kg', s.weight_kg, 'reps', s.reps, + 'done_at', s.done_at, 'source', s.source, + 'in_plan', s.in_plan, 'rpe', s.rpe, + 'knee_pain', s.knee_pain) + ORDER BY s.seq) + FROM src.workout_set s + WHERE s.workout_id = w.id + ), '[]'::jsonb), + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'id', c.id, 'workout_id', c.workout_id, + 't_offset_s', c.t_offset_s, + 'recognized_text', c.recognized_text, + 'semantics', c.semantics, 'accepted', c.accepted) + ORDER BY c.t_offset_s) + FROM src.command_log c + WHERE c.workout_id = w.id + ), '[]'::jsonb) + FROM src.workout w + WHERE w.athlete_id = p_athlete_id AND w.id = p_id; +$$; diff --git a/2-db/src/functions/workout_insert.psql b/2-db/src/functions/workout_insert.psql new file mode 100644 index 0000000..34efeae --- /dev/null +++ b/2-db/src/functions/workout_insert.psql @@ -0,0 +1,39 @@ +-- src.workout_insert: создание тренировки (идемпотентность по uuid 🛡). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id (uuid телефона); p_program_version_id; p_started_at; p_session_kind. +-- Возвращает: uuid — id тренировки; повтор p_id — существующий id. + +CREATE OR REPLACE FUNCTION src.workout_insert( + p_athlete_id int, + p_id uuid, + p_program_version_id uuid, + p_started_at timestamptz, + p_session_kind text DEFAULT 'normal' +) +RETURNS uuid LANGUAGE plpgsql AS $$ +DECLARE + v_id uuid; +BEGIN + -- ретрай sync: тот же uuid возвращает существующую тренировку + SELECT w.id INTO v_id FROM src.workout w + WHERE w.id = p_id AND w.athlete_id = p_athlete_id; + IF v_id IS NOT NULL THEN + RETURN v_id; + END IF; + + -- чужая программа-версия отсекается FK; свою проверяем явно (изоляция 🛡) + IF p_program_version_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 + FROM src.program_version pv + JOIN src.program p ON p.id = pv.program_id + WHERE pv.id = p_program_version_id AND p.athlete_id = p_athlete_id + ) THEN + RAISE EXCEPTION 'версия программы % не принадлежит атлету %', + p_program_version_id, p_athlete_id; + END IF; + + INSERT INTO src.workout (id, athlete_id, program_version_id, started_at, session_kind) + VALUES (p_id, p_athlete_id, p_program_version_id, p_started_at, p_session_kind); + RETURN p_id; +END; +$$; diff --git a/2-db/src/functions/workout_metrics_select.psql b/2-db/src/functions/workout_metrics_select.psql new file mode 100644 index 0000000..5560603 --- /dev/null +++ b/2-db/src/functions/workout_metrics_select.psql @@ -0,0 +1,35 @@ +-- src.workout_metrics_select: метрики тренировок атлета (графики AdminWeb). +-- На основе: новая. +-- Параметры: p_athlete_id; p_workout_id/p_date_from/p_date_to — NULL = не применяется. +-- Возвращает: TABLE — строки workout_metrics + started_at; порядок по started_at. + +CREATE OR REPLACE FUNCTION src.workout_metrics_select( + p_athlete_id int, + p_workout_id uuid DEFAULT NULL, + p_date_from date DEFAULT NULL, + p_date_to date DEFAULT NULL +) +RETURNS TABLE ( + id int, + workout_id uuid, + athlete_id int, + adherence numeric, + tonnage numeric, + tonnage_by_group jsonb, + e1pm_by_exercise jsonb, + hr_metrics jsonb, + red_flags jsonb, + computed_at timestamptz, + started_at timestamptz +) LANGUAGE sql AS $$ + SELECT m.id, m.workout_id, m.athlete_id, m.adherence, m.tonnage, + m.tonnage_by_group, m.e1pm_by_exercise, m.hr_metrics, + m.red_flags, m.computed_at, w.started_at + FROM src.workout_metrics m + JOIN src.workout w ON w.id = m.workout_id + WHERE m.athlete_id = p_athlete_id + AND (p_workout_id IS NULL OR m.workout_id = p_workout_id) + AND (p_date_from IS NULL OR w.started_at::date >= p_date_from) + AND (p_date_to IS NULL OR w.started_at::date <= p_date_to) + ORDER BY w.started_at; +$$; diff --git a/2-db/src/functions/workout_metrics_upsert.psql b/2-db/src/functions/workout_metrics_upsert.psql new file mode 100644 index 0000000..12b2a80 --- /dev/null +++ b/2-db/src/functions/workout_metrics_upsert.psql @@ -0,0 +1,46 @@ +-- src.workout_metrics_upsert: сохранение метрик тренировки (1:1, пересчёт MetricsEngine). +-- На основе: новая. +-- Параметры: p_athlete_id; p_workout_id; p_adherence; p_tonnage; p_tonnage_by_group; +-- p_e1pm_by_exercise; p_hr_metrics; p_red_flags. +-- Возвращает: int — id записи (пересчёт — прежний id). + +CREATE OR REPLACE FUNCTION src.workout_metrics_upsert( + p_athlete_id int, + p_workout_id uuid, + p_adherence numeric, + p_tonnage numeric, + p_tonnage_by_group text, -- errata m02: text, каста в теле + p_e1pm_by_exercise text, -- errata m02 + p_hr_metrics text, -- errata m02 + p_red_flags text -- errata m02: text, каста в теле +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_id int; +BEGIN + + -- тренировка должна принадлежать атлету (изоляция 🛡; FK чужую не отсекает) + IF NOT EXISTS ( + SELECT 1 FROM src.workout w + WHERE w.id = p_workout_id AND w.athlete_id = p_athlete_id + ) THEN + RAISE EXCEPTION 'тренировка % не принадлежит атлету %', p_workout_id, p_athlete_id; + END IF; + INSERT INTO src.workout_metrics + (workout_id, athlete_id, adherence, tonnage, tonnage_by_group, + e1pm_by_exercise, hr_metrics, red_flags, computed_at) + VALUES + (p_workout_id, p_athlete_id, p_adherence, p_tonnage, + p_tonnage_by_group::jsonb, p_e1pm_by_exercise::jsonb, p_hr_metrics::jsonb, p_red_flags::jsonb, now()) + ON CONFLICT (workout_id) DO UPDATE + SET adherence = EXCLUDED.adherence, + tonnage = EXCLUDED.tonnage, + tonnage_by_group = EXCLUDED.tonnage_by_group, + e1pm_by_exercise = EXCLUDED.e1pm_by_exercise, + hr_metrics = EXCLUDED.hr_metrics, + red_flags = EXCLUDED.red_flags, + computed_at = now() + RETURNING id INTO v_id; + RETURN v_id; +END; +$$; diff --git a/2-db/src/functions/workout_select.psql b/2-db/src/functions/workout_select.psql new file mode 100644 index 0000000..6ff62f6 --- /dev/null +++ b/2-db/src/functions/workout_select.psql @@ -0,0 +1,47 @@ +-- src.workout_select: карточки тренировок + подходы агрегатом jsonb (d-db-6). +-- На основе: новая. +-- Параметры: p_athlete_id; p_status/p_date_from/p_date_to — NULL = фильтр не применяется. +-- Возвращает: TABLE — колонки workout + sets jsonb; порядок по started_at (убывание). + +CREATE OR REPLACE FUNCTION src.workout_select( + p_athlete_id int, + p_status text DEFAULT NULL, + p_date_from date DEFAULT NULL, + p_date_to date DEFAULT NULL +) +RETURNS TABLE ( + id uuid, + athlete_id int, + program_version_id uuid, + started_at timestamptz, + ended_at timestamptz, + status text, + session_kind text, + avg_hr int, + facts_json jsonb, + fit_file_id text, + hr_recovery_s int, + sets jsonb +) LANGUAGE sql AS $$ + SELECT w.id, w.athlete_id, w.program_version_id, w.started_at, w.ended_at, + w.status, w.session_kind, w.avg_hr, w.facts_json, w.fit_file_id, + w.hr_recovery_s, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object( + 'id', s.id, 'workout_id', s.workout_id, + 'exercise_id', s.exercise_id, 'seq', s.seq, + 'weight_kg', s.weight_kg, 'reps', s.reps, + 'done_at', s.done_at, 'source', s.source, + 'in_plan', s.in_plan, 'rpe', s.rpe, + 'knee_pain', s.knee_pain) + ORDER BY s.seq) + FROM src.workout_set s + WHERE s.workout_id = w.id + ), '[]'::jsonb) AS sets + FROM src.workout w + WHERE w.athlete_id = p_athlete_id + AND (p_status IS NULL OR w.status = p_status) + AND (p_date_from IS NULL OR w.started_at::date >= p_date_from) + AND (p_date_to IS NULL OR w.started_at::date <= p_date_to) + ORDER BY w.started_at DESC; +$$; diff --git a/2-db/src/functions/workout_set_get.psql b/2-db/src/functions/workout_set_get.psql new file mode 100644 index 0000000..1971246 --- /dev/null +++ b/2-db/src/functions/workout_set_get.psql @@ -0,0 +1,29 @@ +-- src.workout_set_get: один подход атлета. +-- На основе: новая. +-- Параметры: p_athlete_id; p_id. +-- Возвращает: TABLE — строка workout_set; чужой/нет — пустая таблица. + +CREATE OR REPLACE FUNCTION src.workout_set_get( + p_athlete_id int, + p_id uuid +) +RETURNS TABLE ( + id uuid, + workout_id uuid, + athlete_id int, + exercise_id uuid, + seq int, + weight_kg numeric, + reps int, + done_at timestamptz, + source text, + in_plan boolean, + rpe int, + knee_pain int +) LANGUAGE sql AS $$ + SELECT s.id, s.workout_id, s.athlete_id, s.exercise_id, s.seq, + s.weight_kg, s.reps, s.done_at, s.source, s.in_plan, + s.rpe, s.knee_pain + FROM src.workout_set s + WHERE s.athlete_id = p_athlete_id AND s.id = p_id; +$$; diff --git a/2-db/src/functions/workout_set_insert.psql b/2-db/src/functions/workout_set_insert.psql new file mode 100644 index 0000000..46a6d8d --- /dev/null +++ b/2-db/src/functions/workout_set_insert.psql @@ -0,0 +1,48 @@ +-- src.workout_set_insert: фиксация подхода (идемпотентность по PK uuid, Р2-Б). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id; p_workout_id; p_exercise_id; p_seq; p_weight_kg; +-- p_reps; p_done_at; p_source; p_in_plan; p_rpe; p_knee_pain. +-- Возвращает: uuid — id подхода; повтор p_id — существующий id. + +CREATE OR REPLACE FUNCTION src.workout_set_insert( + p_athlete_id int, + p_id uuid, + p_workout_id uuid, + p_exercise_id uuid, + p_seq int, + p_weight_kg numeric, + p_reps int, + p_done_at timestamptz, + p_source text, + p_in_plan boolean, + p_rpe int DEFAULT NULL, + p_knee_pain int DEFAULT NULL +) +RETURNS uuid LANGUAGE plpgsql AS $$ +DECLARE + v_id uuid; +BEGIN + -- ретрай sync: тот же uuid возвращает существующий подход + SELECT s.id INTO v_id FROM src.workout_set s + WHERE s.id = p_id AND s.athlete_id = p_athlete_id; + IF v_id IS NOT NULL THEN + RETURN v_id; + END IF; + + -- тренировка должна принадлежать атлету (изоляция 🛡; FK чужую не отсекает) + IF NOT EXISTS ( + SELECT 1 FROM src.workout w + WHERE w.id = p_workout_id AND w.athlete_id = p_athlete_id + ) THEN + RAISE EXCEPTION 'тренировка % не принадлежит атлету %', p_workout_id, p_athlete_id; + END IF; + + INSERT INTO src.workout_set + (id, workout_id, athlete_id, exercise_id, seq, weight_kg, reps, + done_at, source, in_plan, rpe, knee_pain) + VALUES + (p_id, p_workout_id, p_athlete_id, p_exercise_id, p_seq, p_weight_kg, + p_reps, p_done_at, p_source, p_in_plan, p_rpe, p_knee_pain); + RETURN p_id; +END; +$$; diff --git a/2-db/src/functions/workout_update.psql b/2-db/src/functions/workout_update.psql new file mode 100644 index 0000000..7e71394 --- /dev/null +++ b/2-db/src/functions/workout_update.psql @@ -0,0 +1,34 @@ +-- src.workout_update: обновление тренировки (закрытие клиентом; факты .fit — FitParser). +-- На основе: новая. +-- Параметры: p_athlete_id; p_id; p_ended_at; p_status; p_session_kind; p_avg_hr; +-- p_facts_json; p_fit_file_id; p_hr_recovery_s. NULL-семантики нет (db-m-8). +-- Возвращает: int — число изменённых строк (чужая/нет → 0). + +CREATE OR REPLACE FUNCTION src.workout_update( + p_athlete_id int, + p_id uuid, + p_ended_at timestamptz DEFAULT NULL, + p_status text DEFAULT 'active', + p_session_kind text DEFAULT 'normal', + p_avg_hr int DEFAULT NULL, + p_facts_json text DEFAULT NULL, -- errata m02: text, каста в теле + p_fit_file_id text DEFAULT NULL, + p_hr_recovery_s int DEFAULT NULL +) +RETURNS int LANGUAGE plpgsql AS $$ +DECLARE + v_count int; +BEGIN + UPDATE src.workout w + SET ended_at = p_ended_at, + status = p_status, + session_kind = p_session_kind, + avg_hr = p_avg_hr, + facts_json = p_facts_json::jsonb, + fit_file_id = p_fit_file_id, + hr_recovery_s = p_hr_recovery_s + WHERE w.id = p_id AND w.athlete_id = p_athlete_id; + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$; diff --git a/2-db/src/tests/test_athlete_get.psql b/2-db/src/tests/test_athlete_get.psql new file mode 100644 index 0000000..e4462ca --- /dev/null +++ b/2-db/src/tests/test_athlete_get.psql @@ -0,0 +1,30 @@ +-- test_athlete_get: тесты src.athlete_get (спецификация — func_specs\athlete_get.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_id int; + v_count int; +BEGIN + -- фикстура + v_id := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + + -- #1 основной сценарий + SELECT count(*) INTO v_count FROM src.athlete_get(v_id); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.athlete_get #1: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.athlete_get #1'; + + -- #2 граничный случай: несуществующий id + SELECT count(*) INTO v_count FROM src.athlete_get(999999); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.athlete_get #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.athlete_get #2'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_athlete_insert.psql b/2-db/src/tests/test_athlete_insert.psql new file mode 100644 index 0000000..623322a --- /dev/null +++ b/2-db/src/tests/test_athlete_insert.psql @@ -0,0 +1,41 @@ +-- test_athlete_insert: тесты src.athlete_insert (спецификация — func_specs\athlete_insert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_id int; + v_rec record; +BEGIN + -- #1 основной сценарий + v_id := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + IF v_id IS NULL THEN + RAISE EXCEPTION 'FAIL src.athlete_insert #1: id IS NULL'; + END IF; + SELECT * INTO v_rec FROM src.athlete WHERE id = v_id; + IF v_rec.name <> 'TEST-Атлет' OR v_rec.is_blocked <> false + OR v_rec.created_at IS NULL THEN + RAISE EXCEPTION 'FAIL src.athlete_insert #1: неверные значения полей'; + END IF; + RAISE NOTICE 'PASS src.athlete_insert #1'; + + -- #2 исключение: повтор api_key_hash + BEGIN + PERFORM src.athlete_insert('TEST-Атлет-другой', 'TEST-hash-1'); + RAISE EXCEPTION 'FAIL src.athlete_insert #2: исключения не было'; + EXCEPTION WHEN unique_violation THEN + RAISE NOTICE 'PASS src.athlete_insert #2'; + END; + + -- #3 исключение: NULL обязательного параметра + BEGIN + PERFORM src.athlete_insert(NULL, 'TEST-hash-3'); + RAISE EXCEPTION 'FAIL src.athlete_insert #3: исключения не было'; + EXCEPTION WHEN not_null_violation THEN + RAISE NOTICE 'PASS src.athlete_insert #3'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_athlete_profile_get.psql b/2-db/src/tests/test_athlete_profile_get.psql new file mode 100644 index 0000000..fbad089 --- /dev/null +++ b/2-db/src/tests/test_athlete_profile_get.psql @@ -0,0 +1,41 @@ +-- test_athlete_profile_get: тесты src.athlete_profile_get (спецификация — func_specs\athlete_profile_get.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_a1 int; + v_a2 int; + v_count int; +BEGIN + -- фикстуры + v_a1 := src.athlete_insert('TEST-Атлет-1', 'TEST-hash-1'); + v_a2 := src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + PERFORM src.athlete_profile_upsert(v_a1, 'm', 1978, 158.0, NULL); + + -- #1 основной сценарий + SELECT count(*) INTO v_count FROM src.athlete_profile_get(v_a1); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.athlete_profile_get #1: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.athlete_profile_get #1'; + + -- #2 граничный случай: профиля нет + SELECT count(*) INTO v_count FROM src.athlete_profile_get(v_a2); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.athlete_profile_get #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.athlete_profile_get #2'; + + -- #3 граничный случай: профиль другого атлета не виден (изоляция 🛡) + PERFORM src.athlete_profile_upsert(v_a2, 'f', 1990, 70.0, NULL); + SELECT weight_kg INTO v_count FROM src.athlete_profile_get(v_a1); + IF v_count IS NULL OR v_count <> 158.0 THEN + RAISE EXCEPTION 'FAIL src.athlete_profile_get #3: чужой профиль виден'; + END IF; + RAISE NOTICE 'PASS src.athlete_profile_get #3'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_athlete_profile_upsert.psql b/2-db/src/tests/test_athlete_profile_upsert.psql new file mode 100644 index 0000000..9310e1c --- /dev/null +++ b/2-db/src/tests/test_athlete_profile_upsert.psql @@ -0,0 +1,44 @@ +-- test_athlete_profile_upsert: тесты src.athlete_profile_upsert (спецификация — func_specs\athlete_profile_upsert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_id1 int; + v_id2 int; + v_rec record; +BEGIN + -- фикстура + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + + -- #1 основной сценарий: профиля нет → insert + v_id1 := src.athlete_profile_upsert(v_athlete, 'm', 1978, 158.0, NULL); + IF v_id1 IS NULL THEN + RAISE EXCEPTION 'FAIL src.athlete_profile_upsert #1: id IS NULL'; + END IF; + RAISE NOTICE 'PASS src.athlete_profile_upsert #1'; + + -- #2 граничный случай: повторный вызов → update, тот же id + v_id2 := src.athlete_profile_upsert(v_athlete, 'm', 1978, 156.5, 185); + IF v_id2 <> v_id1 THEN + RAISE EXCEPTION 'FAIL src.athlete_profile_upsert #2: id изменился (% → %)', v_id1, v_id2; + END IF; + SELECT * INTO v_rec FROM src.athlete_profile WHERE id = v_id1; + IF v_rec.weight_kg <> 156.5 OR v_rec.hr_max <> 185 THEN + RAISE EXCEPTION 'FAIL src.athlete_profile_upsert #2: поля не обновлены'; + END IF; + RAISE NOTICE 'PASS src.athlete_profile_upsert #2'; + + -- #3 исключение: недопустимый p_sex (CHECK) + BEGIN + PERFORM src.athlete_profile_upsert(v_athlete, 'x', 1978, 158.0, NULL); + RAISE EXCEPTION 'FAIL src.athlete_profile_upsert #3: исключения не было'; + EXCEPTION WHEN check_violation THEN + RAISE NOTICE 'PASS src.athlete_profile_upsert #3'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_athlete_select.psql b/2-db/src/tests/test_athlete_select.psql new file mode 100644 index 0000000..3943680 --- /dev/null +++ b/2-db/src/tests/test_athlete_select.psql @@ -0,0 +1,42 @@ +-- test_athlete_select: тесты src.athlete_select (спецификация — func_specs\athlete_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_count int; +BEGIN + -- фикстуры + PERFORM src.athlete_insert('TEST-B', 'TEST-hash-1'); + PERFORM src.athlete_insert('TEST-A', 'TEST-hash-2'); + PERFORM src.athlete_insert('TEST-C', 'TEST-hash-3'); + UPDATE src.athlete SET is_blocked = true WHERE api_key_hash = 'TEST-hash-3'; + + -- #1 основной сценарий: фильтр по хэшу + SELECT count(*) INTO v_count FROM src.athlete_select('TEST-hash-1', NULL); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.athlete_select #1: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.athlete_select #1'; + + -- #2 граничный случай: оба фильтра NULL — все атлеты; порядок по name + SELECT count(*) INTO v_count FROM src.athlete_select(NULL, NULL); + IF v_count <> 3 THEN + RAISE EXCEPTION 'FAIL src.athlete_select #2: count = %', v_count; + END IF; + IF (SELECT name FROM src.athlete_select(NULL, NULL) LIMIT 1) <> 'TEST-A' THEN + RAISE EXCEPTION 'FAIL src.athlete_select #2: порядок не по name'; + END IF; + RAISE NOTICE 'PASS src.athlete_select #2'; + + -- #3 граничный случай: p_is_blocked = true при заблокированных + SELECT count(*) INTO v_count FROM src.athlete_select(NULL, true); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.athlete_select #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.athlete_select #3'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_athlete_update.psql b/2-db/src/tests/test_athlete_update.psql new file mode 100644 index 0000000..10a61d4 --- /dev/null +++ b/2-db/src/tests/test_athlete_update.psql @@ -0,0 +1,45 @@ +-- test_athlete_update: тесты src.athlete_update (спецификация — func_specs\athlete_update.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_id int; + v_count int; + v_rec record; +BEGIN + -- фикстуры + v_id := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + + -- #1 основной сценарий + v_count := src.athlete_update(v_id, 'TEST-Атлет-новый', 'TEST-hash-новый', true); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.athlete_update #1: count = %', v_count; + END IF; + SELECT * INTO v_rec FROM src.athlete WHERE id = v_id; + IF v_rec.name <> 'TEST-Атлет-новый' OR v_rec.api_key_hash <> 'TEST-hash-новый' + OR v_rec.is_blocked <> true THEN + RAISE EXCEPTION 'FAIL src.athlete_update #1: поля не обновлены'; + END IF; + RAISE NOTICE 'PASS src.athlete_update #1'; + + -- #2 граничный случай: несуществующий id + v_count := src.athlete_update(999999, 'TEST-x', 'TEST-hash-x', false); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.athlete_update #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.athlete_update #2'; + + -- #3 исключение: дубликат hash другого атлета + BEGIN + PERFORM src.athlete_update(v_id, 'TEST-y', 'TEST-hash-2', false); + RAISE EXCEPTION 'FAIL src.athlete_update #3: исключения не было'; + EXCEPTION WHEN unique_violation THEN + RAISE NOTICE 'PASS src.athlete_update #3'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_break_insert.psql b/2-db/src/tests/test_break_insert.psql new file mode 100644 index 0000000..95f0a73 --- /dev/null +++ b/2-db/src/tests/test_break_insert.psql @@ -0,0 +1,56 @@ +-- test_break_insert: тесты src.break_insert (спецификация — func_specs\break_insert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_b uuid := 'eeee0000-0000-0000-0000-000000000001'; + v_ret uuid; + v_count int; +BEGIN + -- фикстура + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + + -- #1 основной сценарий: manual без ответа + v_ret := src.break_insert(v_athlete, v_b, NULL, 'manual', '2026-10-20', '2026-10-27', 'TEST-отпуск'); + IF v_ret <> v_b THEN + RAISE EXCEPTION 'FAIL src.break_insert #1: uuid %', v_ret; + END IF; + SELECT kind INTO v_count FROM src.breaks WHERE id = v_b; + IF v_count IS NOT NULL THEN + RAISE EXCEPTION 'FAIL src.break_insert #1: kind = %', v_count; + END IF; + RAISE NOTICE 'PASS src.break_insert #1'; + + -- #2 граничный случай: повтор p_id + v_ret := src.break_insert(v_athlete, v_b, 'vacation', 'manual', '2026-10-20', '2026-10-27', 'TEST-отпуск'); + IF v_ret <> v_b THEN + RAISE EXCEPTION 'FAIL src.break_insert #2: uuid %', v_ret; + END IF; + SELECT count(*) INTO v_count FROM src.breaks WHERE id = v_b; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.break_insert #2: дубль'; + END IF; + RAISE NOTICE 'PASS src.break_insert #2'; + + -- #3 исключение: source = other + BEGIN + PERFORM src.break_insert(v_athlete, 'eeee0000-0000-0000-0000-000000000002', NULL, 'other', '2026-10-20', NULL, ''); + RAISE EXCEPTION 'FAIL src.break_insert #3: исключения не было'; + EXCEPTION WHEN check_violation THEN + RAISE NOTICE 'PASS src.break_insert #3'; + END; + + -- #4 исключение: kind = spacetravel + BEGIN + PERFORM src.break_insert(v_athlete, 'eeee0000-0000-0000-0000-000000000003', 'spacetravel', 'manual', '2026-10-20', NULL, ''); + RAISE EXCEPTION 'FAIL src.break_insert #4: исключения не было'; + EXCEPTION WHEN check_violation THEN + RAISE NOTICE 'PASS src.break_insert #4'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_break_select.psql b/2-db/src/tests/test_break_select.psql new file mode 100644 index 0000000..dc97ba2 --- /dev/null +++ b/2-db/src/tests/test_break_select.psql @@ -0,0 +1,44 @@ +-- test_break_select: тесты src.break_select (спецификация — func_specs\break_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_count int; +BEGIN + -- фикстуры: illness и vacation + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.break_insert(v_athlete, 'eeee0000-0000-0000-0000-000000000001', 'illness', 'auto', '2026-10-01', NULL, 'TEST-болезнь'); + PERFORM src.break_insert(v_athlete, 'eeee0000-0000-0000-0000-000000000002', 'vacation', 'manual', '2026-10-20', '2026-10-27', 'TEST-отпуск'); + + -- #1 основной сценарий: фильтр по kind + SELECT count(*) INTO v_count FROM src.break_select(v_athlete, 'illness', NULL, NULL); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.break_select #1: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.break_select #1'; + + -- #2 граничный случай: все фильтры NULL; порядок по started_on DESC + SELECT count(*) INTO v_count FROM src.break_select(v_athlete, NULL, NULL, NULL); + IF v_count <> 2 THEN + RAISE EXCEPTION 'FAIL src.break_select #2: count = %', v_count; + END IF; + IF (SELECT kind FROM src.break_select(v_athlete, NULL, NULL, NULL) LIMIT 1) <> 'vacation' THEN + RAISE EXCEPTION 'FAIL src.break_select #2: порядок'; + END IF; + RAISE NOTICE 'PASS src.break_select #2'; + + -- #3 граничный случай: чужие перерывы + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_count FROM src.break_select( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), NULL, NULL, NULL); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.break_select #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.break_select #3'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_break_update.psql b/2-db/src/tests/test_break_update.psql new file mode 100644 index 0000000..ce3c7ba --- /dev/null +++ b/2-db/src/tests/test_break_update.psql @@ -0,0 +1,56 @@ +-- test_break_update: тесты src.break_update (спецификация — func_specs\break_update.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_b uuid := 'eeee0000-0000-0000-0000-000000000001'; + v_count int; + v_rec record; + v_ended date; +BEGIN + -- фикстура + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.break_insert(v_athlete, v_b, NULL, 'manual', '2026-10-20', NULL, ''); + + -- #1 основной сценарий: ответ атлета + v_count := src.break_update(v_athlete, v_b, 'vacation', 'TEST-отпуск', '2026-10-27', NULL); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.break_update #1: count = %', v_count; + END IF; + SELECT * INTO v_rec FROM src.breaks WHERE id = v_b; + IF v_rec.kind <> 'vacation' OR v_rec.planned_return <> '2026-10-27' OR v_rec.ended_on IS NOT NULL THEN + RAISE EXCEPTION 'FAIL src.break_update #1: поля'; + END IF; + RAISE NOTICE 'PASS src.break_update #1'; + + -- #2 основной сценарий: закрытие + v_count := src.break_update(v_athlete, v_b, 'vacation', 'TEST-отпуск', '2026-10-27', '2026-10-26'); + SELECT ended_on INTO v_ended FROM src.breaks WHERE id = v_b; + IF v_ended <> '2026-10-26' THEN + RAISE EXCEPTION 'FAIL src.break_update #2: ended_on = %', v_ended; + END IF; + RAISE NOTICE 'PASS src.break_update #2'; + + -- #3 граничный случай: чужой перерыв + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + v_count := src.break_update((SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), + v_b, 'illness', 'TEST-x', NULL, NULL); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.break_update #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.break_update #3'; + + -- #4 исключение: kind = spacetravel + BEGIN + PERFORM src.break_update(v_athlete, v_b, 'spacetravel', '', NULL, NULL); + RAISE EXCEPTION 'FAIL src.break_update #4: исключения не было'; + EXCEPTION WHEN check_violation THEN + RAISE NOTICE 'PASS src.break_update #4'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_command_log_insert.psql b/2-db/src/tests/test_command_log_insert.psql new file mode 100644 index 0000000..933e62b --- /dev/null +++ b/2-db/src/tests/test_command_log_insert.psql @@ -0,0 +1,59 @@ +-- test_command_log_insert: тесты src.command_log_insert (спецификация — func_specs\command_log_insert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_other int; + v_w uuid := 'aaaa0000-0000-0000-0000-000000000001'; + v_c uuid := 'cccc0000-0000-0000-0000-000000000001'; + v_ret uuid; + v_count int; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_other := src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + PERFORM src.workout_insert(v_athlete, v_w, NULL, '2026-10-17 18:00+00', 'normal'); + + -- #1 основной сценарий + v_ret := src.command_log_insert(v_athlete, v_c, v_w, 65, 'TEST-вес пятьдесят', '{"cmd":"weight","weight":50}', true); + IF v_ret <> v_c THEN + RAISE EXCEPTION 'FAIL src.command_log_insert #1: uuid %', v_ret; + END IF; + RAISE NOTICE 'PASS src.command_log_insert #1'; + + -- #2 граничный случай: повтор p_id + v_ret := src.command_log_insert(v_athlete, v_c, v_w, 65, 'TEST-вес пятьдесят', '{"cmd":"weight"}', true); + IF v_ret <> v_c THEN + RAISE EXCEPTION 'FAIL src.command_log_insert #2: uuid %', v_ret; + END IF; + SELECT count(*) INTO v_count FROM src.command_log WHERE id = v_c; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.command_log_insert #2: дубль'; + END IF; + RAISE NOTICE 'PASS src.command_log_insert #2'; + + -- #3 исключение: чужая тренировка + BEGIN + PERFORM src.command_log_insert(v_other, 'cccc0000-0000-0000-0000-000000000002', v_w, 10, 'TEST-x', '{}', true); + RAISE EXCEPTION 'FAIL src.command_log_insert #3: исключения не было'; + EXCEPTION WHEN others THEN + IF SQLERRM NOT LIKE 'тренировка % не принадлежит%' THEN + RAISE EXCEPTION 'FAIL src.command_log_insert #3: %', SQLERRM; + END IF; + RAISE NOTICE 'PASS src.command_log_insert #3'; + END; + + -- #4 исключение: semantics NULL + BEGIN + PERFORM src.command_log_insert(v_athlete, 'cccc0000-0000-0000-0000-000000000003', v_w, 10, 'TEST-x', NULL, true); + RAISE EXCEPTION 'FAIL src.command_log_insert #4: исключения не было'; + EXCEPTION WHEN not_null_violation THEN + RAISE NOTICE 'PASS src.command_log_insert #4'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_command_log_select.psql b/2-db/src/tests/test_command_log_select.psql new file mode 100644 index 0000000..240d4e1 --- /dev/null +++ b/2-db/src/tests/test_command_log_select.psql @@ -0,0 +1,47 @@ +-- test_command_log_select: тесты src.command_log_select (спецификация — func_specs\command_log_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_w uuid := 'aaaa0000-0000-0000-0000-000000000001'; + v_count int; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.workout_insert(v_athlete, v_w, NULL, '2026-10-17 18:00+00', 'normal'); + PERFORM src.command_log_insert(v_athlete, 'cccc0000-0000-0000-0000-000000000002', v_w, 120, 'TEST-вторая', '{}', true); + PERFORM src.command_log_insert(v_athlete, 'cccc0000-0000-0000-0000-000000000001', v_w, 65, 'TEST-первая', '{}', true); + + -- #1 основной сценарий: порядок по t_offset_s + SELECT count(*) INTO v_count FROM src.command_log_select(v_athlete, v_w); + IF v_count <> 2 THEN + RAISE EXCEPTION 'FAIL src.command_log_select #1: count = %', v_count; + END IF; + IF (SELECT recognized_text FROM src.command_log_select(v_athlete, v_w) LIMIT 1) <> 'TEST-первая' THEN + RAISE EXCEPTION 'FAIL src.command_log_select #1: порядок'; + END IF; + RAISE NOTICE 'PASS src.command_log_select #1'; + + -- #2 граничный случай: команд нет + PERFORM src.workout_insert(v_athlete, 'aaaa0000-0000-0000-0000-000000000002', NULL, '2026-10-10 18:00+00', 'normal'); + SELECT count(*) INTO v_count FROM src.command_log_select(v_athlete, 'aaaa0000-0000-0000-0000-000000000002'); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.command_log_select #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.command_log_select #2'; + + -- #3 граничный случай: чужая тренировка + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_count FROM src.command_log_select( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), v_w); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.command_log_select #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.command_log_select #3'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_daily_log_select.psql b/2-db/src/tests/test_daily_log_select.psql new file mode 100644 index 0000000..840bfb4 --- /dev/null +++ b/2-db/src/tests/test_daily_log_select.psql @@ -0,0 +1,44 @@ +-- test_daily_log_select: тесты src.daily_log_select (спецификация — func_specs\daily_log_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_count int; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.daily_log_upsert(v_athlete, 'dddd0000-0000-0000-0000-000000000002', '2026-10-10', 135, 85, NULL, NULL, 3); + PERFORM src.daily_log_upsert(v_athlete, 'dddd0000-0000-0000-0000-000000000001', '2026-10-17', 130, 80, NULL, NULL, 4); + + -- #1 основной сценарий: окно + порядок по date + SELECT count(*) INTO v_count FROM src.daily_log_select(v_athlete, '2026-10-01', '2026-10-17'); + IF v_count <> 2 THEN + RAISE EXCEPTION 'FAIL src.daily_log_select #1: count = %', v_count; + END IF; + IF (SELECT date FROM src.daily_log_select(v_athlete, NULL, NULL) LIMIT 1) <> '2026-10-10' THEN + RAISE EXCEPTION 'FAIL src.daily_log_select #1: порядок'; + END IF; + RAISE NOTICE 'PASS src.daily_log_select #1'; + + -- #2 граничный случай: окно без записей + SELECT count(*) INTO v_count FROM src.daily_log_select(v_athlete, '2026-09-01', '2026-09-30'); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.daily_log_select #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.daily_log_select #2'; + + -- #3 граничный случай: чужой дневник + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_count FROM src.daily_log_select( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), NULL, NULL); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.daily_log_select #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.daily_log_select #3'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_daily_log_upsert.psql b/2-db/src/tests/test_daily_log_upsert.psql new file mode 100644 index 0000000..501d742 --- /dev/null +++ b/2-db/src/tests/test_daily_log_upsert.psql @@ -0,0 +1,49 @@ +-- test_daily_log_upsert: тесты src.daily_log_upsert (спецификация — func_specs\daily_log_upsert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_d1 uuid := 'dddd0000-0000-0000-0000-000000000001'; + v_d2 uuid := 'dddd0000-0000-0000-0000-000000000002'; + v_id uuid; + v_rec record; +BEGIN + -- фикстура + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + + -- #1 основной сценарий: новый день + v_id := src.daily_log_upsert(v_athlete, v_d1, '2026-10-17', 135, 85, 158.0, 62, 4); + IF v_id <> v_d1 THEN + RAISE EXCEPTION 'FAIL src.daily_log_upsert #1: uuid %', v_id; + END IF; + RAISE NOTICE 'PASS src.daily_log_upsert #1'; + + -- #2 граничный случай: повтор дня — прежний id, поля обновлены + v_id := src.daily_log_upsert(v_athlete, v_d2, '2026-10-17', 130, 80, 157.5, 60, 5); + IF v_id <> v_d1 THEN + RAISE EXCEPTION 'FAIL src.daily_log_upsert #2: id изменился на %', v_id; + END IF; + SELECT * INTO v_rec FROM src.daily_log WHERE id = v_d1; + IF v_rec.bp_sys <> 130 OR v_rec.wellbeing <> 5 THEN + RAISE EXCEPTION 'FAIL src.daily_log_upsert #2: поля'; + END IF; + SELECT count(*) INTO v_rec FROM src.daily_log WHERE athlete_id = v_athlete; + IF v_rec.count <> 1 THEN + RAISE EXCEPTION 'FAIL src.daily_log_upsert #2: строк %', v_rec.count; + END IF; + RAISE NOTICE 'PASS src.daily_log_upsert #2'; + + -- #3 исключение: wellbeing = 6 + BEGIN + PERFORM src.daily_log_upsert(v_athlete, 'dddd0000-0000-0000-0000-000000000003', '2026-10-16', NULL, NULL, NULL, NULL, 6); + RAISE EXCEPTION 'FAIL src.daily_log_upsert #3: исключения не было'; + EXCEPTION WHEN check_violation THEN + RAISE NOTICE 'PASS src.daily_log_upsert #3'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_exercise_delete.psql b/2-db/src/tests/test_exercise_delete.psql new file mode 100644 index 0000000..ad256ae --- /dev/null +++ b/2-db/src/tests/test_exercise_delete.psql @@ -0,0 +1,47 @@ +-- test_exercise_delete: тесты src.exercise_delete (спецификация — func_specs\exercise_delete.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_eid uuid := '11111111-1111-1111-1111-111111111111'; + v_count int; + v_rec record; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.exercise_insert(v_athlete, v_eid, 'TEST-жим', 1, true); + + -- #1 основной сценарий: логическое удаление + v_count := src.exercise_delete(v_athlete, v_eid); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.exercise_delete #1: count = %', v_count; + END IF; + SELECT * INTO v_rec FROM src.exercise WHERE id = v_eid; + IF v_rec IS NULL OR v_rec.is_active THEN + RAISE EXCEPTION 'FAIL src.exercise_delete #1: строка удалена или активна'; + END IF; + RAISE NOTICE 'PASS src.exercise_delete #1'; + + -- #2 граничный случай: повторный вызов + v_count := src.exercise_delete(v_athlete, v_eid); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.exercise_delete #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_delete #2'; + + -- #3 граничный случай: упражнение другого атлета + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + v_count := src.exercise_delete( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), + v_eid); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.exercise_delete #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_delete #3'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_exercise_get.psql b/2-db/src/tests/test_exercise_get.psql new file mode 100644 index 0000000..8a47188 --- /dev/null +++ b/2-db/src/tests/test_exercise_get.psql @@ -0,0 +1,39 @@ +-- test_exercise_get: тесты src.exercise_get (спецификация — func_specs\exercise_get.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_e1 uuid := '11111111-1111-1111-1111-111111111111'; + v_e2 uuid := '22222222-2222-2222-2222-222222222222'; + v_count int; + v_repl jsonb; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.exercise_insert(v_athlete, v_e1, 'TEST-жим', 1, true); + PERFORM src.exercise_insert(v_athlete, v_e2, 'TEST-жим-2', 1, true); + PERFORM src.exercise_replacement_insert(v_athlete, v_e1, v_e2); + + -- #1 основной сценарий + SELECT count(*) INTO v_count FROM src.exercise_get(v_athlete, v_e1); + SELECT replacement_ids INTO v_repl FROM src.exercise_get(v_athlete, v_e1); + IF v_count <> 1 OR v_repl <> jsonb_build_array(v_e2) THEN + RAISE EXCEPTION 'FAIL src.exercise_get #1: count = %, repl = %', v_count, v_repl; + END IF; + RAISE NOTICE 'PASS src.exercise_get #1'; + + -- #2 граничный случай: чужое/несуществующее + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_count FROM src.exercise_get( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), v_e1); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.exercise_get #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_get #2'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_exercise_insert.psql b/2-db/src/tests/test_exercise_insert.psql new file mode 100644 index 0000000..89e16f5 --- /dev/null +++ b/2-db/src/tests/test_exercise_insert.psql @@ -0,0 +1,57 @@ +-- test_exercise_insert: тесты src.exercise_insert (спецификация — func_specs\exercise_insert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_eid uuid := '11111111-1111-1111-1111-111111111111'; + v_ret uuid; + v_count int; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + + -- #1 основной сценарий + v_ret := src.exercise_insert(v_athlete, v_eid, 'TEST-жим', 1, true); + IF v_ret <> v_eid THEN + RAISE EXCEPTION 'FAIL src.exercise_insert #1: uuid %', v_ret; + END IF; + SELECT count(*) INTO v_count FROM src.exercise + WHERE id = v_eid AND is_active AND athlete_id = v_athlete; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.exercise_insert #1: строка не создана'; + END IF; + RAISE NOTICE 'PASS src.exercise_insert #1'; + + -- #2 граничный случай: повтор p_id — тот же id, без дубля + v_ret := src.exercise_insert(v_athlete, v_eid, 'TEST-жим', 1, true); + IF v_ret <> v_eid THEN + RAISE EXCEPTION 'FAIL src.exercise_insert #2: uuid %', v_ret; + END IF; + SELECT count(*) INTO v_count FROM src.exercise WHERE id = v_eid; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.exercise_insert #2: дубль строки'; + END IF; + RAISE NOTICE 'PASS src.exercise_insert #2'; + + -- #3 исключение: повтор имени у атлета + BEGIN + PERFORM src.exercise_insert(v_athlete, '22222222-2222-2222-2222-222222222222', 'TEST-жим', 1, true); + RAISE EXCEPTION 'FAIL src.exercise_insert #3: исключения не было'; + EXCEPTION WHEN unique_violation THEN + RAISE NOTICE 'PASS src.exercise_insert #3'; + END; + + -- #4 исключение: несуществующая группа + BEGIN + PERFORM src.exercise_insert(v_athlete, '33333333-3333-3333-3333-333333333333', 'TEST-тяга', 999, true); + RAISE EXCEPTION 'FAIL src.exercise_insert #4: исключения не было'; + EXCEPTION WHEN foreign_key_violation THEN + RAISE NOTICE 'PASS src.exercise_insert #4'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_exercise_replacement_delete.psql b/2-db/src/tests/test_exercise_replacement_delete.psql new file mode 100644 index 0000000..d40ad06 --- /dev/null +++ b/2-db/src/tests/test_exercise_replacement_delete.psql @@ -0,0 +1,47 @@ +-- test_exercise_replacement_delete: тесты src.exercise_replacement_delete (спецификация — func_specs\exercise_replacement_delete.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_a1 int; + v_a2 int; + v_e1 uuid := '11111111-1111-1111-1111-111111111111'; + v_e2 uuid := '22222222-2222-2222-2222-222222222222'; + v_count int; +BEGIN + -- фикстуры + v_a1 := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_a2 := src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + PERFORM src.exercise_insert(v_a1, v_e1, 'TEST-жим', 1, true); + PERFORM src.exercise_insert(v_a1, v_e2, 'TEST-жим-2', 1, true); + PERFORM src.exercise_replacement_insert(v_a1, v_e1, v_e2); + + -- #1 основной сценарий + v_count := src.exercise_replacement_delete(v_a1, v_e1, v_e2); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.exercise_replacement_delete #1: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_replacement_delete #1'; + + -- #2 граничный случай: несуществующая пара + v_count := src.exercise_replacement_delete(v_a1, v_e1, v_e2); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.exercise_replacement_delete #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_replacement_delete #2'; + + -- #3 граничный случай: пара, не принадлежащая атлету (упражнения атлета-2) + PERFORM src.exercise_insert(v_a2, '55555555-5555-5555-5555-555555555555', 'TEST-чужой-1', 1, true); + PERFORM src.exercise_insert(v_a2, '66666666-6666-6666-6666-666666666666', 'TEST-чужой-2', 1, true); + PERFORM src.exercise_replacement_insert(v_a2, '55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666'); + v_count := src.exercise_replacement_delete(v_a1, '55555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666'); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.exercise_replacement_delete #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_replacement_delete #3'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_exercise_replacement_insert.psql b/2-db/src/tests/test_exercise_replacement_insert.psql new file mode 100644 index 0000000..41d99c9 --- /dev/null +++ b/2-db/src/tests/test_exercise_replacement_insert.psql @@ -0,0 +1,70 @@ +-- test_exercise_replacement_insert: тесты src.exercise_replacement_insert (спецификация — func_specs\exercise_replacement_insert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_a1 int; + v_a2 int; + v_e1 uuid := '11111111-1111-1111-1111-111111111111'; + v_e2 uuid := '22222222-2222-2222-2222-222222222222'; + v_e3 uuid := '33333333-3333-3333-3333-333333333333'; + v_count int; +BEGIN + -- фикстуры: e1, e2 — группа 1 (push); e3 — группа 2 (pull) + v_a1 := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_a2 := src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + PERFORM src.exercise_insert(v_a1, v_e1, 'TEST-жим', 1, true); + PERFORM src.exercise_insert(v_a1, v_e2, 'TEST-жим-2', 1, true); + PERFORM src.exercise_insert(v_a1, v_e3, 'TEST-тяга', 2, true); + + -- #1 основной сценарий: одна группа + v_count := src.exercise_replacement_insert(v_a1, v_e1, v_e2); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.exercise_replacement_insert #1: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_replacement_insert #1'; + + -- #2 исключение: разные группы + BEGIN + PERFORM src.exercise_replacement_insert(v_a1, v_e1, v_e3); + RAISE EXCEPTION 'FAIL src.exercise_replacement_insert #2: исключения не было'; + EXCEPTION WHEN others THEN + IF SQLERRM NOT LIKE 'замена допустима%' THEN + RAISE EXCEPTION 'FAIL src.exercise_replacement_insert #2: %', SQLERRM; + END IF; + RAISE NOTICE 'PASS src.exercise_replacement_insert #2'; + END; + + -- #3 исключение: упражнение другого атлета + PERFORM src.exercise_insert(v_a2, '55555555-5555-5555-5555-555555555555', 'TEST-чужой', 1, true); + BEGIN + PERFORM src.exercise_replacement_insert(v_a2, '55555555-5555-5555-5555-555555555555', v_e2); + RAISE EXCEPTION 'FAIL src.exercise_replacement_insert #3: исключения не было'; + EXCEPTION WHEN others THEN + IF SQLERRM NOT LIKE 'замена допустима%' THEN + RAISE EXCEPTION 'FAIL src.exercise_replacement_insert #3: %', SQLERRM; + END IF; + RAISE NOTICE 'PASS src.exercise_replacement_insert #3'; + END; + + -- #4 исключение: повтор пары + BEGIN + PERFORM src.exercise_replacement_insert(v_a1, v_e1, v_e2); + RAISE EXCEPTION 'FAIL src.exercise_replacement_insert #4: исключения не было'; + EXCEPTION WHEN unique_violation THEN + RAISE NOTICE 'PASS src.exercise_replacement_insert #4'; + END; + + -- #5 исключение: self-замена + BEGIN + PERFORM src.exercise_replacement_insert(v_a1, v_e1, v_e1); + RAISE EXCEPTION 'FAIL src.exercise_replacement_insert #5: исключения не было'; + EXCEPTION WHEN check_violation THEN + RAISE NOTICE 'PASS src.exercise_replacement_insert #5'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_exercise_select.psql b/2-db/src/tests/test_exercise_select.psql new file mode 100644 index 0000000..b6bd9c7 --- /dev/null +++ b/2-db/src/tests/test_exercise_select.psql @@ -0,0 +1,54 @@ +-- test_exercise_select: тесты src.exercise_select (спецификация — func_specs\exercise_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_e1 uuid := '11111111-1111-1111-1111-111111111111'; + v_e2 uuid := '22222222-2222-2222-2222-222222222222'; + v_count int; + v_repl jsonb; +BEGIN + -- фикстуры: e1 + e2, замена e1→e2 (одна группа) + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.exercise_insert(v_athlete, v_e1, 'TEST-aжим', 1, true); + PERFORM src.exercise_insert(v_athlete, v_e2, 'TEST-bжим', 1, true); + PERFORM src.exercise_replacement_insert(v_athlete, v_e1, v_e2); + + -- #1 основной сценарий: замены в денормализации + SELECT replacement_ids INTO v_repl FROM src.exercise_select(v_athlete, 'TEST-aжим', NULL, NULL, NULL); + IF v_repl <> jsonb_build_array(v_e2) THEN + RAISE EXCEPTION 'FAIL src.exercise_select #1: replacement_ids = %', v_repl; + END IF; + RAISE NOTICE 'PASS src.exercise_select #1'; + + -- #2 граничный случай: p_is_active = true — только активные + PERFORM src.exercise_delete(v_athlete, v_e2); + SELECT count(*) INTO v_count FROM src.exercise_select(v_athlete, NULL, NULL, NULL, true); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.exercise_select #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_select #2'; + + -- #3 граничный случай: изоляция — у другого атлета пусто + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_count FROM src.exercise_select( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), + NULL, NULL, NULL, NULL); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.exercise_select #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_select #3'; + + -- #4 граничный случай: все фильтры NULL — все (включая неактивные) + SELECT count(*) INTO v_count FROM src.exercise_select(v_athlete, NULL, NULL, NULL, NULL); + IF v_count <> 2 THEN + RAISE EXCEPTION 'FAIL src.exercise_select #4: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_select #4'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_exercise_update.psql b/2-db/src/tests/test_exercise_update.psql new file mode 100644 index 0000000..130fdec --- /dev/null +++ b/2-db/src/tests/test_exercise_update.psql @@ -0,0 +1,50 @@ +-- test_exercise_update: тесты src.exercise_update (спецификация — func_specs\exercise_update.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_e1 uuid := '11111111-1111-1111-1111-111111111111'; + v_e2 uuid := '22222222-2222-2222-2222-222222222222'; + v_count int; + v_rec record; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.exercise_insert(v_athlete, v_e1, 'TEST-жим', 1, true); + PERFORM src.exercise_insert(v_athlete, v_e2, 'TEST-тяга', 2, true); + + -- #1 основной сценарий + v_count := src.exercise_update(v_athlete, v_e1, 'TEST-жим 45°', 1, false); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.exercise_update #1: count = %', v_count; + END IF; + SELECT * INTO v_rec FROM src.exercise WHERE id = v_e1; + IF v_rec.name <> 'TEST-жим 45°' OR v_rec.is_base OR v_rec.id <> v_e1 THEN + RAISE EXCEPTION 'FAIL src.exercise_update #1: поля не обновлены'; + END IF; + RAISE NOTICE 'PASS src.exercise_update #1'; + + -- #2 граничный случай: упражнение другого атлета + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + v_count := src.exercise_update( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), + v_e1, 'TEST-взлом', 1, true); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.exercise_update #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.exercise_update #2'; + + -- #3 исключение: имя другого упражнения атлета + BEGIN + PERFORM src.exercise_update(v_athlete, v_e1, 'TEST-тяга', 1, true); + RAISE EXCEPTION 'FAIL src.exercise_update #3: исключения не было'; + EXCEPTION WHEN unique_violation THEN + RAISE NOTICE 'PASS src.exercise_update #3'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_migration_insert.psql b/2-db/src/tests/test_migration_insert.psql new file mode 100644 index 0000000..d2b7e53 --- /dev/null +++ b/2-db/src/tests/test_migration_insert.psql @@ -0,0 +1,41 @@ +-- test_migration_insert: тесты src.migration_insert (спецификация — func_specs\migration_insert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. +-- Фикстуры: маркер TEST-. + +BEGIN; + +DO $$ +DECLARE + v_id int; + v_count int; +BEGIN + -- #1 основной сценарий: валидное имя файла + v_id := src.migration_insert('TEST-0001.sql'); + IF v_id IS NULL THEN + RAISE EXCEPTION 'FAIL src.migration_insert #1: id IS NULL'; + END IF; + SELECT count(*) INTO v_count FROM src.migration WHERE filename = 'TEST-0001.sql'; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.migration_insert #1: строка не создана'; + END IF; + RAISE NOTICE 'PASS src.migration_insert #1'; + + -- #2 исключение: повтор filename + BEGIN + PERFORM src.migration_insert('TEST-0001.sql'); + RAISE EXCEPTION 'FAIL src.migration_insert #2: исключения не было'; + EXCEPTION WHEN unique_violation THEN + RAISE NOTICE 'PASS src.migration_insert #2'; + END; + + -- #3 исключение: NULL обязательного параметра + BEGIN + PERFORM src.migration_insert(NULL); + RAISE EXCEPTION 'FAIL src.migration_insert #3: исключения не было'; + EXCEPTION WHEN not_null_violation THEN + RAISE NOTICE 'PASS src.migration_insert #3'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_migration_select.psql b/2-db/src/tests/test_migration_select.psql new file mode 100644 index 0000000..7a33411 --- /dev/null +++ b/2-db/src/tests/test_migration_select.psql @@ -0,0 +1,29 @@ +-- test_migration_select: тесты src.migration_select (спецификация — func_specs\migration_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_count int; +BEGIN + -- фикстура + PERFORM src.migration_insert('TEST-0001.sql'); + + -- #1 основной сценарий: файл в журнале + SELECT count(*) INTO v_count FROM src.migration_select('TEST-0001.sql'); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.migration_select #1: ожидалась 1 строка, got %', v_count; + END IF; + RAISE NOTICE 'PASS src.migration_select #1'; + + -- #2 граничный случай: файла нет в журнале + SELECT count(*) INTO v_count FROM src.migration_select('TEST-нет-такой.sql'); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.migration_select #2: ожидалось 0 строк, got %', v_count; + END IF; + RAISE NOTICE 'PASS src.migration_select #2'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_program_get.psql b/2-db/src/tests/test_program_get.psql new file mode 100644 index 0000000..71e4f57 --- /dev/null +++ b/2-db/src/tests/test_program_get.psql @@ -0,0 +1,34 @@ +-- test_program_get: тесты src.program_get (спецификация — func_specs\program_get.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_a1 int; + v_a2 int; + v_pid int; + v_count int; +BEGIN + -- фикстуры + v_a1 := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_a2 := src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + v_pid := src.program_insert(v_a1, 'TEST-Программа'); + + -- #1 основной сценарий + SELECT count(*) INTO v_count FROM src.program_get(v_a1, v_pid); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.program_get #1: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.program_get #1'; + + -- #2 граничный случай: чужая программа + SELECT count(*) INTO v_count FROM src.program_get(v_a2, v_pid); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.program_get #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.program_get #2'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_program_insert.psql b/2-db/src/tests/test_program_insert.psql new file mode 100644 index 0000000..c95a46b --- /dev/null +++ b/2-db/src/tests/test_program_insert.psql @@ -0,0 +1,42 @@ +-- test_program_insert: тесты src.program_insert (спецификация — func_specs\program_insert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_pid int; +BEGIN + -- фикстура + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + + -- #1 основной сценарий + v_pid := src.program_insert(v_athlete, 'TEST-Программа'); + IF v_pid IS NULL THEN + RAISE EXCEPTION 'FAIL src.program_insert #1: id IS NULL'; + END IF; + RAISE NOTICE 'PASS src.program_insert #1'; + + -- #2 исключение: вторая программа атлета + BEGIN + PERFORM src.program_insert(v_athlete, 'TEST-Программа-2'); + RAISE EXCEPTION 'FAIL src.program_insert #2: исключения не было'; + EXCEPTION WHEN unique_violation THEN + RAISE NOTICE 'PASS src.program_insert #2'; + END; + + -- #3 исключение (З-35): повтор того же external_id у другого атлета + v_athlete := src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + PERFORM src.program_insert(v_athlete, 'TEST-Пакет-1', 'TEST-pkg-1'); + v_athlete := src.athlete_insert('TEST-Атлет-3', 'TEST-hash-3'); + BEGIN + PERFORM src.program_insert(v_athlete, 'TEST-Пакет-2', 'TEST-pkg-1'); + RAISE EXCEPTION 'FAIL src.program_insert #3: исключения не было'; + EXCEPTION WHEN unique_violation THEN + RAISE NOTICE 'PASS src.program_insert #3'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_program_select.psql b/2-db/src/tests/test_program_select.psql new file mode 100644 index 0000000..a4d552a --- /dev/null +++ b/2-db/src/tests/test_program_select.psql @@ -0,0 +1,46 @@ +-- test_program_select: тесты src.program_select (спецификация — func_specs\program_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_pid int; + v_v1 uuid := '11111111-1111-1111-1111-111111111111'; + v_v2 uuid := '22222222-2222-2222-2222-222222222222'; + v_rec record; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_pid := src.program_insert(v_athlete, 'TEST-Программа'); + PERFORM src.program_version_insert(v_athlete, v_v1, v_pid, NULL, 'user', '{}', NULL); + PERFORM src.program_version_insert(v_athlete, v_v2, v_pid, 1, 'user', '{}', NULL); + + -- #2 граничный случай: все версии draft — активной нет + SELECT * INTO v_rec FROM src.program_select(v_athlete); + IF v_rec.active_version_id IS NOT NULL OR v_rec.active_version IS NOT NULL THEN + RAISE EXCEPTION 'FAIL src.program_select #2: active_version не NULL'; + END IF; + RAISE NOTICE 'PASS src.program_select #2'; + + -- #1 основной сценарий: активная версия заполнена + PERFORM src.program_version_update(v_athlete, v_v1, 'active'); + SELECT * INTO v_rec FROM src.program_select(v_athlete); + IF v_rec.active_version_id <> v_v1 OR v_rec.active_version <> 1 THEN + RAISE EXCEPTION 'FAIL src.program_select #1: active = % / %', v_rec.active_version_id, v_rec.active_version; + END IF; + RAISE NOTICE 'PASS src.program_select #1'; + + -- #3 граничный случай: программ нет + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_rec FROM src.program_select( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2')); + RAISE NOTICE 'PASS src.program_select #3 (строк: %)', v_rec.count; + IF v_rec.count <> 0 THEN + RAISE EXCEPTION 'FAIL src.program_select #3: count = %', v_rec.count; + END IF; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_program_version_get.psql b/2-db/src/tests/test_program_version_get.psql new file mode 100644 index 0000000..0b1b9b2 --- /dev/null +++ b/2-db/src/tests/test_program_version_get.psql @@ -0,0 +1,38 @@ +-- test_program_version_get: тесты src.program_version_get (спецификация — func_specs\program_version_get.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_pid int; + v_v1 uuid := '11111111-1111-1111-1111-111111111111'; + v_count int; + v_body jsonb; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_pid := src.program_insert(v_athlete, 'TEST-Программа'); + PERFORM src.program_version_insert(v_athlete, v_v1, v_pid, NULL, 'user', '{"weeks": []}', NULL); + + -- #1 основной сценарий: тело возвращается + SELECT count(*) INTO v_count FROM src.program_version_get(v_athlete, v_v1); + SELECT body INTO v_body FROM src.program_version_get(v_athlete, v_v1); + IF v_count <> 1 OR v_body <> '{"weeks": []}'::jsonb THEN + RAISE EXCEPTION 'FAIL src.program_version_get #1: count = %, body = %', v_count, v_body; + END IF; + RAISE NOTICE 'PASS src.program_version_get #1'; + + -- #2 граничный случай: чужая версия + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_count FROM src.program_version_get( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), v_v1); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.program_version_get #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.program_version_get #2'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_program_version_insert.psql b/2-db/src/tests/test_program_version_insert.psql new file mode 100644 index 0000000..c5a2655 --- /dev/null +++ b/2-db/src/tests/test_program_version_insert.psql @@ -0,0 +1,54 @@ +-- test_program_version_insert: тесты src.program_version_insert (спецификация — func_specs\program_version_insert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_pid int; + v_v1 uuid := '11111111-1111-1111-1111-111111111111'; + v_v2 uuid := '22222222-2222-2222-2222-222222222222'; + v_rec record; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_pid := src.program_insert(v_athlete, 'TEST-Программа'); + + -- #1 основной сценарий: первая версия + PERFORM src.program_version_insert(v_athlete, v_v1, v_pid, NULL, 'user', '{}', 'TEST-первая'); + SELECT * INTO v_rec FROM src.program_version WHERE id = v_v1; + IF v_rec.version <> 1 OR v_rec.status <> 'draft' OR v_rec.based_on_version IS NOT NULL THEN + RAISE EXCEPTION 'FAIL src.program_version_insert #1: v=% s=%', v_rec.version, v_rec.status; + END IF; + RAISE NOTICE 'PASS src.program_version_insert #1'; + + -- #2 основной сценарий: вторая версия + PERFORM src.program_version_insert(v_athlete, v_v2, v_pid, 1, 'user', '{}', NULL); + SELECT * INTO v_rec FROM src.program_version WHERE id = v_v2; + IF v_rec.version <> 2 OR v_rec.based_on_version <> 1 THEN + RAISE EXCEPTION 'FAIL src.program_version_insert #2: v=% b=%', v_rec.version, v_rec.based_on_version; + END IF; + RAISE NOTICE 'PASS src.program_version_insert #2'; + + -- #3 исключение: чужая/несуществующая программа + BEGIN + PERFORM src.program_version_insert(v_athlete, '33333333-3333-3333-3333-333333333333', 999999, NULL, 'user', '{}', NULL); + RAISE EXCEPTION 'FAIL src.program_version_insert #3: исключения не было'; + EXCEPTION WHEN foreign_key_violation THEN + RAISE NOTICE 'PASS src.program_version_insert #3'; + END; + + -- #4 🛡 идемпотентность: повтор p_id возвращает существующий id (errata m02) + DECLARE v_repeat uuid; + BEGIN + v_repeat := src.program_version_insert(v_athlete, v_v1, v_pid, 1, 'user', '{}', NULL); + IF v_repeat <> v_v1 THEN + RAISE EXCEPTION 'FAIL src.program_version_insert #4: % <> %', v_repeat, v_v1; + END IF; + RAISE NOTICE 'PASS src.program_version_insert #4'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_program_version_select.psql b/2-db/src/tests/test_program_version_select.psql new file mode 100644 index 0000000..b1a960f --- /dev/null +++ b/2-db/src/tests/test_program_version_select.psql @@ -0,0 +1,54 @@ +-- test_program_version_select: тесты src.program_version_select (спецификация — func_specs\program_version_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_pid int; + v_pid2 int; + v_v1 uuid := '11111111-1111-1111-1111-111111111111'; + v_v2 uuid := '22222222-2222-2222-2222-222222222222'; + v_v3 uuid := '33333333-3333-3333-3333-333333333333'; + v_count int; + v_body int; +BEGIN + -- фикстуры: v1 draft, v2 active, v3 draft author=user; вторая программа атлета-2 + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_pid := src.program_insert(v_athlete, 'TEST-Программа'); + PERFORM src.program_version_insert(v_athlete, v_v1, v_pid, NULL, 'user', '{}', NULL); + PERFORM src.program_version_insert(v_athlete, v_v2, v_pid, 1, 'user', '{}', NULL); + PERFORM src.program_version_insert(v_athlete, v_v3, v_pid, 2, 'user', '{}', NULL); + PERFORM src.program_version_update(v_athlete, v_v2, 'active'); + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + v_pid2 := src.program_insert((SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), 'TEST-Программа-2'); + PERFORM src.program_version_insert((SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), '44444444-4444-4444-4444-444444444444', v_pid2, NULL, 'user', '{}', NULL); + + -- #1 основной сценарий: фильтр по статусу + SELECT count(*) INTO v_count FROM src.program_version_select(v_athlete, v_pid, 'active', NULL); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.program_version_select #1: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.program_version_select #1'; + + -- #2 граничный случай: все фильтры NULL — все версии, порядок по version + SELECT count(*) INTO v_count FROM src.program_version_select(v_athlete, NULL, NULL, NULL); + IF v_count <> 3 THEN + RAISE EXCEPTION 'FAIL src.program_version_select #2: count = %', v_count; + END IF; + IF (SELECT version FROM src.program_version_select(v_athlete, NULL, NULL, NULL) LIMIT 1) <> 1 THEN + RAISE EXCEPTION 'FAIL src.program_version_select #2: порядок не по version'; + END IF; + RAISE NOTICE 'PASS src.program_version_select #2'; + + -- #3 граничный случай: версии чужой программы не видны + SELECT count(*) INTO v_count FROM src.program_version_select(v_athlete, v_pid2, NULL, NULL); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.program_version_select #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.program_version_select #3'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_program_version_update.psql b/2-db/src/tests/test_program_version_update.psql new file mode 100644 index 0000000..245b8f6 --- /dev/null +++ b/2-db/src/tests/test_program_version_update.psql @@ -0,0 +1,91 @@ +-- test_program_version_update: тесты src.program_version_update (спецификация — func_specs\program_version_update.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_pid int; + v_v1 uuid := '11111111-1111-1111-1111-111111111111'; + v_v2 uuid := '22222222-2222-2222-2222-222222222222'; + v_count int; + v_active int; + v_rec record; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_pid := src.program_insert(v_athlete, 'TEST-Программа'); + PERFORM src.program_version_insert(v_athlete, v_v1, v_pid, NULL, 'user', '{}', NULL); + PERFORM src.program_version_insert(v_athlete, v_v2, v_pid, 1, 'user', '{}', NULL); + + -- #1 основной сценарий: draft → active + v_count := src.program_version_update(v_athlete, v_v1, 'active'); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.program_version_update #1: count = %', v_count; + END IF; + SELECT status INTO v_rec FROM src.program_version WHERE id = v_v1; + IF v_rec.status <> 'active' THEN + RAISE EXCEPTION 'FAIL src.program_version_update #1: status = %', v_rec.status; + END IF; + RAISE NOTICE 'PASS src.program_version_update #1'; + + -- #2 основной сценарий: active v1 → active v2; v1 уходит в archived + PERFORM src.program_version_update(v_athlete, v_v2, 'active'); + SELECT status INTO v_rec FROM src.program_version WHERE id = v_v1; + IF v_rec.status <> 'archived' THEN + RAISE EXCEPTION 'FAIL src.program_version_update #2: v1 = %', v_rec.status; + END IF; + SELECT count(*) INTO v_active FROM src.program_version + WHERE program_id = v_pid AND status = 'active'; + IF v_active <> 1 THEN + RAISE EXCEPTION 'FAIL src.program_version_update #2: активных = %', v_active; + END IF; + RAISE NOTICE 'PASS src.program_version_update #2'; + + -- #3 основной сценарий: active → archived (v2 активна после #2) + v_count := src.program_version_update(v_athlete, v_v2, 'archived'); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.program_version_update #3: count = %', v_count; + END IF; + SELECT status INTO v_rec FROM src.program_version WHERE id = v_v2; + IF v_rec.status <> 'archived' THEN + RAISE EXCEPTION 'FAIL src.program_version_update #3: status = %', v_rec.status; + END IF; + RAISE NOTICE 'PASS src.program_version_update #3'; + + -- #4 исключение: archived → active (обратный переход) + BEGIN + PERFORM src.program_version_update(v_athlete, v_v2, 'active'); + RAISE EXCEPTION 'FAIL src.program_version_update #4: исключения не было'; + EXCEPTION WHEN others THEN + IF SQLERRM NOT LIKE 'недопустимый переход%' THEN + RAISE EXCEPTION 'FAIL src.program_version_update #4: %', SQLERRM; + END IF; + RAISE NOTICE 'PASS src.program_version_update #4'; + END; + + -- #5 исключение: draft → archived (минуя active) + PERFORM src.program_version_insert(v_athlete, '33333333-3333-3333-3333-333333333333', v_pid, 2, 'user', '{}', NULL); + BEGIN + PERFORM src.program_version_update(v_athlete, '33333333-3333-3333-3333-333333333333', 'archived'); + RAISE EXCEPTION 'FAIL src.program_version_update #5: исключения не было'; + EXCEPTION WHEN others THEN + IF SQLERRM NOT LIKE 'недопустимый переход%' THEN + RAISE EXCEPTION 'FAIL src.program_version_update #5: %', SQLERRM; + END IF; + RAISE NOTICE 'PASS src.program_version_update #5'; + END; + + -- #6 граничный случай: чужая версия + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + v_count := src.program_version_update( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), v_v1, 'archived'); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.program_version_update #6: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.program_version_update #6'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_workout_get.psql b/2-db/src/tests/test_workout_get.psql new file mode 100644 index 0000000..323d577 --- /dev/null +++ b/2-db/src/tests/test_workout_get.psql @@ -0,0 +1,45 @@ +-- test_workout_get: тесты src.workout_get (спецификация — func_specs\workout_get.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_w uuid := 'aaaa0000-0000-0000-0000-000000000001'; + v_rec record; + v_count int; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.workout_insert(v_athlete, v_w, NULL, '2026-10-17 18:00+00', 'normal'); + PERFORM src.workout_set_insert(v_athlete, 'bbbb0000-0000-0000-0000-000000000001', v_w, NULL, 1, 50.0, 15, '2026-10-17 18:05+00', 'voice', true, NULL, NULL); + PERFORM src.command_log_insert(v_athlete, 'cccc0000-0000-0000-0000-000000000001', v_w, 65, 'TEST-команда', '{"cmd":"weight"}', true); + + -- #1 основной сценарий: sets + commands + SELECT * INTO v_rec FROM src.workout_get(v_athlete, v_w); + IF jsonb_array_length(v_rec.sets) <> 1 OR jsonb_array_length(v_rec.commands) <> 1 THEN + RAISE EXCEPTION 'FAIL src.workout_get #1: sets=% commands=%', v_rec.sets, v_rec.commands; + END IF; + RAISE NOTICE 'PASS src.workout_get #1'; + + -- #2 граничный случай: пустая тренировка + PERFORM src.workout_insert(v_athlete, 'aaaa0000-0000-0000-0000-000000000002', NULL, '2026-10-10 18:00+00', 'normal'); + SELECT * INTO v_rec FROM src.workout_get(v_athlete, 'aaaa0000-0000-0000-0000-000000000002'); + IF v_rec.sets <> '[]'::jsonb OR v_rec.commands <> '[]'::jsonb THEN + RAISE EXCEPTION 'FAIL src.workout_get #2: sets=% commands=%', v_rec.sets, v_rec.commands; + END IF; + RAISE NOTICE 'PASS src.workout_get #2'; + + -- #3 граничный случай: чужая тренировка + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_count FROM src.workout_get( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), v_w); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.workout_get #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.workout_get #3'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_workout_insert.psql b/2-db/src/tests/test_workout_insert.psql new file mode 100644 index 0000000..b69f870 --- /dev/null +++ b/2-db/src/tests/test_workout_insert.psql @@ -0,0 +1,66 @@ +-- test_workout_insert: тесты src.workout_insert (спецификация — func_specs\workout_insert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_other int; + v_pid int; + v_pv uuid := '70000000-0000-0000-0000-000000000001'; + v_w uuid := 'aaaa0000-0000-0000-0000-000000000001'; + v_ret uuid; + v_count int; + v_status text; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_other := src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + v_pid := src.program_insert(v_athlete, 'TEST-Программа'); + PERFORM src.program_version_insert(v_athlete, v_pv, v_pid, NULL, 'user', '{}', NULL); + + -- #1 основной сценарий + v_ret := src.workout_insert(v_athlete, v_w, v_pv, '2026-10-17 18:00+00', 'normal'); + IF v_ret <> v_w THEN + RAISE EXCEPTION 'FAIL src.workout_insert #1: uuid %', v_ret; + END IF; + SELECT status INTO v_status FROM src.workout WHERE id = v_w; + IF v_status IS DISTINCT FROM 'active' THEN + RAISE EXCEPTION 'FAIL src.workout_insert #1: status = %', v_status; + END IF; + RAISE NOTICE 'PASS src.workout_insert #1'; + + -- #2 граничный случай: повтор p_id + v_ret := src.workout_insert(v_athlete, v_w, v_pv, '2026-10-17 18:00+00', 'normal'); + IF v_ret <> v_w THEN + RAISE EXCEPTION 'FAIL src.workout_insert #2: uuid %', v_ret; + END IF; + SELECT count(*) INTO v_count FROM src.workout WHERE id = v_w; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.workout_insert #2: дубль'; + END IF; + RAISE NOTICE 'PASS src.workout_insert #2'; + + -- #3 исключение: чужая версия программы + BEGIN + PERFORM src.workout_insert(v_other, 'aaaa0000-0000-0000-0000-000000000002', v_pv, '2026-10-17 18:00+00', 'normal'); + RAISE EXCEPTION 'FAIL src.workout_insert #3: исключения не было'; + EXCEPTION WHEN others THEN + IF SQLERRM NOT LIKE 'версия программы%' THEN + RAISE EXCEPTION 'FAIL src.workout_insert #3: %', SQLERRM; + END IF; + RAISE NOTICE 'PASS src.workout_insert #3'; + END; + + -- #4 исключение: недопустимый session_kind + BEGIN + PERFORM src.workout_insert(v_athlete, 'aaaa0000-0000-0000-0000-000000000003', NULL, '2026-10-17 18:00+00', 'other'); + RAISE EXCEPTION 'FAIL src.workout_insert #4: исключения не было'; + EXCEPTION WHEN check_violation THEN + RAISE NOTICE 'PASS src.workout_insert #4'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_workout_metrics_select.psql b/2-db/src/tests/test_workout_metrics_select.psql new file mode 100644 index 0000000..63e2062 --- /dev/null +++ b/2-db/src/tests/test_workout_metrics_select.psql @@ -0,0 +1,45 @@ +-- test_workout_metrics_select: тесты src.workout_metrics_select (спецификация — func_specs\workout_metrics_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_w1 uuid := 'aaaa0000-0000-0000-0000-000000000001'; + v_w2 uuid := 'aaaa0000-0000-0000-0000-000000000002'; + v_count int; + v_rec record; +BEGIN + -- фикстуры: w1 с метриками, w2 без + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.workout_insert(v_athlete, v_w1, NULL, '2026-10-17 18:00+00', 'normal'); + PERFORM src.workout_insert(v_athlete, v_w2, NULL, '2026-10-10 18:00+00', 'normal'); + PERFORM src.workout_metrics_upsert(v_athlete, v_w1, 92.5, 4200.0, '{}', '{}', '{}', '[]'); + + -- #1 основной сценарий: метрики + started_at + SELECT * INTO v_rec FROM src.workout_metrics_select(v_athlete, NULL, NULL, NULL); + IF v_rec.workout_id <> v_w1 OR v_rec.started_at IS NULL THEN + RAISE EXCEPTION 'FAIL src.workout_metrics_select #1: % / %', v_rec.workout_id, v_rec.started_at; + END IF; + RAISE NOTICE 'PASS src.workout_metrics_select #1'; + + -- #2 граничный случай: тренировка без метрик не попадает + SELECT count(*) INTO v_count FROM src.workout_metrics_select(v_athlete, v_w2, NULL, NULL); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.workout_metrics_select #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.workout_metrics_select #2'; + + -- #3 граничный случай: изоляция + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_count FROM src.workout_metrics_select( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), NULL, NULL, NULL); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.workout_metrics_select #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.workout_metrics_select #3'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_workout_metrics_upsert.psql b/2-db/src/tests/test_workout_metrics_upsert.psql new file mode 100644 index 0000000..2c43639 --- /dev/null +++ b/2-db/src/tests/test_workout_metrics_upsert.psql @@ -0,0 +1,51 @@ +-- test_workout_metrics_upsert: тесты src.workout_metrics_upsert (спецификация — func_specs\workout_metrics_upsert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_other int; + v_w uuid := 'aaaa0000-0000-0000-0000-000000000001'; + v_id1 int; + v_id2 int; + v_rec record; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_other := src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + PERFORM src.workout_insert(v_athlete, v_w, NULL, '2026-10-17 18:00+00', 'normal'); + + -- #1 основной сценарий: первая запись + v_id1 := src.workout_metrics_upsert(v_athlete, v_w, 92.5, 4200.0, '{"push":4200}', '{}', '{}', '[]'); + IF v_id1 IS NULL THEN + RAISE EXCEPTION 'FAIL src.workout_metrics_upsert #1: id IS NULL'; + END IF; + RAISE NOTICE 'PASS src.workout_metrics_upsert #1'; + + -- #2 граничный случай: пересчёт — прежний id + v_id2 := src.workout_metrics_upsert(v_athlete, v_w, 100.0, 4400.0, '{"push":4400}', '{}', '{}', '[]'); + IF v_id2 <> v_id1 THEN + RAISE EXCEPTION 'FAIL src.workout_metrics_upsert #2: id % → %', v_id1, v_id2; + END IF; + SELECT * INTO v_rec FROM src.workout_metrics WHERE id = v_id1; + IF v_rec.adherence <> 100.0 OR v_rec.tonnage <> 4400.0 THEN + RAISE EXCEPTION 'FAIL src.workout_metrics_upsert #2: поля'; + END IF; + RAISE NOTICE 'PASS src.workout_metrics_upsert #2'; + + -- #3 исключение: чужая тренировка + BEGIN + PERFORM src.workout_metrics_upsert(v_other, v_w, 50.0, 0.0, '{}', '{}', '{}', '[]'); + RAISE EXCEPTION 'FAIL src.workout_metrics_upsert #3: исключения не было'; + EXCEPTION WHEN others THEN + IF SQLERRM NOT LIKE 'тренировка % не принадлежит%' THEN + RAISE EXCEPTION 'FAIL src.workout_metrics_upsert #3: %', SQLERRM; + END IF; + RAISE NOTICE 'PASS src.workout_metrics_upsert #3'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_workout_select.psql b/2-db/src/tests/test_workout_select.psql new file mode 100644 index 0000000..065deca --- /dev/null +++ b/2-db/src/tests/test_workout_select.psql @@ -0,0 +1,58 @@ +-- test_workout_select: тесты src.workout_select (спецификация — func_specs\workout_select.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_w1 uuid := 'aaaa0000-0000-0000-0000-000000000001'; + v_w2 uuid := 'aaaa0000-0000-0000-0000-000000000002'; + v_s1 uuid := 'bbbb0000-0000-0000-0000-000000000001'; + v_s2 uuid := 'bbbb0000-0000-0000-0000-000000000002'; + v_rec record; + v_count int; +BEGIN + -- фикстуры: w1 + 2 подхода; w2 пустая, старее + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.workout_insert(v_athlete, v_w1, NULL, '2026-10-17 18:00+00', 'normal'); + PERFORM src.workout_insert(v_athlete, v_w2, NULL, '2026-10-10 18:00+00', 'normal'); + PERFORM src.workout_set_insert(v_athlete, v_s1, v_w1, NULL, 1, 50.0, 15, '2026-10-17 18:05+00', 'voice', true, NULL, NULL); + PERFORM src.workout_set_insert(v_athlete, v_s2, v_w1, NULL, 2, 52.5, 15, '2026-10-17 18:10+00', 'voice', true, NULL, NULL); + + -- #1 основной сценарий: карточки + подходы по порядку seq + SELECT * INTO v_rec FROM src.workout_select(v_athlete, NULL, NULL, NULL) + WHERE id = v_w1; + IF jsonb_array_length(v_rec.sets) <> 2 + OR v_rec.sets->0->>'seq' <> '1' OR v_rec.sets->1->>'seq' <> '2' THEN + RAISE EXCEPTION 'FAIL src.workout_select #1: sets = %', v_rec.sets; + END IF; + RAISE NOTICE 'PASS src.workout_select #1'; + + -- #2 граничный случай: тренировка без подходов + SELECT sets INTO v_rec.sets FROM src.workout_select(v_athlete, NULL, NULL, NULL) + WHERE id = v_w2; + IF v_rec.sets <> '[]'::jsonb THEN + RAISE EXCEPTION 'FAIL src.workout_select #2: sets = %', v_rec.sets; + END IF; + RAISE NOTICE 'PASS src.workout_select #2'; + + -- #3 граничный случай: фильтр по датам + SELECT count(*) INTO v_count FROM src.workout_select(v_athlete, NULL, '2026-10-15', '2026-10-17'); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.workout_select #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.workout_select #3'; + + -- #4 граничный случай: изоляция + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_count FROM src.workout_select( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), NULL, NULL, NULL); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.workout_select #4: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.workout_select #4'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_workout_set_get.psql b/2-db/src/tests/test_workout_set_get.psql new file mode 100644 index 0000000..7120dfc --- /dev/null +++ b/2-db/src/tests/test_workout_set_get.psql @@ -0,0 +1,35 @@ +-- test_workout_set_get: тесты src.workout_set_get (спецификация — func_specs\workout_set_get.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_s uuid := 'bbbb0000-0000-0000-0000-000000000001'; + v_count int; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.workout_insert(v_athlete, 'aaaa0000-0000-0000-0000-000000000001', NULL, '2026-10-17 18:00+00', 'normal'); + PERFORM src.workout_set_insert(v_athlete, v_s, 'aaaa0000-0000-0000-0000-000000000001', NULL, 1, 50.0, 15, '2026-10-17 18:05+00', 'voice', true, NULL, NULL); + + -- #1 основной сценарий + SELECT count(*) INTO v_count FROM src.workout_set_get(v_athlete, v_s); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.workout_set_get #1: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.workout_set_get #1'; + + -- #2 граничный случай: чужой подход + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + SELECT count(*) INTO v_count FROM src.workout_set_get( + (SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), v_s); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.workout_set_get #2: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.workout_set_get #2'; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_workout_set_insert.psql b/2-db/src/tests/test_workout_set_insert.psql new file mode 100644 index 0000000..0ee1f94 --- /dev/null +++ b/2-db/src/tests/test_workout_set_insert.psql @@ -0,0 +1,67 @@ +-- test_workout_set_insert: тесты src.workout_set_insert (спецификация — func_specs\workout_set_insert.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_other int; + v_w uuid := 'aaaa0000-0000-0000-0000-000000000001'; + v_s uuid := 'bbbb0000-0000-0000-0000-000000000001'; + v_ret uuid; + v_count int; +BEGIN + -- фикстуры + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + v_other := src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + PERFORM src.workout_insert(v_athlete, v_w, NULL, '2026-10-17 18:00+00', 'normal'); + + -- #1 основной сценарий + v_ret := src.workout_set_insert(v_athlete, v_s, v_w, NULL, 1, 50.0, 15, '2026-10-17 18:05+00', 'voice', true, 7, NULL); + IF v_ret <> v_s THEN + RAISE EXCEPTION 'FAIL src.workout_set_insert #1: uuid %', v_ret; + END IF; + RAISE NOTICE 'PASS src.workout_set_insert #1'; + + -- #2 граничный случай: повтор p_id + v_ret := src.workout_set_insert(v_athlete, v_s, v_w, NULL, 1, 50.0, 15, '2026-10-17 18:05+00', 'voice', true, 7, NULL); + IF v_ret <> v_s THEN + RAISE EXCEPTION 'FAIL src.workout_set_insert #2: uuid %', v_ret; + END IF; + SELECT count(*) INTO v_count FROM src.workout_set WHERE id = v_s; + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.workout_set_insert #2: дубль'; + END IF; + RAISE NOTICE 'PASS src.workout_set_insert #2'; + + -- #3 исключение: чужая тренировка + BEGIN + PERFORM src.workout_set_insert(v_other, 'bbbb0000-0000-0000-0000-000000000002', v_w, NULL, 1, 50.0, 15, now(), 'voice', true, NULL, NULL); + RAISE EXCEPTION 'FAIL src.workout_set_insert #3: исключения не было'; + EXCEPTION WHEN others THEN + IF SQLERRM NOT LIKE 'тренировка % не принадлежит%' THEN + RAISE EXCEPTION 'FAIL src.workout_set_insert #3: %', SQLERRM; + END IF; + RAISE NOTICE 'PASS src.workout_set_insert #3'; + END; + + -- #4 исключение: rpe = 11 + BEGIN + PERFORM src.workout_set_insert(v_athlete, 'bbbb0000-0000-0000-0000-000000000003', v_w, NULL, 2, 50.0, 15, now(), 'voice', true, 11, NULL); + RAISE EXCEPTION 'FAIL src.workout_set_insert #4: исключения не было'; + EXCEPTION WHEN check_violation THEN + RAISE NOTICE 'PASS src.workout_set_insert #4'; + END; + + -- #5 исключение: knee_pain = 11 + BEGIN + PERFORM src.workout_set_insert(v_athlete, 'bbbb0000-0000-0000-0000-000000000004', v_w, NULL, 2, 50.0, 15, now(), 'voice', true, NULL, 11); + RAISE EXCEPTION 'FAIL src.workout_set_insert #5: исключения не было'; + EXCEPTION WHEN check_violation THEN + RAISE NOTICE 'PASS src.workout_set_insert #5'; + END; +END; +$$; + +ROLLBACK; diff --git a/2-db/src/tests/test_workout_update.psql b/2-db/src/tests/test_workout_update.psql new file mode 100644 index 0000000..5c73ed5 --- /dev/null +++ b/2-db/src/tests/test_workout_update.psql @@ -0,0 +1,57 @@ +-- test_workout_update: тесты src.workout_update (спецификация — func_specs\workout_update.md). +-- Запуск: в pgAdmin (Query Tool), целиком; при FAIL — исключение, транзакция откатывается. + +BEGIN; + +DO $$ +DECLARE + v_athlete int; + v_w uuid := 'aaaa0000-0000-0000-0000-000000000001'; + v_count int; + v_rec record; +BEGIN + -- фикстура + v_athlete := src.athlete_insert('TEST-Атлет', 'TEST-hash-1'); + PERFORM src.workout_insert(v_athlete, v_w, NULL, '2026-10-17 18:00+00', 'normal'); + + -- #1 основной сценарий: закрытие клиентом + v_count := src.workout_update(v_athlete, v_w, '2026-10-17 19:10+00', 'finished', 'normal', 118, NULL, NULL, NULL); + IF v_count <> 1 THEN + RAISE EXCEPTION 'FAIL src.workout_update #1: count = %', v_count; + END IF; + SELECT * INTO v_rec FROM src.workout WHERE id = v_w; + IF v_rec.status <> 'finished' OR v_rec.ended_at IS NULL OR v_rec.avg_hr <> 118 THEN + RAISE EXCEPTION 'FAIL src.workout_update #1: поля'; + END IF; + RAISE NOTICE 'PASS src.workout_update #1'; + + -- #2 основной сценарий: FitParser дописывает факты (статус не тронут) + v_count := src.workout_update(v_athlete, v_w, '2026-10-17 19:10+00', 'finished', 'normal', 118, + '{"schema":"workout-facts/1"}'::jsonb, 'TEST-fit-1', 240); + SELECT * INTO v_rec FROM src.workout WHERE id = v_w; + IF v_rec.status <> 'finished' OR v_rec.fit_file_id IS DISTINCT FROM 'TEST-fit-1' + OR v_rec.hr_recovery_s <> 240 THEN + RAISE EXCEPTION 'FAIL src.workout_update #2: поля'; + END IF; + RAISE NOTICE 'PASS src.workout_update #2'; + + -- #3 граничный случай: чужая тренировка + PERFORM src.athlete_insert('TEST-Атлет-2', 'TEST-hash-2'); + v_count := src.workout_update((SELECT id FROM src.athlete WHERE api_key_hash = 'TEST-hash-2'), + v_w, now(), 'cancelled', 'normal', NULL, NULL, NULL, NULL); + IF v_count <> 0 THEN + RAISE EXCEPTION 'FAIL src.workout_update #3: count = %', v_count; + END IF; + RAISE NOTICE 'PASS src.workout_update #3'; + + -- #4 исключение: недопустимый статус + BEGIN + PERFORM src.workout_update(v_athlete, v_w, now(), 'other', 'normal', NULL, NULL, NULL, NULL); + RAISE EXCEPTION 'FAIL src.workout_update #4: исключения не было'; + EXCEPTION WHEN check_violation THEN + RAISE NOTICE 'PASS src.workout_update #4'; + END; +END; +$$; + +ROLLBACK; diff --git a/3-app/1-lib/Kit.Helpers/.zcode/plans/plan-sess_8b27371c-8307-4875-994d-f4fc82d983f7.md b/3-app/1-lib/Kit.Helpers/.zcode/plans/plan-sess_8b27371c-8307-4875-994d-f4fc82d983f7.md new file mode 100644 index 0000000..28de659 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/.zcode/plans/plan-sess_8b27371c-8307-4875-994d-f4fc82d983f7.md @@ -0,0 +1,37 @@ +# Вынос inline SQL из SqlHelperSQLite в EmbeddedResource .sql + +Убираем единственный inline-SQL в SQLite-пути — lookup тела хранимой функции в `GetStoredFunctionContent` ([SqlHelperSQLite.cs:305](SqlHelperAbstract/SqlHelperSQLite.cs#L305)). Публичный API не меняется, Postgres не трогаем (по договорённости). + +## Изменения + +**1. Новый файл `SqlHelperAbstract\Sql\sys_function_select.sql`** +Текст запроса один в один как сейчас (одна строка, `:param`-синтаксис, без точки с запятой): +```sql +select func_name, func_params, func_text from sys_functions where func_name = :func_name +``` + +**2. [Kit.Helpers.csproj](Kit.Helpers.csproj) — подключить как EmbeddedResource** +```xml + + + + +``` + +**3. Новый загрузчик `SqlHelperAbstract\SqlTexts.cs`** (namespace `Kit.Helpers.SqlHelperAbstract`) +- Внутренний статический класс с единственным свойством `SelectStoredFunction`. +- Читает ресурс из `typeof(SqlTexts).Assembly` (поиск имени ресурса по суффиксу `sys_function_select.sql` — устойчиво к смене root namespace), кэширует в `ConcurrentDictionary`, текст `Trim()`-ится. + +**4. Правка [SqlHelperSQLite.cs:305](SqlHelperAbstract/SqlHelperSQLite.cs#L305)** +```csharp +command.CommandText = SqlTexts.SelectStoredFunction; +``` +Всё остальное в `GetStoredFunctionContent` без изменений: привязка `:func_name` через `AddWithValue`, проверки имени, восстановление `CommandText`/`CommandType` остаются как есть. + +**5. Синхронизация контракта с DDL (мелочь)** +В `2-db\kb_sl3\sys\tables\sys_functions.sql` и `2-db\org_sl3\sys\tables\sys_functions.sql` есть примечание «Имя и колонки зашиты в Kit.Helpers SqlHelperSQLite — не переименовывать». Обновить формулировку: источник текста — `SqlHelperAbstract\Sql\sys_function_select.sql` в Kit.Helpers. + +## Проверка +- `dotnet build` на Kit.Helpers — без ошибок/предупреждений по изменённым файлам. +- Убедиться, что ресурс реально в сборке (`GetManifestResourceNames` содержит `...sys_function_select.sql`) — покрывается загрузчиком: при отсутствии ресурса `Single()` бросит понятное исключение. +- Существующие тесты (`SqlHelperAbstract\Test`) — фейк-транспорт без БД, их поведение не меняется; прогнать `dotnet test`, если есть test-проект в решении. diff --git a/3-app/1-lib/Kit.Helpers/Cache/BaseCacheProvider.cs b/3-app/1-lib/Kit.Helpers/Cache/BaseCacheProvider.cs new file mode 100644 index 0000000..0b8cd33 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Cache/BaseCacheProvider.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Caching.Memory; +using Kit.Helpers; + +namespace Kit.Helpers.Cache +{ + public abstract class BaseCacheProvider : ICacheProvider + { + + private readonly Dictionary _lockObjects = new Dictionary(1000); + + private readonly object _keysLock = new object(); + + public abstract bool Contains(string cacheName); + public abstract void PurgeCacheItems(string cacheName, bool isPrefix = false); + public abstract void SetCacheData(string cacheName, TObject data, MemoryCacheEntryOptions setProperties = null); + public abstract void SetCacheData(string cacheName, TObject data, MemoryCacheEntryOptions setProperties = null, PostEvictionDelegate callback = null); + public abstract object GetCacheData(string cacheName); + public abstract TResult GetCacheData(string cacheName); + + protected string CacheNameNormalize(string casheName) + { + if (casheName == null) return string.Empty; + + return casheName.ToLower(); + } + protected object GetLockObject(string cacheName) + { + lock (_keysLock) + { + if (!this._lockObjects.ContainsKey(cacheName)) this._lockObjects.Add(cacheName, new object()); + return this._lockObjects[cacheName]; + } + } + + public virtual string CreateCacheName(object prefix, params object[] args) + { + prefix = prefix != null ? prefix.ToString().AddSufixIsNotEmpty("_") : string.Empty; + + string end = string.Empty; + if (!args.IsNullOrEmpty()) + { + end = args.Where(x => x != null).Select(x => x.ToString()).Join("_"); + } + + return $"{prefix}{end}"; + } + + public virtual TResult GetCacheData(string cacheName, Func getMethod, MemoryCacheEntryOptions setProperties = null) + { + cacheName = CacheNameNormalize(cacheName); + + Check.ArgumentIsNotNull(getMethod, "getMethod"); + if (cacheName.IsNullOrEmpty()) return (TResult)getMethod(); + + // если есть данные в кеше но возвращаем + TResult data = GetCacheData(cacheName); + if (data != null) return data; + + lock (this.GetLockObject(cacheName)) + { + // повторная проверка после блокировки + data = this.GetCacheData(cacheName); + if (data != null) return data; + + // если в кеше данных нет то выполняем метод + data = getMethod(); + this.SetCacheData(cacheName, data, setProperties); + } + + return data; + } + + public TResult GetCacheData(string cacheName, Func getMethod, MemoryCacheEntryOptions setProperties = null, PostEvictionDelegate callback = null) + { + cacheName = CacheNameNormalize(cacheName); + + Check.ArgumentIsNotNull(getMethod, "getMethod"); + if (cacheName.IsNullOrEmpty()) return (TResult)getMethod(); + + // если есть данные в кеше но возвращаем + TResult data = GetCacheData(cacheName); + if (data != null) return data; + + lock (this.GetLockObject(cacheName)) + { + // повторная проверка после блокировки + data = this.GetCacheData(cacheName); + if (data != null) return data; + + // если в кеше данных нет то выполняем метод + data = getMethod(); + this.SetCacheData(cacheName, data, setProperties, callback); + } + + return data; + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Cache/CacheParameter.cs b/3-app/1-lib/Kit.Helpers/Cache/CacheParameter.cs new file mode 100644 index 0000000..378b7af --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Cache/CacheParameter.cs @@ -0,0 +1,34 @@ +namespace Kit.Helpers.Cache +{ + using System; + public class CacheParameter : ICacheParameter + { + public string Key { get; } + public CacheParameter(CacheLevels mode, string key) + { + Check.StringArgumentIsNotNullOrEmpty(key, "Key"); + this.CachingLevel = mode; + this.Key = key ?? String.Empty; + } + + #region ICacheParameter Members + + public CacheLevels CachingLevel { get; } + + public string GetCacheKey(CacheLevels cachingMode) + { + return this.IsCache(cachingMode) ? this.Key : string.Empty; + } + + public bool IsCache(CacheLevels cachingLevel) + { + if (this.CachingLevel == cachingLevel) + { + return true; + } + return false; + } + + #endregion + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Cache/CacheProvider.cs b/3-app/1-lib/Kit.Helpers/Cache/CacheProvider.cs new file mode 100644 index 0000000..2ed8821 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Cache/CacheProvider.cs @@ -0,0 +1,256 @@ +namespace Kit.Helpers.Cache +{ + using Microsoft.Extensions.Caching.Memory; + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + + public enum CacheLevels { None = 100, Database = 1, ClientFilter=3, Convert=5, ConvertFilter=7, Sorting=9, Paging=11 } + + public interface ICacheParameter + { + CacheLevels CachingLevel { get; } + string GetCacheKey(CacheLevels cachingMode); + bool IsCache(CacheLevels cachingMode); + } + + public class CacheSetProperties + { + public bool EnableCaching { get; set; } + public Int32 DurationSeconds { get; set; } + public Int32 SlidingSeconds { get; set; } + } + + public class CacheProvider : ICacheProvider + { + public Dictionary CacheCollection = new Dictionary(); + private Dictionary LockObjects = new Dictionary(1000); + private object m_keysLock = new object(); + + private IMemoryCache Cache; + private object m_lock = new object(); + + public CacheProvider(IMemoryCache memoryCache) + { + Cache = memoryCache; + } + + private IList GetCacheKeys() + { + var field = typeof(MemoryCache).GetProperty("EntriesCollection", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var collection = field.GetValue(Cache) as ICollection; + var items = new List(); + if (collection != null) + foreach (var item in collection) + { + var methodInfo = item.GetType().GetProperty("Key"); + var val = methodInfo.GetValue(item); + items.Add(val.ToString()); + } + return items; + } + + public void PurgeCacheItem(string prefix, ICacheParameter parameters, CacheLevels cachingMode) + { + string cacheName = GetCacheKey(prefix, parameters, cachingMode); + + if (this.Cache != null) + { + //формируем список ключей для удаления из кэша + List itemsToRemove = new List(); + IList cacheKeys = this.GetCacheKeys(); + + foreach (string key in cacheKeys) + { + if (key.ToLowerEquals(cacheName)) + { + itemsToRemove.Add(key); + } + } + //удаляем элементы кэша + foreach (string key in itemsToRemove) + { + this.Cache.Remove(key); + } + } + else + { + var itemToRemoveKeys = CacheCollection.Where(x => x.Key.ToLowerEquals(cacheName)).Select(x => x.Key); + foreach (var key in itemToRemoveKeys) + { + lock (GetLockObject(key)) { CacheCollection.Remove(key); } + lock (LockObjects) { LockObjects.Remove(key); } + } + } + } + + public void PurgeCacheItems(string prefix) + { + prefix = prefix.ToLower(); + + if (this.Cache != null) + { + //формируем список ключей для удаления из кэша + List itemsToRemove = new List(); + IList cacheKeys = this.GetCacheKeys(); + foreach (string key in cacheKeys) + { + if (key.ToLower().StartsWith(prefix)) + { + itemsToRemove.Add(key); + } + } + //удаляем элементы кэша + foreach (string key in itemsToRemove) + { + this.Cache.Remove(key); + } + } + else + { + var itemToRemoveKeys = from di in CacheCollection + where di.Key.ToLower().StartsWith(prefix) + select di.Key; + foreach (var key in itemToRemoveKeys) + { + lock (GetLockObject(key)) { CacheCollection.Remove(key); } + lock (LockObjects) { LockObjects.Remove(key); } + } + } + } + public bool Contains(string cacheName) + { + if (this.Cache == null) return CacheCollection.ContainsKey(cacheName); + return this.Cache.TryGetValue(cacheName, out object obj); + } + public void SetCacheData(string key, object data, CacheSetProperties setProperties) + { + if (setProperties.EnableCaching && data != null) + { + if (this.Cache == null) + { + if (CacheCollection.ContainsKey(key)) + CacheCollection[key] = data; + else + CacheCollection.Add(key, data); + } + else + { + var cacheEntryOptions = new MemoryCacheEntryOptions() + .SetSlidingExpiration(TimeSpan.FromSeconds(setProperties.SlidingSeconds)) + .SetAbsoluteExpiration(TimeSpan.FromSeconds(setProperties.DurationSeconds)) + .SetPriority(CacheItemPriority.Normal) + //.SetSize(1024) + ; + + this.Cache.Set(key, data, cacheEntryOptions); + } + } + } + public object GetCacheData(string cacheName) + { + if (Cache == null) + { + if (CacheCollection.ContainsKey(cacheName)) return CacheCollection[cacheName]; + return null; + } + + if (!this.Contains(cacheName)) + { + return null; + } + + if (this.Cache.TryGetValue(cacheName, out object obj)) + { + return obj; + } + + return null; + } + + + public object GetLockObject(string cacheName) + { + lock (m_keysLock) + { + if (!this.LockObjects.ContainsKey(cacheName)) this.LockObjects.Add(cacheName, new object()); + return this.LockObjects[cacheName]; + } + } + public delegate TResult GetData(); + public TResult GetCacheData(string prefix, ICacheParameter parameters, CacheLevels cachingMode, Func getMethod) + { + CacheSetProperties setProperties = new CacheSetProperties{ + EnableCaching = true, + DurationSeconds = 6000, + SlidingSeconds = 60 + }; + return GetCacheData(prefix, parameters, cachingMode, getMethod, setProperties); + } + public TResult GetCacheData(string prefix, ICacheParameter parameters, CacheLevels cachingMode, Func getMethod, CacheSetProperties setProperties) + { + object data = null; + string cacheName = GetCacheKey(prefix, parameters, cachingMode); + + if (!cacheName.IsNullOrEmpty()) + { + data = GetCacheData(cacheName); + + if (data != null && data is TResult) return (TResult)data; + + lock (this.GetLockObject(cacheName)) + { + data = this.GetCacheData(cacheName); + if (data == null) data = getMethod(); + this.SetCacheData(cacheName, data, setProperties); + } + } + else + { + data = getMethod(); + } + return (TResult)data; + } + public string GetCacheKey(string prefix, ICacheParameter parameters, CacheLevels cachingMode) + { + bool isCache = parameters != null && parameters.IsCache(cachingMode); + return (isCache ? String.Format("{0}_{1}", prefix, parameters.GetCacheKey(cachingMode)) : String.Empty); + } + + public void ClearCacheData(string key) + { + throw new NotImplementedException(); + } + + public string CreateCacheName(object prefix, params object[] args) + { + throw new NotImplementedException(); + } + + public void PurgeCacheItems(string cacheName, bool isPrefix = false) + { + throw new NotImplementedException(); + } + + public void SetCacheData(string cacheName, TObject data, MemoryCacheEntryOptions setProperties = null) + { + throw new NotImplementedException(); + } + + public TResult GetCacheData(string cacheName) + { + throw new NotImplementedException(); + } + + public TResult GetCacheData(string cacheName, Func getMethod, MemoryCacheEntryOptions setProperties = null) + { + throw new NotImplementedException(); + } + + public TResult GetCacheData(string cacheName, Func getMethod, MemoryCacheEntryOptions setProperties = null, PostEvictionDelegate callback = null) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Cache/ICacheProvider.cs b/3-app/1-lib/Kit.Helpers/Cache/ICacheProvider.cs new file mode 100644 index 0000000..27deaaa --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Cache/ICacheProvider.cs @@ -0,0 +1,18 @@ +namespace Kit.Helpers.Cache +{ + using Microsoft.Extensions.Caching.Memory; + using System; + + public interface ICacheProvider + { + string CreateCacheName(object prefix, params object[] args); + bool Contains(string cacheName); + void PurgeCacheItems(string cacheName, bool isPrefix = false); + + void SetCacheData(string cacheName, TObject data, MemoryCacheEntryOptions setProperties = null); + object GetCacheData(string cacheName); + TResult GetCacheData(string cacheName); + TResult GetCacheData(string cacheName, Func getMethod, MemoryCacheEntryOptions setProperties = null); + TResult GetCacheData(string cacheName, Func getMethod, MemoryCacheEntryOptions setProperties = null, PostEvictionDelegate callback = null); + } +} diff --git a/3-app/1-lib/Kit.Helpers/Cache/MemoryCacheExtensions.cs b/3-app/1-lib/Kit.Helpers/Cache/MemoryCacheExtensions.cs new file mode 100644 index 0000000..5ef71d9 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Cache/MemoryCacheExtensions.cs @@ -0,0 +1,23 @@ +namespace Kit.Helpers.Cache +{ + using Microsoft.Extensions.Caching.Memory; + using System; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + + public static class MemoryCacheExtensions + { + private static readonly Func GetEntriesCollection = Delegate.CreateDelegate( + typeof(Func), + typeof(MemoryCache).GetProperty("EntriesCollection", BindingFlags.NonPublic | BindingFlags.Instance).GetGetMethod(true), + throwOnBindFailure: true) as Func; + + public static IEnumerable GetKeys(this IMemoryCache memoryCache) => + ((IDictionary)GetEntriesCollection((MemoryCache)memoryCache)).Keys; + + public static IEnumerable GetKeys(this IMemoryCache memoryCache) => + GetKeys(memoryCache).OfType(); + } +} diff --git a/3-app/1-lib/Kit.Helpers/Cache/MemoryCacheProvider.cs b/3-app/1-lib/Kit.Helpers/Cache/MemoryCacheProvider.cs new file mode 100644 index 0000000..5de1221 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Cache/MemoryCacheProvider.cs @@ -0,0 +1,100 @@ +namespace Kit.Helpers.Cache +{ + using Microsoft.Extensions.Caching.Memory; + using Kit.Helpers; + using System; + using System.Collections; + using System.Collections.Generic; + + public interface IHttpCacheProvider : ICacheProvider { } + + public class MemoryCacheProvider : BaseCacheProvider, IHttpCacheProvider + { + private IMemoryCache _memoryCache; + + public MemoryCacheProvider(IMemoryCache memoryCache) + { + _memoryCache = memoryCache; + } + + + public override bool Contains(string cacheName) + { + cacheName = this.CacheNameNormalize(cacheName); + object obj = null; + return _memoryCache.TryGetValue(cacheName, out obj); + } + + public override object GetCacheData(string cacheName) + { + cacheName = CacheNameNormalize(cacheName); + return this._memoryCache.Get(cacheName); + } + + public override TResult GetCacheData(string cacheName) + { + cacheName = CacheNameNormalize(cacheName); + + object result = this._memoryCache.Get(cacheName); + if (result == null) return default(TResult); + + Check.IsTrue(result is TResult, $"Cache value has type:{result.GetType().FullName}. ArgumentType:{typeof(TResult).FullName}"); + + return (TResult)result; + } + + + public override void PurgeCacheItems(string cacheName, bool isPrefix = false) + { + cacheName = CacheNameNormalize(cacheName); + + if (isPrefix) + { + var itemsToRemove = new List(); + + foreach (DictionaryEntry item in this._memoryCache.GetKeys()) + { + if (item.Key.ToString().StartsWith(cacheName)) + { + itemsToRemove.Add(item.Key.ToString()); + } + } + //удаляем элементы кэша + itemsToRemove.ForEach(x => this._memoryCache.Remove(x)); + } + else + { + this._memoryCache.Remove(cacheName); + } + } + + public override void SetCacheData(string cacheName, TObject data, MemoryCacheEntryOptions setProperties = null) + { + if (data == null) return; + cacheName = CacheNameNormalize(cacheName); + + if (setProperties == null) + { + setProperties = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1) }; + setProperties.Priority = CacheItemPriority.Normal; + } + + this._memoryCache.Set(cacheName, data, setProperties); + } + + public override void SetCacheData(string cacheName, TObject data, MemoryCacheEntryOptions setProperties = null, PostEvictionDelegate callback = null) + { + if (data == null) return; + cacheName = CacheNameNormalize(cacheName); + + if (setProperties == null) + { + setProperties = new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1) }; + setProperties.Priority = CacheItemPriority.Normal; + setProperties.RegisterPostEvictionCallback(callback); + } + + this._memoryCache.Set(cacheName, data, setProperties); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Check.cs b/3-app/1-lib/Kit.Helpers/Check.cs new file mode 100644 index 0000000..498a224 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Check.cs @@ -0,0 +1,121 @@ +namespace Kit.Helpers +{ + using System; + using System.Diagnostics; + + /// + /// a static class that checks if parameter is invalid and raises an exception in this case + /// + public static class Check + { + [DebuggerStepThrough] + public static void ArgumentIsNotNull(object argument, string name) + { + if (argument == null) + { + throw new ArgumentNullException(name); + } + } + + [DebuggerStepThrough] + public static void ArgumentIsNotNull(object argument, string name, string message) + { + if (argument == null) + { + throw new ArgumentException(message, name); + } + } + + [DebuggerStepThrough] + public static void IntegerArgumentPositive(Int32 value, string name) + { + if (value < 1) throw new ArgumentOutOfRangeException(name, value, string.Empty); + } + + [DebuggerStepThrough] + public static void IntegerArgumentPositive(Int32? value, string name) + { + if (!value.HasValue) throw new ArgumentNullException(name); + if (value.Value < 1) throw new ArgumentOutOfRangeException(name, value.Value, string.Empty); + } + + [DebuggerStepThrough] + public static void IntegerArgumentPositive(Int64 value, string name) + { + if (value < 1) throw new ArgumentOutOfRangeException(name, value, string.Empty); + } + + [DebuggerStepThrough] + public static void IntegerArgumentPositive(Int64? value, string name) + { + if (!value.HasValue) throw new ArgumentNullException(name); + if (value.Value < 1) throw new ArgumentOutOfRangeException(name, value.Value, string.Empty); + } + + [DebuggerStepThrough] + public static void StringArgumentIsNotNullOrEmpty(string str, string name) + { + if (string.IsNullOrEmpty(str)) + throw new ArgumentNullException(name); + + str = str.Trim(); + if (string.IsNullOrEmpty(str)) + throw new ArgumentNullException(name); + } + + [DebuggerStepThrough] + public static void IsTrue(bool argument, string message) + { + if (argument == false) + { + throw new InvalidOperationException(message); + } + } + + [DebuggerStepThrough] + public static void IsNotTrue(bool argument, string message) + { + if (argument == true) + { + throw new InvalidOperationException(message); + } + } + + [DebuggerStepThrough] + public static void IsNotNull(object argument, string message) + { + if (argument == null) + { + throw new InvalidOperationException(message); + } + } + /// + /// Checks whether argument is of specified type + /// Throws InvalidOperationException if argument is of wrong type + /// + /// the argument to be checked + /// the Type + /// Implied to contain a {0} placeholder within in order to be replaced with string representation of the type param + [DebuggerStepThrough] + public static void ArgumentIsOfType(object argument, Type type, string message) + { + Check.ArgumentIsNotNull(argument, "argument"); + Check.ArgumentIsNotNull(type, "type"); + + if (type != argument.GetType()) + { + throw new InvalidOperationException(string.Format(message, type.ToString())); + } + } + [DebuggerStepThrough] + public static void StringArgumentIsNotNullOrEmpty(string str, string paramName, string message) + { + if (string.IsNullOrEmpty(str)) + throw new ArgumentNullException(paramName: paramName, message: message); + + str = str.Trim(); + if (string.IsNullOrEmpty(str)) + throw new ArgumentNullException(paramName: paramName, message: message); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/ConfigurationHelper.cs b/3-app/1-lib/Kit.Helpers/ConfigurationHelper.cs new file mode 100644 index 0000000..37cd32c --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/ConfigurationHelper.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.Configuration; + +namespace Kit.Helpers +{ + public interface IConfigurationItem + { + DateTime FillDate { get; set; } + + void Init(IConfiguration configuration); + } + public class ConfigurationHelper + { + private readonly string _prefix; + + private readonly IConfiguration _configuration; + + public ConfigurationHelper(IConfiguration configuration) + : this(configuration, string.Empty) + { + _configuration = configuration; + } + + public ConfigurationHelper(IConfiguration configuration, string prefix) + { + _configuration = configuration; + _prefix = prefix; + } + + private string CreateKey(string itemKey) + { + return string.IsNullOrEmpty(_prefix) ? itemKey.ToString() : (_prefix + ":" + itemKey); + } + + public string GetConnectionString(string name, bool required = true) + { + string text = CreateKey(name); + string connectionString = _configuration.GetConnectionString(text); + if (required) + { + Check.StringArgumentIsNotNullOrEmpty(connectionString, null, "connection string not set in config. name=" + text); + } + + return connectionString; + } + + public string GetAppSettingValue(object settingsItemKey, bool required = true, string defaultValue = null) + { + string text = CreateKey(settingsItemKey.ToString()); + string text2 = _configuration[text] ?? defaultValue; + if (required && string.IsNullOrWhiteSpace(text2)) + { + throw new Exception(text + " not set in appSettings section"); + } + + return text2; + } + + public IConfigurationSection GetSection(string name, bool required = true) + { + string appSettingsKey; + return GetSection(name, required, out appSettingsKey); + } + + public IConfigurationSection GetSection(string name, bool required, out string appSettingsKey) + { + appSettingsKey = CreateKey(name.ToString()); + if (required) + { + return _configuration.GetRequiredSection(appSettingsKey); + } + + return _configuration.GetSection(appSettingsKey); + } + + public TEntity Get(string name) + { + string key = CreateKey(name.ToString()); + return _configuration.GetSection(key).Get(); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/CookieHelper.cs b/3-app/1-lib/Kit.Helpers/CookieHelper.cs new file mode 100644 index 0000000..a991312 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/CookieHelper.cs @@ -0,0 +1,53 @@ +namespace Kit.Helpers +{ + using System; + using Microsoft.AspNetCore.Http; + + public class CookieHelper + { + public static string GetValue(HttpContext context, string key) + { + String result = String.Empty; + if (context != null && context.Request.Cookies.ContainsKey(key)) + { + return context.Request.Cookies[key]; + } + return result; + } + + public static void SetValue(HttpContext context, string key, string value, DateTime expires) + { + if (context == null) + { + return; + } + + if (context.Request.Cookies.ContainsKey(key)) + { + context.Response.Cookies.Delete(key); + } + + context.Response.Cookies.Append(key, value, new CookieOptions + { + Expires = expires, + + }); + } + + public static void SetValue(HttpContext context, string key, string value) + { + SetValue(context, key, value, DateTime.MaxValue); + } + + public static void RemoveCookie(HttpContext context, string key) + { + if (context == null) + { + return; + } + + context.Response.Cookies.Delete(key); + } + + } +} diff --git a/3-app/1-lib/Kit.Helpers/Db/ListWithPage.cs b/3-app/1-lib/Kit.Helpers/Db/ListWithPage.cs new file mode 100644 index 0000000..56dab92 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Db/ListWithPage.cs @@ -0,0 +1,38 @@ +namespace Kit.Helpers +{ + public interface IEnumerableWithPage : IEnumerable + { + int TotalRows { get; set; } + int FilterRow { get; set; } + int PageNo { get; set; } + int PageSize { get; set; } + string Sort { get; set; } + } + + public interface IEnumerableWithOffer : IEnumerable + { + int TotalRows { get; set; } + int FilterRow { get; set; } + int Start { get; set; } + int Lenght { get; set; } + string Sort { get; set; } + } + + public class ListWithPage : List, IEnumerableWithPage + { + public int TotalRows { get; set; } + public int FilterRow { get; set; } + public int PageNo { get; set; } + public int PageSize { get; set; } + public new string Sort { get; set; } + } + + public class ListWithOffer : List, IEnumerableWithOffer + { + public int TotalRows { get; set; } + public int FilterRow { get; set; } + public int Start { get; set; } + public int Lenght { get; set; } + public new string Sort { get; set; } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Db/Postgres/SqlServerHelper.cs b/3-app/1-lib/Kit.Helpers/Db/Postgres/SqlServerHelper.cs new file mode 100644 index 0000000..a99b389 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Db/Postgres/SqlServerHelper.cs @@ -0,0 +1,414 @@ +namespace Kit.Helpers.Postgres +{ + using Npgsql; + using System; + using System.Collections.Generic; + using System.Data; + using System.Linq; + + public class OutPutValue + { + public object Value; + } + + public static class SqlServerHelper + { + /// + /// Проверка на наличие столбца в запросе + /// + /// + /// + /// + public static bool HasColumn(this IDataReader reader, string name) + { + string nameLow = name.ToLower(); + for (int n = 0; n < reader.FieldCount; n++) + { + if (reader.GetName(n).ToLower() == nameLow) return true; + } + return false; + } + + public static NpgsqlCommand CreateCommand(this string connectionString, string sql, IEnumerable> parameters = null) + { + if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentNullException("connectionString"); + if (string.IsNullOrWhiteSpace(sql)) throw new ArgumentNullException("sql"); + + NpgsqlCommand command = new NpgsqlConnection(connectionString).CreateCommand(); + command.CommandTimeout = 60 * 60 * 60; + command.CommandType = System.Data.CommandType.Text; + command.CommandText = sql; + + if (parameters != null && parameters.Any()) + { + parameters.ToList().ForEach( + x => { command.Parameters.AddWithValue(x.Key, x.Value ?? DBNull.Value); } + ); + } + + return command; + } + + + public static NpgsqlCommand CreateCommand(this string connectionString, string sql, bool isProcedure = true, IEnumerable> parameters = null) + { + if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentNullException("connectionString"); + if (string.IsNullOrWhiteSpace(sql)) throw new ArgumentNullException("sql"); + + NpgsqlCommand command = new NpgsqlConnection(connectionString).CreateCommand(); + command.CommandTimeout = 60 * 60 * 60; + command.CommandType = System.Data.CommandType.Text; + command.CommandText = sql; + + if (parameters != null && parameters.Any()) + { + parameters.ToList().ForEach( + x => { command.Parameters.AddWithValue(x.Key, x.Value ?? DBNull.Value); } + ); + } + + return command; + } + + public static NpgsqlCommand CreateCommandWithOutput(this string connectionString, string sql, IEnumerable> parameters = null) + { + if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentNullException("connectionString"); + if (string.IsNullOrWhiteSpace(sql)) throw new ArgumentNullException("sql"); + + NpgsqlCommand command = new NpgsqlConnection(connectionString).CreateCommand(); + command.CommandTimeout = 60 * 60 * 60; + command.CommandType = System.Data.CommandType.Text; + command.CommandText = sql; + + if (parameters != null && parameters.Any()) + { + parameters.ToList().ForEach( + x => + { + object value = x.Value; + bool isOutPut = false; + if (x.Value is OutPutValue) + { + value = ((OutPutValue)x.Value).Value; + isOutPut = true; + } + var parameter = command.Parameters.AddWithValue(x.Key, value ?? DBNull.Value); + if (isOutPut) + { + parameter.Direction = ParameterDirection.Output; + } + } + ); + + } + + return command; + } + + public static void ExecuteNonQuery(this string connectionString, string sql, IEnumerable> parameters = null) + { + NpgsqlCommand command = connectionString.CreateCommand(sql, parameters); + using (command.Connection) + { + command.Connection.Open(); + command.ExecuteNonQuery(); + command.Connection.Close(); + } + } + + public static void ExecuteNonQuery(this string connectionString, string sql, bool isStoredProcedure = false, IEnumerable> parameters = null) + { + NpgsqlCommand command = connectionString.CreateCommand(sql, parameters); + using (command.Connection) + { + command.Connection.Open(); + command.ExecuteNonQuery(); + command.Connection.Close(); + } + } + + + public static object ExecuteScalar(this string connectionString, string sql, IEnumerable> parameters = null) + { + object returnScalar; + + NpgsqlCommand command = connectionString.CreateCommand(sql, parameters); + using (command.Connection) + { + command.Connection.Open(); + returnScalar = command.ExecuteScalar(); + command.Connection.Close(); + } + + return returnScalar; + } + + public static IEnumerable> ExecuteOutputParameters(this string connectionString, string sql, IEnumerable> parameters = null) + { + List> result = new List>(); + + NpgsqlCommand command = connectionString.CreateCommandWithOutput(sql, parameters); + using (command.Connection) + { + command.Connection.Open(); + command.ExecuteNonQuery(); + command.Connection.Close(); + + foreach(NpgsqlParameter parameter in command.Parameters) + { + if(parameter.Direction == ParameterDirection.Output) + { + result.Add(new KeyValuePair(parameter.ParameterName, parameter.Value)); + } + } + } + + return result; + } + + public static TResult ExecuteScalar(this string connectionString, string sql, bool isStoredProcedure = false, IEnumerable> parameters = null) + { + object returnScalar; + + NpgsqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + using (command.Connection) + { + command.Connection.Open(); + returnScalar = command.ExecuteScalar(); + command.Connection.Close(); + } + return (TResult)returnScalar; + } + + public static IEnumerable ExecuteSelect(this string connectionString, string sql, Func converter, IEnumerable> parameters = null) + { + if (converter == null) throw new ArgumentNullException("converter"); + + NpgsqlCommand command = connectionString.CreateCommand(sql, parameters); + using (command.Connection) + { + command.Connection.Open(); + using (IDataReader reader = command.ExecuteReader()) + { + IList selectResult = new List(); + while (reader.Read()) + { + selectResult.Add(converter(reader)); + } + return selectResult; + } + } + } + + public static IEnumerable ExecuteSelect(this string connectionString, Func converter, string sql, bool isStoredProcedure = false, IEnumerable> parameters = null) + { + if (converter == null) throw new ArgumentNullException("converter"); + + NpgsqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + command.CommandTimeout = command.Connection.ConnectionTimeout; + using (command.Connection) + { + command.Connection.Open(); + using (IDataReader reader = command.ExecuteReader()) + { + IList selectResult = new List(); + while (reader.Read()) + { + selectResult.Add(converter(reader)); + } + return selectResult; + } + } + } + + public static DataTable ExecuteDataTable(this string connectionString, string sql, IEnumerable> parameters = null) + { + NpgsqlCommand command = connectionString.CreateCommand(sql, parameters); + + using (command.Connection) + { + command.Connection.Open(); + using (var adapter = new NpgsqlDataAdapter(command)) + { + var table = new DataTable(); + adapter.Fill(table); + return table; + } + } + } + + public static DataTable ExecuteDataTable(this string connectionString, string sql, bool isStoredProcedure = false, IEnumerable> parameters = null) + { + NpgsqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + + using (command.Connection) + { + command.Connection.Open(); + using (var adapter = new NpgsqlDataAdapter(command)) + { + var table = new DataTable(); + adapter.Fill(table); + return table; + } + } + } + + public static TValue Get(this IDataRecord record, string fieldName) + { + try + { + //if ((record[fieldName] == DBNull.Value) && (typeof(TValue) == typeof(object)) ) return (TValue)null; + + return (TValue)record[fieldName]; + } + catch (InvalidCastException ex) + { + throw new InvalidCastException(string.Format("{2} {0} нельзя привести к {1}", record[fieldName].GetType().FullName, typeof(TValue).FullName, fieldName), ex); + } + } + public static Decimal GetDecimalX(this IDataRecord record, string fieldName) + { + Decimal rret = 0; + if (record[fieldName] != DBNull.Value) + { + if (record[fieldName].GetType().Name == "Int32") + { + rret = Convert.ToDecimal(record[fieldName]); + } + else + { + rret = record.Get (fieldName); + } + } + + return rret; + } + + public static string CreateParameterListString(this IEnumerable> parameters) + { + return string.Join(", ", parameters.Select(x => x.Key).ToArray()); + } + + + public static TValue GetN(this IDataRecord record, string fieldName) + { + return record[fieldName] != DBNull.Value ? record.Get(fieldName) : default(TValue); + } + + public static IEnumerable ExecuteSelectMany(this string connectionString, string sql, + bool isStoredProcedure = false, IEnumerable> parameters = null, + params Action>[] converters) + { + if (converters == null) throw new ArgumentNullException("converters"); + NpgsqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + command.CommandTimeout = command.Connection.ConnectionTimeout; + using (command.Connection) + { + command.Connection.Open(); + using (IDataReader reader = command.ExecuteReader()) + { + IList selectResult = new List(); + foreach (var converter in converters) + { + while (reader.Read()) + { + converter(reader, selectResult); + } + reader.NextResult(); + } + + return selectResult; + } + } + } + + public static IEnumerableWithPage ExecuteSelectManyWithPage( + this string connectionString, + string sql, + bool isStoredProcedure = false, + IEnumerable> parameters = null, + params Action>[] converters + ) + { + if (converters == null) throw new ArgumentNullException("converters"); + + NpgsqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + command.CommandTimeout = command.Connection.ConnectionTimeout; + using (command.Connection) + { + command.Connection.Open(); + using (IDataReader reader = command.ExecuteReader()) + { + var result = new ListWithPage(); + foreach (var converter in converters) + { + while (reader.Read()) + { + converter(reader, result); + } + reader.NextResult(); + } + + // считывание данных постраничного вывода + while (reader.Read()) + { + if (reader.HasColumn("TotalRows")) result.TotalRows = reader.Get("TotalRows"); + if (reader.HasColumn("PageNo")) result.PageNo = reader.Get("PageNo"); + if (reader.HasColumn("PageSize")) result.PageSize = reader.Get("PageSize"); + if (reader.HasColumn("Sort")) result.Sort = reader.Get("Sort"); + } + + return result; + } + } + } + + public static IEnumerableWithOffer ExecuteSelectManyWithOffer( + this string connectionString, + string sql, + bool isStoredProcedure = false, + IEnumerable> parameters = null, + params Action>[] converters + ) + { + if (converters == null) throw new ArgumentNullException("converters"); + + NpgsqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + command.CommandTimeout = command.Connection.ConnectionTimeout; + using (command.Connection) + { + command.Connection.Open(); + using (IDataReader reader = command.ExecuteReader()) + { + var result = new ListWithOffer(); + foreach (var converter in converters) + { + while (reader.Read()) + { + converter(reader, result); + } + reader.NextResult(); + } + + // считывание данных постраничного вывода + while (reader.Read()) + { + if (reader.HasColumn("TotalRows")) result.TotalRows = reader.Get("TotalRows"); + if (reader.HasColumn("PageNo")) result.Start = reader.Get("Start"); + if (reader.HasColumn("PageSize")) result.Lenght = reader.Get("Length"); + if (reader.HasColumn("Sort")) result.Sort = reader.Get("Sort"); + } + + return result; + } + } + } + + #region Add ----------------------------------------------------------- + + + + #endregion + + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Db/Postgres/SqlServerHelperFluent.cs b/3-app/1-lib/Kit.Helpers/Db/Postgres/SqlServerHelperFluent.cs new file mode 100644 index 0000000..5a2eda4 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Db/Postgres/SqlServerHelperFluent.cs @@ -0,0 +1,162 @@ +namespace Kit.Helpers.Postgres +{ + using System; + using System.Collections.Generic; + using System.Data; + + public class SqlServerHelperFluent + { + protected readonly string _connectionString; + protected readonly string _sqlText; + protected bool _isStoredProcedure; + protected bool _isOutputParameter; + protected List> _parameters; + + public SqlServerHelperFluent(string connectionString, string sqlText) + { + _connectionString = connectionString; + _sqlText = sqlText; + _isStoredProcedure = true; + _isOutputParameter = false; + _parameters = new List>(); + } + + #region fluent methods + + public SqlServerHelperFluent AsStoredProcedure() + { + _isStoredProcedure = true; + return this; + } + + public SqlServerHelperFluent AsSqlText() + { + _isStoredProcedure = false; + return this; + } + + public SqlServerHelperFluent AddParameter(string name, object value) + { + _parameters.Add(new KeyValuePair(name, value)); + return this; + } + public SqlServerHelperFluent AddParameter(string name, string value) + { + + _parameters.Add(new KeyValuePair(name, value ?? string.Empty)); + return this; + } + + public SqlServerHelperFluent AddParameterNullable(string name, object value) + { + _parameters.Add(new KeyValuePair(name, value ?? DBNull.Value)); + return this; + } + + public SqlServerHelperFluent AddOutputParameter(string name, object value) + { + _parameters.Add(new KeyValuePair(name, new OutPutValue { Value = value ?? DBNull.Value })); + return this; + } + + public SqlServerHelperFluent AddParameters(IEnumerable> parameters) + { + _parameters.AddRange(parameters); + return this; + } + + #endregion + + #region finish methods + + public void ExecuteNonQuery() + { + _connectionString.ExecuteNonQuery(_sqlText, isStoredProcedure: _isStoredProcedure, parameters: _parameters); + } + + public TResult ExecuteScalar() + { + return _connectionString.ExecuteScalar(_sqlText, isStoredProcedure: _isStoredProcedure, parameters: _parameters); + } + + #endregion + } + + public class SqlServerHelperFluent : SqlServerHelperFluent + { + protected List>> _converters; + + public SqlServerHelperFluent(string connectionString, string sqlText) : base (connectionString, sqlText) + { + _converters = new List>>(); + } + + #region base fluent methods + + public new SqlServerHelperFluent AddParameter(string name, object value) + { + + base.AddParameter(name, value); + return this; + } + public new SqlServerHelperFluent AddParameterNullable(string name, object value) + { + + base.AddParameterNullable(name, value); + return this; + } + + public new SqlServerHelperFluent AddParameters(IEnumerable> parameters) + { + base.AddParameters(parameters); + return this; + } + + public new SqlServerHelperFluent AsStoredProcedure() + { + base.AsStoredProcedure(); + return this; + } + + public new SqlServerHelperFluent AsSqlText() + { + base.AsSqlText(); + return this; + } + + #endregion + + public SqlServerHelperFluent AddConverter(Action> converter) + { + _converters.Add(converter); + return this; + } + + public IEnumerable ExecuteSelectMany() + { + return _connectionString.ExecuteSelectMany(_sqlText, isStoredProcedure: _isStoredProcedure, parameters: _parameters, converters: _converters.ToArray()); + } + + public IEnumerableWithPage ExecuteSelectManyWithPage() + { + return _connectionString.ExecuteSelectManyWithPage(_sqlText, isStoredProcedure: _isStoredProcedure, parameters: _parameters, converters: _converters.ToArray()); + } + public IEnumerableWithOffer ExecuteSelectManyWithOffer() + { + return _connectionString.ExecuteSelectManyWithOffer(_sqlText, isStoredProcedure: _isStoredProcedure, parameters: _parameters, converters: _converters.ToArray()); + } + } + + public static class SqlServerHelperFluentExtentions + { + public static SqlServerHelperFluent PrepareExecute(this string connectionString, string sqlText) + { + return new SqlServerHelperFluent(connectionString, sqlText); + } + + public static SqlServerHelperFluent PrepareExecute(this string connectionString, string sqlText) + { + return new SqlServerHelperFluent(connectionString, sqlText); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Db/SqlServerHelper.cs b/3-app/1-lib/Kit.Helpers/Db/SqlServerHelper.cs new file mode 100644 index 0000000..e2bb027 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Db/SqlServerHelper.cs @@ -0,0 +1,425 @@ +namespace Kit.Helpers.MsSql +{ + using System; + using System.Collections.Generic; + using System.Data; + using System.Data.SqlClient; + using System.Linq; + + public class OutPutValue + { + public object Value; + } + + public static class SqlServerHelper + { + /// + /// Проверка на наличие столбца в запросе + /// + /// + /// + /// + public static bool HasColumn(this IDataReader reader, string name) + { + string nameLow = name.ToLower(); + for (int n = 0; n < reader.FieldCount; n++) + { + if (reader.GetName(n).ToLower() == nameLow) return true; + } + return false; + } + + public static SqlCommand CreateCommand(this string connectionString, string sql, IEnumerable> parameters = null) + { + if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentNullException("connectionString"); + if (string.IsNullOrWhiteSpace(sql)) throw new ArgumentNullException("sql"); + + SqlCommand command = new SqlConnection(connectionString).CreateCommand(); + command.CommandTimeout = 60 * 60 * 60; + command.CommandType = System.Data.CommandType.Text; + command.CommandText = sql; + + if (parameters != null && parameters.Any()) + { + parameters.ToList().ForEach( + x => { command.Parameters.AddWithValue(x.Key, x.Value ?? DBNull.Value); } + ); + } + + return command; + } + + public static SqlCommand CreateCommandWithOutput(this string connectionString, string sql, IEnumerable> parameters = null) + { + if (string.IsNullOrWhiteSpace(connectionString)) throw new ArgumentNullException("connectionString"); + if (string.IsNullOrWhiteSpace(sql)) throw new ArgumentNullException("sql"); + + SqlCommand command = new SqlConnection(connectionString).CreateCommand(); + command.CommandTimeout = 60 * 60 * 60; + command.CommandType = System.Data.CommandType.Text; + command.CommandText = sql; + + if (parameters != null && parameters.Any()) + { + parameters.ToList().ForEach( + x => + { + object value = x.Value; + bool isOutPut = false; + if (x.Value is OutPutValue) + { + value = ((OutPutValue)x.Value).Value; + isOutPut = true; + } + var parameter = command.Parameters.AddWithValue(x.Key, value ?? DBNull.Value); + if (isOutPut) + { + parameter.Direction = ParameterDirection.Output; + } + } + ); + + } + + return command; + } + + public static SqlCommand CreateCommand(this string connectionString, string sql, bool isStoredProcedure = false, IEnumerable> parameters = null) + { + // if (connectionString.IsNullOrEmpty()) throw new ArgumentNullException("connectionString"); + // if (sql.IsNullOrEmpty()) throw new ArgumentNullException("sql"); + + var sqlConnection = new SqlConnection(connectionString); + + SqlCommand command = sqlConnection.CreateCommand(); + command.CommandTimeout = sqlConnection.ConnectionTimeout; + if (isStoredProcedure) + { + command.CommandType = System.Data.CommandType.StoredProcedure; + } + else + { + command.CommandType = System.Data.CommandType.Text; + } + command.CommandText = sql; + + if (parameters != null && parameters.Any()) + { + foreach (var keyValuePair in parameters) + { + SqlParameter addedParameter = command.Parameters.AddWithValue(keyValuePair.Key, keyValuePair.Value ?? DBNull.Value); + if (keyValuePair.Value is DataTable) addedParameter.SqlDbType = SqlDbType.Structured; + } + } + + return command; + } + + public static void ExecuteNonQuery(this string connectionString, string sql, IEnumerable> parameters = null) + { + SqlCommand command = connectionString.CreateCommand(sql, parameters); + using (command.Connection) + { + command.Connection.Open(); + command.ExecuteNonQuery(); + command.Connection.Close(); + } + } + + public static void ExecuteNonQuery(this string connectionString, string sql, bool isStoredProcedure = false, IEnumerable> parameters = null) + { + SqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + using (command.Connection) + { + command.Connection.Open(); + command.ExecuteNonQuery(); + command.Connection.Close(); + } + } + + + public static object ExecuteScalar(this string connectionString, string sql, IEnumerable> parameters = null) + { + object returnScalar; + + SqlCommand command = connectionString.CreateCommand(sql, parameters); + using (command.Connection) + { + command.Connection.Open(); + returnScalar = command.ExecuteScalar(); + command.Connection.Close(); + } + + return returnScalar; + } + + public static IEnumerable> ExecuteOutputParameters(this string connectionString, string sql, IEnumerable> parameters = null) + { + List> result = new List>(); + + SqlCommand command = connectionString.CreateCommandWithOutput(sql, parameters); + using (command.Connection) + { + command.Connection.Open(); + command.ExecuteNonQuery(); + command.Connection.Close(); + + foreach(SqlParameter parameter in command.Parameters) + { + if(parameter.Direction == ParameterDirection.Output) + { + result.Add(new KeyValuePair(parameter.ParameterName, parameter.Value)); + } + } + } + + return result; + } + + public static TResult ExecuteScalar(this string connectionString, string sql, bool isStoredProcedure = false, IEnumerable> parameters = null) + { + object returnScalar; + + SqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + using (command.Connection) + { + command.Connection.Open(); + returnScalar = command.ExecuteScalar(); + command.Connection.Close(); + } + return (TResult)returnScalar; + } + + public static IEnumerable ExecuteSelect(this string connectionString, string sql, Func converter, IEnumerable> parameters = null) + { + if (converter == null) throw new ArgumentNullException("converter"); + + SqlCommand command = connectionString.CreateCommand(sql, parameters); + using (command.Connection) + { + command.Connection.Open(); + using (IDataReader reader = command.ExecuteReader()) + { + IList selectResult = new List(); + while (reader.Read()) + { + selectResult.Add(converter(reader)); + } + return selectResult; + } + } + } + + public static IEnumerable ExecuteSelect(this string connectionString, Func converter, string sql, bool isStoredProcedure = false, IEnumerable> parameters = null) + { + if (converter == null) throw new ArgumentNullException("converter"); + + SqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + command.CommandTimeout = command.Connection.ConnectionTimeout; + using (command.Connection) + { + command.Connection.Open(); + using (IDataReader reader = command.ExecuteReader()) + { + IList selectResult = new List(); + while (reader.Read()) + { + selectResult.Add(converter(reader)); + } + return selectResult; + } + } + } + + public static DataTable ExecuteDataTable(this string connectionString, string sql, IEnumerable> parameters = null) + { + SqlCommand command = connectionString.CreateCommand(sql, parameters); + + using (command.Connection) + { + command.Connection.Open(); + using (var adapter = new SqlDataAdapter(command)) + { + var table = new DataTable(); + adapter.Fill(table); + return table; + } + } + } + + public static DataTable ExecuteDataTable(this string connectionString, string sql, bool isStoredProcedure = false, IEnumerable> parameters = null) + { + SqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + + using (command.Connection) + { + command.Connection.Open(); + using (var adapter = new SqlDataAdapter(command)) + { + var table = new DataTable(); + adapter.Fill(table); + return table; + } + } + } + + public static TValue Get(this IDataRecord record, string fieldName) + { + try + { + //if ((record[fieldName] == DBNull.Value) && (typeof(TValue) == typeof(object)) ) return (TValue)null; + + return (TValue)record[fieldName]; + } + catch (InvalidCastException ex) + { + throw new InvalidCastException(string.Format("{2} {0} нельзя привести к {1}", record[fieldName].GetType().FullName, typeof(TValue).FullName, fieldName), ex); + } + } + public static Decimal GetDecimalX(this IDataRecord record, string fieldName) + { + Decimal rret = 0; + if (record[fieldName] != DBNull.Value) + { + if (record[fieldName].GetType().Name == "Int32") + { + rret = Convert.ToDecimal(record[fieldName]); + } + else + { + rret = record.Get (fieldName); + } + } + + return rret; + } + + public static string CreateParameterListString(this IEnumerable> parameters) + { + return string.Join(", ", parameters.Select(x => x.Key).ToArray()); + } + + + public static TValue GetN(this IDataRecord record, string fieldName) + { + return record[fieldName] != DBNull.Value ? record.Get(fieldName) : default(TValue); + } + + public static IEnumerable ExecuteSelectMany(this string connectionString, string sql, + bool isStoredProcedure = false, IEnumerable> parameters = null, + params Action>[] converters) + { + if (converters == null) throw new ArgumentNullException("converters"); + + SqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + command.CommandTimeout = command.Connection.ConnectionTimeout; + using (command.Connection) + { + command.Connection.Open(); + using (IDataReader reader = command.ExecuteReader()) + { + IList selectResult = new List(); + foreach (var converter in converters) + { + while (reader.Read()) + { + converter(reader, selectResult); + } + reader.NextResult(); + } + + return selectResult; + } + } + } + + public static IEnumerableWithPage ExecuteSelectManyWithPage( + this string connectionString, + string sql, + bool isStoredProcedure = false, + IEnumerable> parameters = null, + params Action>[] converters + ) + { + if (converters == null) throw new ArgumentNullException("converters"); + + SqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + command.CommandTimeout = command.Connection.ConnectionTimeout; + using (command.Connection) + { + command.Connection.Open(); + using (IDataReader reader = command.ExecuteReader()) + { + var result = new ListWithPage(); + foreach (var converter in converters) + { + while (reader.Read()) + { + converter(reader, result); + } + reader.NextResult(); + } + + // считывание данных постраничного вывода + while (reader.Read()) + { + if (reader.HasColumn("TotalRows")) result.TotalRows = reader.Get("TotalRows"); + if (reader.HasColumn("PageNo")) result.PageNo = reader.Get("PageNo"); + if (reader.HasColumn("PageSize")) result.PageSize = reader.Get("PageSize"); + if (reader.HasColumn("Sort")) result.Sort = reader.Get("Sort"); + } + + return result; + } + } + } + + public static IEnumerableWithOffer ExecuteSelectManyWithOffer( + this string connectionString, + string sql, + bool isStoredProcedure = false, + IEnumerable> parameters = null, + params Action>[] converters + ) + { + if (converters == null) throw new ArgumentNullException("converters"); + + SqlCommand command = connectionString.CreateCommand(sql, isStoredProcedure, parameters); + command.CommandTimeout = command.Connection.ConnectionTimeout; + using (command.Connection) + { + command.Connection.Open(); + using (IDataReader reader = command.ExecuteReader()) + { + var result = new ListWithOffer(); + foreach (var converter in converters) + { + while (reader.Read()) + { + converter(reader, result); + } + reader.NextResult(); + } + + // считывание данных постраничного вывода + while (reader.Read()) + { + if (reader.HasColumn("TotalRows")) result.TotalRows = reader.Get("TotalRows"); + if (reader.HasColumn("PageNo")) result.Start = reader.Get("Start"); + if (reader.HasColumn("PageSize")) result.Lenght = reader.Get("Length"); + if (reader.HasColumn("Sort")) result.Sort = reader.Get("Sort"); + } + + return result; + } + } + } + + #region Add ----------------------------------------------------------- + + + + #endregion + + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Db/SqlServerHelperFluent.cs b/3-app/1-lib/Kit.Helpers/Db/SqlServerHelperFluent.cs new file mode 100644 index 0000000..d060ce4 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Db/SqlServerHelperFluent.cs @@ -0,0 +1,162 @@ +namespace Kit.Helpers.MsSql +{ + using System; + using System.Collections.Generic; + using System.Data; + + public class SqlServerHelperFluent + { + protected readonly string _connectionString; + protected readonly string _sqlText; + protected bool _isStoredProcedure; + protected bool _isOutputParameter; + protected List> _parameters; + + public SqlServerHelperFluent(string connectionString, string sqlText) + { + _connectionString = connectionString; + _sqlText = sqlText; + _isStoredProcedure = true; + _isOutputParameter = false; + _parameters = new List>(); + } + + #region fluent methods + + public SqlServerHelperFluent AsStoredProcedure() + { + _isStoredProcedure = true; + return this; + } + + public SqlServerHelperFluent AsSqlText() + { + _isStoredProcedure = false; + return this; + } + + public SqlServerHelperFluent AddParameter(string name, object value) + { + _parameters.Add(new KeyValuePair(name, value)); + return this; + } + public SqlServerHelperFluent AddParameter(string name, string value) + { + + _parameters.Add(new KeyValuePair(name, value ?? string.Empty)); + return this; + } + + public SqlServerHelperFluent AddParameterNullable(string name, object value) + { + _parameters.Add(new KeyValuePair(name, value ?? DBNull.Value)); + return this; + } + + public SqlServerHelperFluent AddOutputParameter(string name, object value) + { + _parameters.Add(new KeyValuePair(name, new OutPutValue { Value = value ?? DBNull.Value })); + return this; + } + + public SqlServerHelperFluent AddParameters(IEnumerable> parameters) + { + _parameters.AddRange(parameters); + return this; + } + + #endregion + + #region finish methods + + public void ExecuteNonQuery() + { + _connectionString.ExecuteNonQuery(_sqlText, isStoredProcedure: _isStoredProcedure, parameters: _parameters); + } + + public TResult ExecuteScalar() + { + return _connectionString.ExecuteScalar(_sqlText, isStoredProcedure: _isStoredProcedure, parameters: _parameters); + } + + #endregion + } + + public class SqlServerHelperFluent : SqlServerHelperFluent + { + protected List>> _converters; + + public SqlServerHelperFluent(string connectionString, string sqlText) : base (connectionString, sqlText) + { + _converters = new List>>(); + } + + #region base fluent methods + + public new SqlServerHelperFluent AddParameter(string name, object value) + { + + base.AddParameter(name, value); + return this; + } + public new SqlServerHelperFluent AddParameterNullable(string name, object value) + { + + base.AddParameterNullable(name, value); + return this; + } + + public new SqlServerHelperFluent AddParameters(IEnumerable> parameters) + { + base.AddParameters(parameters); + return this; + } + + public new SqlServerHelperFluent AsStoredProcedure() + { + base.AsStoredProcedure(); + return this; + } + + public new SqlServerHelperFluent AsSqlText() + { + base.AsSqlText(); + return this; + } + + #endregion + + public SqlServerHelperFluent AddConverter(Action> converter) + { + _converters.Add(converter); + return this; + } + + public IEnumerable ExecuteSelectMany() + { + return _connectionString.ExecuteSelectMany(_sqlText, isStoredProcedure: _isStoredProcedure, parameters: _parameters, converters: _converters.ToArray()); + } + + public IEnumerableWithPage ExecuteSelectManyWithPage() + { + return _connectionString.ExecuteSelectManyWithPage(_sqlText, isStoredProcedure: _isStoredProcedure, parameters: _parameters, converters: _converters.ToArray()); + } + public IEnumerableWithOffer ExecuteSelectManyWithOffer() + { + return _connectionString.ExecuteSelectManyWithOffer(_sqlText, isStoredProcedure: _isStoredProcedure, parameters: _parameters, converters: _converters.ToArray()); + } + } + + public static class SqlServerHelperFluentExtentions + { + public static SqlServerHelperFluent PrepareExecute(this string connectionString, string sqlText) + { + return new SqlServerHelperFluent(connectionString, sqlText); + } + + public static SqlServerHelperFluent PrepareExecute(this string connectionString, string sqlText) + { + return new SqlServerHelperFluent(connectionString, sqlText); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Exception/ExceptionHelper.cs b/3-app/1-lib/Kit.Helpers/Exception/ExceptionHelper.cs new file mode 100644 index 0000000..e098f47 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Exception/ExceptionHelper.cs @@ -0,0 +1,54 @@ +namespace Kit.Helpers +{ + using System; + using System.Text; + + public static class ExceptionHelper_Extensions + { + public static string GetInfoAsPlainText(this Exception ex) + { + if (ex == null) return string.Empty; + StringBuilder sb = new StringBuilder(); + sb.AppendFormat("Source: {0}", ex.Source); sb.AppendLine(); + sb.AppendFormat("Message: {0}", ex.Message); sb.AppendLine(); + sb.AppendFormat("StackTrace: {0}", ex.StackTrace); sb.AppendLine(); + if (ex.InnerException != null) + { + sb.AppendLine("InnerException:"); + sb.Append(ex.InnerException.GetInfoAsPlainText()); + } + + return sb.ToString(); + } + + public static string GetInfoAsHtml(this Exception ex) + { + return ex.GetInfoAsPlainText().Replace(Environment.NewLine, "
"); + } + } + + /// + /// Settings for ExceptionToStringSettings/> + /// + [Flags] + public enum ExceptionToStringSettings + { + /// + /// Show message + /// + ShowMessageAndInnerExceptionMessages = 1, + + /// + /// Show assembly information + /// + ShowAssemblyInformation = 2, + + /// + /// Show sepparators + /// + ShowSeparators = 4, + + //All + All = ShowMessageAndInnerExceptionMessages | ShowAssemblyInformation | ShowSeparators + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Extension/Entities/IIdTitle.cs b/3-app/1-lib/Kit.Helpers/Extension/Entities/IIdTitle.cs new file mode 100644 index 0000000..962344f --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/Entities/IIdTitle.cs @@ -0,0 +1,99 @@ +using System; + +namespace Kit.Helpers.Extension.Entities +{ + public interface IIdTitle + { + int Id { get; set; } + string Title { get; set; } + } + public class IdTitle : IInt32Id, IEquatable, IIdTitle + { + public IdTitle() { } + + public IdTitle(int id, string title) : this() + { + this.Id = id; + this.Title = title; + } + + public virtual int Id { get; set; } + public virtual string Title { get; set; } + + #region Empty + + public static readonly int EmptyId = -1; + public static readonly string EmptyTitle = "n/a"; + + public static TEntity GetEmpty() + where TEntity : IdTitle, new() + { + return new TEntity + { + Id = EmptyId, + Title = EmptyTitle + }; + } + + public static IdTitle GetEmpty() + { + return GetEmpty(); + } + + #endregion + + /// + /// Determines whether the specified is equal to the current . + /// + /// + /// true if the specified is equal to the current ; otherwise, false. + /// + /// The to compare with the current . 2 + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (!(obj is IdTitle)) return false; + return Equals((IdTitle)obj); + } + + /// + /// Indicates whether the current object is equal to another object of the same type. + /// + /// + /// true if the current object is equal to the parameter; otherwise, false. + /// + /// An object to compare with this object. + public bool Equals(IdTitle other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return other.Id == Id && Equals(other.Title, Title); + } + + /// + /// Serves as a hash function for a particular type. + /// + /// + /// A hash code for the current . + /// + /// 2 + public override int GetHashCode() + { + unchecked + { + return (Id * 397) ^ (Title != null ? Title.GetHashCode() : 0); + } + } + + public static bool operator ==(IdTitle left, IdTitle right) + { + return Equals(left, right); + } + + public static bool operator !=(IdTitle left, IdTitle right) + { + return !Equals(left, right); + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Extension/Entities/IIdTitleParent.cs b/3-app/1-lib/Kit.Helpers/Extension/Entities/IIdTitleParent.cs new file mode 100644 index 0000000..40478f3 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/Entities/IIdTitleParent.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace Kit.Helpers.Extension.Entities +{ + public interface IIdTitleParent + { + string Title { get; set; } + TEntity Parent { get; set; } + int? ParentId { get; set; } + + int Level { get; set; } + bool IsFirst { get; set; } + bool IsLast { get; set; } + + IEnumerable Childs { get; set; } + } + + public class IdTitleParent : IdTitle, IIdTitleParent + { + public int? ParentId { get; set; } + public TEntity Parent { get; set; } + public int Level { get; set; } + public bool IsFirst { get; set; } + public bool IsLast { get; set; } + + public IEnumerable Childs { get; set; } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Extension/Entities/IInt32Id.cs b/3-app/1-lib/Kit.Helpers/Extension/Entities/IInt32Id.cs new file mode 100644 index 0000000..0b5665b --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/Entities/IInt32Id.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace Kit.Helpers.Extension.Entities +{ + public interface IInt32Id + { + int Id { get; } + } + + public static class IInt32IdExtentions + { + public static IDictionary ConvertToDictionary(this IEnumerable objects) + where TObject : IInt32Id + { + var dictionary = new Dictionary(); + { + foreach (var obj in objects) dictionary.Add(obj.Id, obj); + } + return dictionary; + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Extension/Entities/IdParent.cs b/3-app/1-lib/Kit.Helpers/Extension/Entities/IdParent.cs new file mode 100644 index 0000000..5317741 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/Entities/IdParent.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; + +namespace Kit.Helpers.Extension.Entities +{ + public static class IdParentExtension + { + public static string BuildPath(this IIdParent item) + { + if (item != null) + { + return item.Parent.BuildPath().AddSufixIsNotEmpty(".") + item.Id.ToString("x9"); + } + return string.Empty; + } + + public static int BuildLevel(this IIdParent item) + { + if (item != null) + { + return item.Parent.BuildLevel() + 1; + } + return 0; + } + } + + + public interface IIdParent + { + int Id { get; set; } + IIdParent Parent { get; set; } + int? ParentId { get; set; } + int Level { get; } + string Path { get; } + IEnumerable Childs { get; set; } + } + + public class IdParent : IIdParent + { + private string _path; + private int _lvl; + public int Id { get; set; } + public IIdParent Parent { get; set; } + public int? ParentId { get; set; } + public int Level + { + get + { + if (_lvl == 0) + { + _lvl = this.BuildLevel(); + } + + return _lvl; + } + } + public IEnumerable Childs { get; set; } + public string Path + { + get + { + if (_path.IsNullOrEmpty()) + { + _path = this.BuildPath(); + } + + return _path; + } + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Extension/Entities/IdTitleExtensions.cs b/3-app/1-lib/Kit.Helpers/Extension/Entities/IdTitleExtensions.cs new file mode 100644 index 0000000..70a3246 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/Entities/IdTitleExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Kit.Helpers.Extension.Entities +{ + public static class IdTitleExtensions + { + public static IEnumerable AddSelectListItem(this IEnumerable selectListItems, string title, string value, bool selected = false, bool insertFirst = true) + { + var selectListItem = new SelectListItem { Text = title, Value = value, Selected = selected }; + + var list = selectListItems as IList + ?? selectListItems.ToList(); + + if (insertFirst) + { + list.Insert(0, selectListItem); + } + else + { + list.Add(selectListItem); + } + + return list; + } + + public static IEnumerable AddSelectListItem(this IEnumerable selectListItems, string title, object value, bool selected = false, bool insertFirst = true) + { + return selectListItems.AddSelectListItem(title, value.ToString(), selected, insertFirst); + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Extension/Microsoft.AspNetCore.Http.Session.cs b/3-app/1-lib/Kit.Helpers/Extension/Microsoft.AspNetCore.Http.Session.cs new file mode 100644 index 0000000..b15122b --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/Microsoft.AspNetCore.Http.Session.cs @@ -0,0 +1,34 @@ +using System.Text; +using Microsoft.AspNetCore.Http; + +namespace Kit.Helpers +{ + public static class MicrosoftAspNetCoreHttpSessionExtentions + { + public static T GetValue(this ISession session, string key) + { + string json = session.GetString(key); + return json.JsonDeserialize(); + } + + public static T GetValueOrDefault(this ISession session, string key) + { + string json = session.GetString(key); + T result; + try + { + result = json.JsonDeserialize(); + } + catch + { + return default; + } + return result; + } + + public static void SetValue(this ISession session, string key, object obj) + { + session.SetString(key, obj.JsonSerialize()); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Extension/System.Collections.cs b/3-app/1-lib/Kit.Helpers/Extension/System.Collections.cs new file mode 100644 index 0000000..3bb18c5 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/System.Collections.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Text; + +namespace Kit.Helpers +{ + public static class SystemCollectionExtensionMethods + { + public static IList NullToEmpty(this IList list) + { + return list ?? new List(); + } + + public static IEnumerable NullToEmpty(this IEnumerable list) + { + return list ?? new List(); + } + + public static IList AddRange(this IList list, IEnumerable items) + { + if (items == null) return list; + foreach (TItem item in items) list.Add(item); + return list; + } + + public static IList AddRangeEx(this IList list, IEnumerable items) + { + if (items == null) return list; + foreach (TItem item in items) list.Add(item); + return list; + } + + public static IList AddEx(this IList list, TItem item) + { + list.Add(item); + return list; + } + + public static IEnumerable AddToNewList(this IEnumerable list, TItem item) + { + return list.ToList().AddEx(item); + } + + public static IDictionary AddRange(this IDictionary target, IDictionary source) + { + foreach (var item in source) target.SetByKey(item.Key, item.Value); + return target; + } + + public static bool IsNullOrEmpty(this IEnumerable collection) + { + return (collection == null || !collection.Any()); + } + + public static TValue GetByKey(this IDictionary dictionary, TKey key, TValue defaultValue) + { + return dictionary != null && dictionary.ContainsKey(key) + ? dictionary[key] + : defaultValue; + } + + public static TValue GetByKey(this IDictionary dictionary, TKey key) + { + return dictionary.GetByKey(key, default(TValue)); + } + + public static TValue SetByKey(this IDictionary dictionary, TKey key, TValue value) + { + if (dictionary == null) throw new ArgumentNullException("dictionary"); + lock (dictionary) + { + if (!dictionary.ContainsKey(key)) dictionary.Add(key, value); + else dictionary[key] = value; + } + return value; + } + + public static object CastToTypedEnumerable(this IEnumerable collection, Type targetType) + { + var resultListType = typeof(List<>).MakeGenericType(targetType); + var resultList = (IList)Activator.CreateInstance(resultListType); + foreach (var collectionItem in collection) resultList.Add(collectionItem); + return resultList; + } + + public static Dictionary ParseToDictionaryString(this NameValueCollection collection) + { + Dictionary result = new Dictionary(); + foreach (string item in collection.Keys) + { + result.Add(item, collection[item]); + } + return result; + } + + public static string ParseToString(this NameValueCollection collection, char parameterSeparator, char nameValueSeparator) + { + StringBuilder sb = new StringBuilder(); + foreach (string item in collection.Keys) + { + sb.AppendFormat("{0}{1}{2}{3}",item,nameValueSeparator,collection[item],parameterSeparator); + } + return sb.ToString(); + } + + public static void Add(this NameValueCollection collection, IEnumerable> items) + { + foreach (KeyValuePair kv in items) + { + if (Equals(kv.Key, default(TKey))) continue; + string value = Equals(kv.Value, default(TValue)) ? string.Empty : kv.Value.ToString(); + collection.Add(kv.Key.ToString(), value); + } + } + + public static NameValueCollection ToNameValueCollection(this IEnumerable> kvpCollection) + { + NameValueCollection collection = new NameValueCollection(); + collection.Add(kvpCollection); + return collection; + } + public static string Join(this string[] array, string separator) + { + return string.Join(separator, array); + } + public static string Join(this string[] array) + { + return array.Join(","); + } + public static string Join(this IEnumerable collection, string separator) + { + if (collection == null) return null; + IEnumerable stringCollection = collection.Select(x => x.ToString()); + + + //(from o in collection + // where Equals(o, default(TObject)) + // select o.ToString()); + return stringCollection.ToArray().Join(separator); + } + public static string Join(this IEnumerable collection) + { + return collection.Join(","); + } + public static IEnumerable> ToKeyValuePairs(this NameValueCollection nvc) + { + List> kvc = new List>(); + if (nvc == null) return kvc; + foreach(string key in nvc.Keys) kvc.Add(new KeyValuePair(key, nvc[key])); + return kvc; + } + public static IEnumerable Random(this IEnumerable collection, int limit = -1) + { + if (collection == null) return null; + var random = new Random(); + collection = collection.OrderBy(o => random.Next()); + if (limit > -1) collection = collection.Take(limit); + return collection; + } + + public static IEnumerable ForEach(this IEnumerable collection, Action action) + { + if (collection == null) return null; + if (action == null) return collection; + IEnumerable forEach = collection as IList ?? collection.ToList(); + foreach (T element in forEach) action(element); + return forEach; + } + + public static IEnumerable InsertFirstToNewList(this IEnumerable list, TItem item) + { + var lists = list.ToList(); + lists.Insert(0, item); + return lists; + } + + + public static List SearchBytePattern(this byte[] Source, byte[] Pattern, int Start) + { + int SourceLen = Source.Length - Pattern.Length + 1;//Получаем длину исходного массива. + int j; + var positions = new List();//Переменная для хранения списка результатов поиска. + + for (int i = Start; i < SourceLen; i++) + { + if (Source[i] != Pattern[0]) continue;//Сравниваем первый искомый байт и если он совпадает, то проверяем остальные байты за ним идущие, иначе идем дальше по массиву. + for (j = Pattern.Length - 1; j >= 1 && Source[i + j] == Pattern[j]; j--) ;//Сравниваем остальные байты с нашим значением. + if (j == 0)//Переменная будет равна нулю, если все байты совпали + { + positions.Add(i);//Добавляем адрес конца совпадения первого байта в список + i += Pattern.Length - 1;//Увеличиваем значение переменной на величину искомого, так как мы его уже проверили и это ускоряет поиск. + } + } + return positions;//Отдаем список адресов, которые совпадают с искомым значением. + } + + public static bool IsEnumerable(this object obj) + { + return typeof(IEnumerable).IsAssignableFrom(obj.GetType()); + } + + public static bool IsList(this object obj) + { + return typeof(IList).IsAssignableFrom(obj.GetType()); + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Extension/System.DateTime.cs b/3-app/1-lib/Kit.Helpers/Extension/System.DateTime.cs new file mode 100644 index 0000000..17b4c42 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/System.DateTime.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Kit.Helpers +{ + public class WeekInfo + { + public int NumberOfYear { get; set; } + public DateTime StartDate { get; set; } + public DateTime EndDate { get; set; } + } + public static partial class SystemDateTimeExtensionMethods + { + public static string ToStringDateRus(this DateTime dt) + { + if (dt == null) return String.Empty; + return dt.ToString("dd.MM.yyyy"); + } + + public static string ToStringDateTimeRus(this DateTime dt) + { + if (dt == null) return String.Empty; + return dt.ToString("dd.MM.yyyy hh:mm"); + } + + public static string ToStringTimeRus(this DateTime dt) + { + if (dt == null) return String.Empty; + return dt.ToString("hh:mm:ss"); + } + + + public static string ToStringDateRus(this DateTime? dt) + { + if (dt == null) return String.Empty; + return dt.Value.ToString("dd.MM.yyyy"); + } + + public static string ToStringDateTimeRus(this DateTime? dt) + { + if (dt == null) return String.Empty; + return dt.Value.ToString("dd.MM.yyyy hh:mm"); + } + + public static string ToStringTimeRus(this DateTime? dt) + { + if (dt == null) return String.Empty; + return dt.Value.ToString("hh:mm:ss"); + } + + private static CultureInfo cultureRu = new System.Globalization.CultureInfo("ru-Ru"); + public static string GetStringMonthYear(this DateTime date) + { + return date.ToString("Y"); + } + //public static DateTime Normolize(this DateTime currentValue) + //{ + // return currentValue.Normolize(DateTime.MinValue, DateTime.Now); + //} + + public static DateTime GetClearTime(this DateTime date) + { + return new DateTime(date.Year, date.Month, date.Day); + } + + private static readonly long DatetimeMinTimeTicks = + (new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).Ticks; + + public static long ToJavaScriptMilliseconds(this DateTime dt) + { + return (long)((dt.ToUniversalTime().Ticks - DatetimeMinTimeTicks) / 10000); + } + + + public static DateTime GetUtcFromDateTime(this DateTime dt, string timezene) + { + TimeZoneInfo tzf; + try + { + tzf = TimeZoneInfo.FindSystemTimeZoneById(timezene); + } + catch + { + tzf = TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"); + } + return TimeZoneInfo.ConvertTimeToUtc(dt, tzf); + } + + public static DateTime GetDateTimeFromUtc(this DateTime dt, string timezene) + { + TimeZoneInfo tzf; + try + { + tzf = TimeZoneInfo.FindSystemTimeZoneById(timezene); + } + catch + { + tzf = TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time"); + } + return TimeZoneInfo.ConvertTimeFromUtc(dt, tzf); + } + + + public static string ToStringOrEmpty(this DateTime? dt, string format) + { + if (dt == null) return String.Empty; + return dt.Value.ToString(format); + } + + public static string ToStringOrEmpty(this DateTime? dt) + { + if (dt == null) return String.Empty; + return dt.Value.ToString("dd.MM.yyyy"); + } + + public static string ToShortDateStringRu(this DateTime value) + { + if (value == null) return String.Empty; + return value.ToString("dd.MM.yyyy"); + } + + public static string ToShortDateStringUniversal(this DateTime value) + { + if (value == null) return String.Empty; + return value.ToString("yyyy.MM.dd"); + } + + public static string ToStringDMYHMS(this DateTime value) + { + if (value == null) return String.Empty; + return value.ToString("dd.MM.yyyy hh:mm:ss"); + } + + public static WeekInfo GetInfoWeek(this DateTime? value) + { + if (!value.HasValue) return null; + return value.Value.GetInfoWeek(); + } + + public static WeekInfo GetInfoWeek(this DateTime value) + { + var result = new WeekInfo(); + DateTimeFormatInfo dfi = cultureRu.DateTimeFormat; + Calendar cal = dfi.Calendar; + + result.NumberOfYear = cal.GetWeekOfYear(value, CalendarWeekRule.FirstFullWeek, DayOfWeek.Monday); + + var dtStart = new DateTime(value.Year, 1, 1).AddDays(-(int)(new DateTime(value.Year, 1, 1).DayOfWeek - 1)); + result.StartDate = dtStart.AddDays((result.NumberOfYear - 1) * 7); + result.EndDate = dtStart.AddDays(result.NumberOfYear * 7); + + return result; + } + + public static string GetMonthNameRP(this DateTime dt) + { + return dt.ToString("MMMMMMMMMMM", cultureRu).Replace("т", "та").Replace("тая", "тя").Replace("й", "я").Replace("ь", "я"); + } + + } +} diff --git a/3-app/1-lib/Kit.Helpers/Extension/System.Integer.cs b/3-app/1-lib/Kit.Helpers/Extension/System.Integer.cs new file mode 100644 index 0000000..794a111 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/System.Integer.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace Kit.Helpers +{ + public static class SystemIntegerExtensionMethods + { + public static string ToMonthName(this int monthNo) + { + if (monthNo < 1 || monthNo > 12) { throw new ArgumentOutOfRangeException("monthNo", monthNo, "monthNo must be one of 1..12"); } + var dateTimeFormat = new CultureInfo("ru-RU").DateTimeFormat; + return dateTimeFormat.GetMonthName(monthNo); + } + public static string ToMonthNamea(this int monthNo) + { + if (monthNo < 1 || monthNo > 12) { throw new ArgumentOutOfRangeException("monthNo", monthNo, "monthNo must be one of 1..12"); } + var monthNames = new string[] { + "января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря" + }; + return monthNames[monthNo - 1]; + } + + + public static string Join(this IEnumerable collection, string separator) + { + if (collection == null) return null; + IEnumerable stringCollection = collection.Select(x => x.ToString()); + return stringCollection.ToArray().Join(separator); + } + + public static bool InRange(this int value, int rangeStart, int rangeEnd) + { + return value >= rangeStart && value <= rangeEnd; + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Extension/System.Object.cs b/3-app/1-lib/Kit.Helpers/Extension/System.Object.cs new file mode 100644 index 0000000..c4c3a04 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/System.Object.cs @@ -0,0 +1,180 @@ +using System.Collections.Specialized; +using System.Security.Cryptography; + +namespace Kit.Helpers +{ + public static partial class SystemObjectExtensionMethods + { + public static string ToJSON(this object obj) + { + return System.Text.Json.JsonSerializer.Serialize(obj); + } + + + public static TObject ToObjectFromJSON(this string json) + { + return System.Text.Json.JsonSerializer.Deserialize(json); + } + + + + public static void SetProperties(this object obj, NameValueCollection properties) + { + Type objectType = obj.GetType(); + foreach (string name in properties.Keys) + { + var property = objectType.GetProperty(name); + object propertyValue = property.PropertyType.IsEnum + ? Enum.Parse(property.PropertyType, properties[name]) + : System.Convert.ChangeType(properties[name], property.PropertyType); + property.SetValue(obj, propertyValue, null); + } + } + + public static void SetProperties(this object obj, Dictionary properties) + { + Type objectType = obj.GetType(); + foreach (string name in properties.Keys) + { + var property = objectType.GetProperty(name); + object propertyValue = properties[name]; + property.SetValue(obj, propertyValue, null); + } + } + + public static object GetPropertyValue(this object thisObject, string propertyPath) + { + if (thisObject == null) return null; + + string[] propertyNames = propertyPath.Split('.'); + foreach (var propertyName in propertyNames) + { + var propertyInfo = thisObject.GetType().GetProperty(propertyName); + if (propertyInfo == null) throw new ArgumentOutOfRangeException("PropertyName", propertyName, "Property not found."); + thisObject = propertyInfo.GetValue(thisObject, null); + } + return thisObject; + } + + public static T Normolize(this T currentValue, T minValue, T defaultValue) + where T : IComparable + { + return currentValue.CompareTo(minValue) > 0 ? currentValue : defaultValue; + } + + public static T CreateClone(this T thisObject) + where T : ICloneable + { + return (T)thisObject.Clone(); + } + + public static IEnumerable AscendantsOrSelf(this T obj, Func GetParent) where T : class + { + if (obj == null) throw new ArgumentNullException("obj"); + if (GetParent == null) throw new ArgumentNullException("GetParent"); + + yield return obj; + T parent = GetParent(obj); + while (parent != null) + { + yield return parent; + parent = GetParent(parent); + } + } + + public static string ToStringOrEmpty(this object obj) + { + if (obj == null) return String.Empty; + return obj.ToString(); + } + + /// + /// Шифрует исходное сообщение AES ключом (добавляет соль) + /// + /// + /// + public static byte[] ToAes256(this string src, byte[] aeskey) + { + //Объявляем объект класса AES + Aes aes = Aes.Create(); + //Генерируем соль + aes.GenerateIV(); + //Присваиваем ключ. aeskey - переменная (массив байт), сгенерированная методом GenerateKey() класса AES + aes.Key = aeskey; + byte[] encrypted; + ICryptoTransform crypt = aes.CreateEncryptor(aes.Key, aes.IV); + using (MemoryStream ms = new MemoryStream()) + { + using (CryptoStream cs = new CryptoStream(ms, crypt, CryptoStreamMode.Write)) + { + using (StreamWriter sw = new StreamWriter(cs)) + { + sw.Write(src); + } + } + //Записываем в переменную encrypted зашиврованный поток байтов + encrypted = ms.ToArray(); + } + //Возвращаем поток байт + крепим соль + return encrypted.Concat(aes.IV).ToArray(); + } + + public static string FromAes256(this byte[] shifr, byte[] aeskey) + { + byte[] bytesIv = new byte[16]; + byte[] mess = new byte[shifr.Length - 16]; + //Списываем соль + for (int i = shifr.Length - 16, j = 0; i < shifr.Length; i++, j++) + bytesIv[j] = shifr[i]; + //Списываем оставшуюся часть сообщения + for (int i = 0; i < shifr.Length - 16; i++) + mess[i] = shifr[i]; + //Объект класса Aes + Aes aes = Aes.Create(); + //Задаем тот же ключ, что и для шифрования + aes.Key = aeskey; + //Задаем соль + aes.IV = bytesIv; + //Строковая переменная для результата + string text = ""; + byte[] data = mess; + ICryptoTransform crypt = aes.CreateDecryptor(aes.Key, aes.IV); + using (MemoryStream ms = new MemoryStream(data)) + { + using (CryptoStream cs = new CryptoStream(ms, crypt, CryptoStreamMode.Read)) + { + using (StreamReader sr = new StreamReader(cs)) + { + //Результат записываем в переменную text в вие исходной строки + text = sr.ReadToEnd(); + } + } + } + return text; + + } + + public static byte[] ReadAllBytes(this Stream instream) + { + if (instream is MemoryStream) + return ((MemoryStream)instream).ToArray(); + + using (var memoryStream = new MemoryStream()) + { + instream.CopyTo(memoryStream); + return memoryStream.ToArray(); + } + } + + public static string ReadFileAes256(this Stream stream,byte[] aeskey) + { + return ReadAllBytes(stream).FromAes256(aeskey); + } + + public static void WriteFileAes256(this Stream stream, string data, byte[] aeskey) + { + byte[] dataBytes = data.ToAes256(aeskey); + stream.Write(dataBytes, 0, dataBytes.Length); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Extension/System.String.cs b/3-app/1-lib/Kit.Helpers/Extension/System.String.cs new file mode 100644 index 0000000..f91d367 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/System.String.cs @@ -0,0 +1,1476 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Web; +using System.Data; +using System.IO; +using System.IO.Compression; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Http; + +namespace Kit.Helpers +{ + public static partial class SystemStringExtensionMethods + { + private static readonly CultureInfo ci = new CultureInfo("en-US"); + + #region преобразования строк + /// + /// Вставляем подстроку в сроку + /// + /// исходная строка + /// куда вставлять + /// строка для вставки + /// количество повторений при вставке + /// преобразованная строка + public static string Insert(this string thisString, int startIndex, string value, int count) + { + StringBuilder sb = new StringBuilder(thisString); + sb.Insert(startIndex, value, count); + return sb.ToString(); + } + /// + /// Применяем функцию String.Format к текущей строке + /// + /// строка с форматом + /// аргументы преобразования + /// преобразованная строка + public static string ApplyFormat(this string formatString, params object[] args) + { + if (formatString.IsNullOrEmpty()) + return String.Empty; + return String.Format(formatString, args); + } + /// + /// Применяем функция String.Format к параметру, используя текущую строку как аргумент + /// + /// текущая строка - аргумент операции + /// строка форматирования + /// преобразованная строка + public static string ApplyToFormat(this string thisString, string formatString) + { + return String.Format(formatString, thisString); + } + /// + /// Проверка строки на null или пустое значение + /// + /// Строка для проверки + /// true - если строка null или пустая, иначе false + public static bool IsNullOrEmpty(this string thisString) + { + return String.IsNullOrEmpty(thisString); + } + /// + /// Замена исходной строки на другую, если она пустая или null + /// + /// Проверяемая строка + /// Строка для замены + /// строка для замены, если исходная строка равна null или пустая, иначе исходная строка + public static string IfNullOrEmpty(this string thisString, string otherString) + { + return !String.IsNullOrEmpty(thisString) ? thisString : otherString; + } + /// + /// Удаление подстроки + /// + /// Строка в которой идет поиск + /// Подстрока, которую необходимо удалить + /// Преобразованная строка + public static string Remove(this string input, string remove) + { + if (input == null) return null; + return input.Replace(remove, String.Empty); + } + + /// + /// Обрезка строки по символу. + /// + /// Строка которую обрезаем + /// Максимальная длинна строки + /// Максимальная длинна строки + /// Обрезанная строка + public static string CutString(this string input, int lenght, char end) + { + string result = input; + if (lenght < input.Length) + { + string tmpstr = input.Substring(0, lenght); + string[] arrstr = tmpstr.Split(end); + if (lenght > 1) + result = arrstr.Take(arrstr.Length - 1).ToArray().Join(end.ToString()) + end.ToString(); + } + return result; + } + #endregion + + #region Методы для перечислений + /// + /// Разбираем строку в элемент перечисления + /// + /// тип перечисления + /// строка + /// игнорировать регистр + /// типизированный элемент перечисления + public static T ParseToEnum(this string value, bool ignoreCase) + { + return (T)Enum.Parse(typeof(T), value.Trim(), ignoreCase); + } + /// + /// Разбираем строку в элемент перечисления + /// с учетом регистра + /// + /// тип перечисления + /// строка + /// типизированный элемент перечисления + public static T ParseToEnum(this string value) + { + return value.ParseToEnum(true); + } + + public static T ParseToEnum(this string value, bool ignoreCase, T defaultValue) + { + try + { + return (T)Enum.Parse(typeof(T), value.Trim(), ignoreCase); + } + catch(Exception) + { + return defaultValue; + } + } + + #endregion + + #region StringBuilder + + public static StringBuilder AppendFormatLine(this StringBuilder sb, string formatString, params object[] args) + { + if (!formatString.IsNullOrEmpty()) sb.AppendLine(formatString.ApplyFormat(args)); + return sb; + } + + #endregion + + + public static int ParseIPToInt32(this string ipString) + { + int ipInt32 = 0; + int q = 1; + foreach (string part in ipString.Split('.').Reverse()) + { + ipInt32 += Int32.Parse(part) * q; + q *= 256; + } + return ipInt32; + } + + public static string CleanFromHtmlTags(this string text) + { + if (text == null) return null; + text = text.Replace("&", "&"); + text = text.Replace(" ", " "); + text = text.Replace("«", "\""); + text = text.Replace("»", "\""); + text = text.Replace("–", "-"); + text = text.Replace("―", "-"); + + text = Regex.Replace(text, @"\x26\S*?\x3B", " ", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);//&...; + text = Regex.Replace(text, @"\<\s*?p\s*?.*?\>", " ", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);//

+ text = Regex.Replace(text, @"\<\s*?br\s*?.*?\>", "\n", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);//

+ text = Regex.Replace(text, @"\<(.+?)\>", " ", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);// <...> + text = Regex.Replace(text, @"\", " ", RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Compiled);// + return text; + } + + public static string ToFirstSymbolUppper(this string input) + { + string output = string.Empty; + if (input != null && input.Length > 0) + { + output = input.Substring(0, 1).ToUpper(); + if (input.Length > 1) output += input.Substring(1, input.Length - 1); + } + return output; + } + + public static string ToFirstSymbolUppperOnly(this string input) + { + string output = string.Empty; + if (input != null && input.Length > 0) + { + output = input.Substring(0, 1).ToUpper(); + } + return output; + } + + + #region преобразования типов + + public static Int32? TryParseToInt32Nulable(this string input) + { + Int32 intValue; + if (!Int32.TryParse(input, out intValue)) return null; + return intValue; + } + + public static Int32 TryParseToInt32(this string input) + { + Int32 intValue = 0; + Int32.TryParse(input, out intValue); + return intValue; + } + + public static Int32 TryParseToInt32(this string input, Int32 defaultValue) + { + int result; + if(Int32.TryParse(input, out result)) return result; + return defaultValue; + } + public static UInt32 TryParseToUInt32(this string input) + { + UInt32 intValue = 0; + UInt32.TryParse(input, out intValue); + return intValue; + } + public static UInt32 TryParseToUInt32(this string input, UInt32 defaultValue) + { + uint result; + if (UInt32.TryParse(input, out result)) return result; + return defaultValue; + } + public static Decimal TryParseToDecimal(this string input, Decimal defaultValue) + { + Decimal.TryParse(input, out defaultValue); + return defaultValue; + } + + public static Decimal TryParseToDecimal(this string input) + { + Decimal decimalValue = 0; + Decimal.TryParse(input, out decimalValue); + return decimalValue; + } + + public static Decimal TryParseToDecimal(this string input, IFormatProvider fmtPrvdr, Decimal defaultValue) + { + Decimal.TryParse(input, NumberStyles.Number, fmtPrvdr, out defaultValue); + return defaultValue; + } + + public static Double TryParseToDouble(this string input, Double defaultValue) + { + Double.TryParse(input, out defaultValue); + return defaultValue; + } + + public static Double TryParseToDouble(this string input) + { + Double decimalValue = 0; + Double.TryParse(input, out decimalValue); + return decimalValue; + } + + public static Double TryParseToDouble(this string input, IFormatProvider fmtPrvdr, Double defaultValue) + { + Double.TryParse(input, NumberStyles.Number, fmtPrvdr, out defaultValue); + return defaultValue; + } + + public static IEnumerable ParseToInt32Collection(this string input) + { + return input.ParseToInt32Collection(","); + } + public static IEnumerable ParseToInt32Collection(this string input, string separator) + { + if (input.IsNullOrEmpty()) return new List(); + return input + .Split(separator.ToCharArray()) + .Select(str => str.TryParseToInt32Nulable()) + .Where(intValue => intValue != null) + .Select(intValue => intValue ?? 0) + .ToList(); + } + + public static IEnumerable ParseToGuidCollection(this string input) + { + return input.ParseToGuidCollection(","); + } + public static IEnumerable ParseToGuidCollection(this string input, string separator) + { + if (input.IsNullOrEmpty()) return new List(); + return input + .Split(separator.ToCharArray()) + .Select(str => str.TryParseToGuidNulable(null)) + .Where(intValue => intValue != null) + .Select(intValue => intValue.Value) + .ToList(); + } + + public static IEnumerable ParseToShortCollection(this string input) + { + return input.ParseToShortCollection(","); + } + public static IEnumerable ParseToShortCollection(this string input, string separator) + { + if (input.IsNullOrEmpty()) return new List(); + return input + .Split(separator.ToCharArray()) + .Select(str => str.TryParseToShort()) + .ToList(); + } + + public static short TryParseToShort(this string input) + { + short intValue = 0; + short.TryParse(input, out intValue); + return intValue; + } + + public static Int64 TryParseToShort(this string input, short defaultValue) + { + short.TryParse(input, out defaultValue); + return defaultValue; + } + + public static Int64? TryParseToInt64Nulable(this string input) + { + Int64 intValue; + if (!Int64.TryParse(input, out intValue)) return null; + return intValue; + } + + public static Int64 TryParseToInt64(this string input) + { + Int64 intValue = 0; + Int64.TryParse(input, out intValue); + return intValue; + } + + public static Int64 TryParseToInt64(this string input, Int64 defaultValue) + { + Int64.TryParse(input, out defaultValue); + return defaultValue; + } + + public static IEnumerable ParseToInt64Collection(this string input) + { + return input.ParseToInt64Collection(","); + } + public static IEnumerable ParseToInt64Collection(this string input, string separator) + { + if (input.IsNullOrEmpty()) return new List(); + return input + .Split(separator.ToCharArray()) + .Select(str => str.TryParseToInt64Nulable()) + .Where(intValue => intValue != null) + .Select(intValue => intValue ?? 0); + } + + public static NameValueCollection ParseToNameValueCollection(this string input, char parameterSeparator, char nameValueSeparator) + { + NameValueCollection collection = new NameValueCollection(); + if (!input.IsNullOrEmpty()) + foreach (string item in input.Split(parameterSeparator)) + { + string[] parameter = item.Split(nameValueSeparator); + if (parameter.Length == 2) + { + collection.Add(parameter[0], parameter[1]); + } + } + return collection; + } + + public static NameValueCollection ParseToNameValueCollection(this string input) + { + return input.ParseToNameValueCollection(';', ':'); + } + + public static IEnumerable ParseToSplitCollection(this string value, char parameterSeparator) + { + if (value.IsNullOrEmpty()) return new List(); + return value.Split(parameterSeparator); + } + + public static DateTime? TryParseToDateTimeNullable(this string value) + { + return value.TryParseToDateTimeNullable(null); + } + + public static DateTime? TryParseToDateTimeNullable(this string value, DateTime? defaultValue) + { + DateTime td; + if (DateTime.TryParse(value, out td)) return td; + return defaultValue; + } + + public static DateTime TryParseToDateTimeUTC(this string value, DateTime defaultValue) + { + DateTime td; + var culture = CultureInfo.CreateSpecificCulture("ru-Ru"); + var styles = DateTimeStyles.AdjustToUniversal; + + if (DateTime.TryParse(value, culture, styles, out td)) return td; + return defaultValue; + } + public static DateTime TryParseToDateDMYNullable(this string value, DateTime defaultValue) + { + if (value.IsNullOrEmpty()) return defaultValue; + var dtvalues = value.Replace("-","/").Replace(".","/").ParseToInt32Collection("/").ToArray(); + DateTime dt = DateTime.MaxValue; + if (dtvalues.Count() == 3) + try + { + defaultValue = new DateTime(dtvalues[2], dtvalues[1], dtvalues[0]); + } + catch (Exception) { } + return defaultValue; + } + + public static DateTime? TryParseToDateDMYNullable(this string value) + { + DateTime? defaultValue = null; + if (value.IsNullOrEmpty()) return defaultValue; + var dtvalues = value.Replace("-", "/").Replace(".", "/").ParseToInt32Collection("/").ToArray(); + DateTime dt = DateTime.MaxValue; + if (dtvalues.Count() == 3) + try + { + defaultValue = new DateTime(dtvalues[2], dtvalues[1], dtvalues[0]); + } + catch (Exception) { } + return defaultValue; + } + + public static DateTime? TryParseToDateDMYHMSNullable(this string value) + { + DateTime? defaultValue = null; + if (value.IsNullOrEmpty()) return defaultValue; + var dtvalues = value.TrimStart().TrimEnd().Replace("-", "/").Replace(".", "/").Replace(" ", "/").Replace(":", "/").ParseToInt32Collection("/").ToArray(); + DateTime dt = DateTime.MaxValue; + if (dtvalues.Count() == 6) + try + { + defaultValue = new DateTime(dtvalues[2], dtvalues[1], dtvalues[0], dtvalues[3], dtvalues[4], dtvalues[5]); + } + catch + { + // ignored + } + + return defaultValue; + } + + public static DateTime TryParseToDateDMYFromExcel(this string value,DateTime defaultValue) + { + + return value.TryParseToDateDMYFromExcelNullable() ?? defaultValue; + } + public static DateTime? TryParseToDateDMYFromExcelNullable(this string value) + { + DateTime? defaultValue = null; + var days = value.TryParseToInt32(); + if (days != 0) + { + return new DateTime(1899, 12, 30).AddDays(days); + } + + if (value.IsNullOrEmpty()) return defaultValue; + var dtvalues = value.Replace("-", "/").Replace(".", "/").ParseToInt32Collection("/").ToArray(); + DateTime dt = DateTime.MaxValue; + if (dtvalues.Count() == 3) + try + { + defaultValue = new DateTime(dtvalues[2], dtvalues[1], dtvalues[0]); + } + catch (Exception) { } + + return defaultValue; + } + + public static Encoding TryParseToEncoding(this string value, Encoding defaultValue) + { + Encoding retVal = defaultValue; + try + { + retVal = Encoding.GetEncoding(value); + } + catch (Exception){} // swallow any exceptions + + return retVal; + } + + #endregion + + public static bool ToLowerEquals(this string one, string other, bool nullEquals) + { + if (one != null && other != null) return one.ToLower() == other.ToLower(); + if (one == null && other == null) return nullEquals; + return false; + } + + public static bool ToLowerEquals(this string one, string other) + { + return one.ToLowerEquals(other, false); + } + + public static bool ToLowerContains(this string one, string other, bool nullEquals) + { + if (one != null && other != null) return one.ToLower().Contains(other.ToLower()); + if (one == null && other == null) return nullEquals; + return false; + } + public static bool ToLowerContains(this string one, string other) + { + return one.ToLowerContains(other, false); + } + public static bool ToLowerStartsWith(this string one, string other, bool nullEquals) + { + if (one != null && other != null) return one.ToLower().StartsWith(other.ToLower()); + if (one == null && other == null) return nullEquals; + return false; + } + public static bool ToLowerStartsWith(this string one, string other) + { + return one.ToLowerStartsWith(other, false); + } + public static bool TryParseToBool(this string input, bool defaultValue) + { + bool result; + if (!bool.TryParse(input, out result)) + return defaultValue; + return result; + } + public static bool TryParseToBool(this string input,string trueValue, bool defaultValue) + { + bool result; + if (!bool.TryParse(input.ToLowerContains(trueValue)?"true":"false", out result)) + return defaultValue; + return result; + } + public static bool IsExactlyEqualToBoolValue(this string input, bool theValueToCompareWith) + { + bool parsingOutput; + if (!bool.TryParse(input, out parsingOutput)) + return false; + return parsingOutput == theValueToCompareWith; + } + public static bool IsNotExactlyEqualToBoolValue(this string input, bool theValueToCompareWith) + { + bool parsingOutput; + if (!bool.TryParse(input, out parsingOutput)) + return true; + return parsingOutput != theValueToCompareWith; + } + + + #region MarkUp Helpers + public static string NotEmptyAttributeString(this string name, string value) + { + return value.IsNullOrEmpty() + ? String.Empty + : "{0}=\"{1}\"".ApplyFormat(name, value); + } + #endregion + + #region Generic Types + public static Type GetGenericType(this string type, params string[] typeArgs) + { + Type openType = Type.GetType(type, true); + Type[] types = typeArgs + .Select(tname => Type.GetType(tname)) + .ToArray(); + return openType.MakeGenericType(types); + } + public static object GetGenericTypeInstance(this string type, params string[] typeArgs) + { + Type genericType = type.GetGenericType(typeArgs); + return Activator.CreateInstance(genericType); + } + public static object GetGenericTypeInstance(this string type, string[] typeArgs, object[] parameters) + { + Type genericType = type.GetGenericType(typeArgs); + return Activator.CreateInstance(genericType, parameters); + } + #endregion + + public static string MapPath(string path) + { + return Path.Combine( + (string)AppDomain.CurrentDomain.GetData("ContentRootPath"), + path); + } + + public static string ServerMapPath(this string virtualPath, HttpContext httpContext) + { + //HttpContext httpContext = HttpContext.Current; + + string rootPath = Environment.CurrentDirectory; + + virtualPath = virtualPath.Remove("~/").Replace('/', '\\'); + + return Path.Combine(rootPath, virtualPath); + } + + #region CSV File + public static DataTable ReadCSV(this string filePath, char separator = ';') + { + DataTable table = new DataTable(); + + using (var reader = File.OpenText(filePath)) + { + if (reader.EndOfStream == true) return table; + //columns + var columns = from columnName in reader.ReadLine().Split(separator) + select new DataColumn(columnName); + table.Columns.AddRange(columns.ToArray()); + //rows + while (reader.EndOfStream == false) + { + string line = reader.ReadLine(); + if (line.IsNullOrEmpty()) continue; + + var values = line.Split(separator); + var row = table.NewRow(); + for (int index = 0; index < table.Columns.Count; index++) row[index] = values[index]; + table.Rows.Add(row); + } + reader.Close(); + } + return table; + } + + public static void WriteCsv(this string filePath, DataTable table, char separator = ';', string[] columnNames = null) + { + if (columnNames == null) columnNames = table.Columns.Cast().Select(c => c.ColumnName).ToArray(); + StringBuilder content = new StringBuilder(); + content.AppendLine(columnNames.Join(separator.ToString())); + foreach (DataRow row in table.Rows) + { + bool firstColumn = true; + foreach (var columnName in columnNames) + { + if (firstColumn) { firstColumn = false; } else { content.Append(separator); } + content.Append(row[columnName]); + } + content.AppendLine(); + } + File.WriteAllText(filePath, content.ToString(), Encoding.UTF8); + } + #endregion + + public static string Clear(this string sourceString) + { + #region chars map + + var map = new Dictionary(33); + map.Add('а', "a"); + map.Add('б', "b"); + map.Add('в', "v"); + map.Add('г', "g"); + map.Add('д', "d"); + map.Add('е', "e"); + map.Add('ё', "e"); + map.Add('ж', "zh"); + map.Add('з', "z"); + map.Add('и', "i"); + map.Add('й', "y"); + map.Add('к', "k"); + map.Add('л', "l"); + map.Add('м', "m"); + map.Add('н', "n"); + map.Add('о', "o"); + map.Add('п', "p"); + map.Add('р', "r"); + map.Add('с', "s"); + map.Add('т', "t"); + map.Add('у', "u"); + map.Add('ф', "f"); + map.Add('х', "h"); + map.Add('ц', "c"); + map.Add('ч', "ch"); + map.Add('ш', "sh"); + map.Add('щ', "sh"); + map.Add('ь', ""); + map.Add('ы', "i"); + map.Add('ъ', ""); + map.Add('э', "e"); + map.Add('ю', "u"); + map.Add('я', "ya"); + + map.Add('А', "A"); + map.Add('Б', "B"); + map.Add('В', "V"); + map.Add('Г', "G"); + map.Add('Д', "D"); + map.Add('Е', "E"); + map.Add('Ё', "E"); + map.Add('Ж', "Zh"); + map.Add('З', "Z"); + map.Add('И', "I"); + map.Add('Й', "Y"); + map.Add('К', "K"); + map.Add('Л', "L"); + map.Add('М', "M"); + map.Add('Н', "N"); + map.Add('О', "O"); + map.Add('П', "P"); + map.Add('Р', "R"); + map.Add('С', "S"); + map.Add('Т', "T"); + map.Add('У', "U"); + map.Add('Ф', "F"); + map.Add('Х', "H"); + map.Add('Ц', "C"); + map.Add('Ч', "Ch"); + map.Add('Ш', "Sh"); + map.Add('Щ', "Sh"); + map.Add('Ь', ""); + map.Add('Ы', "I"); + map.Add('Ъ', ""); + map.Add('Э', "E"); + map.Add('Ю', "U"); + map.Add('Я', "Ya"); + + map.Add('a', "a"); + map.Add('b', "b"); + map.Add('c', "c"); + map.Add('d', "d"); + map.Add('e', "e"); + map.Add('f', "f"); + map.Add('g', "g"); + map.Add('h', "h"); + map.Add('i', "i"); + map.Add('j', "j"); + map.Add('k', "k"); + map.Add('l', "l"); + map.Add('m', "m"); + map.Add('n', "n"); + map.Add('o', "o"); + map.Add('p', "p"); + map.Add('q', "q"); + map.Add('r', "r"); + map.Add('s', "s"); + map.Add('t', "t"); + map.Add('u', "u"); + map.Add('v', "v"); + map.Add('w', "w"); + map.Add('x', "x"); + map.Add('y', "y"); + map.Add('z', "z"); + + map.Add('A', "A"); + map.Add('B', "B"); + map.Add('C', "C"); + map.Add('D', "D"); + map.Add('E', "E"); + map.Add('F', "F"); + map.Add('G', "G"); + map.Add('H', "H"); + map.Add('I', "I"); + map.Add('J', "J"); + map.Add('K', "K"); + map.Add('L', "L"); + map.Add('M', "M"); + map.Add('N', "N"); + map.Add('O', "O"); + map.Add('P', "P"); + map.Add('Q', "Q"); + map.Add('R', "R"); + map.Add('S', "S"); + map.Add('T', "T"); + map.Add('U', "U"); + map.Add('V', "V"); + map.Add('W', "W"); + map.Add('X', "X"); + map.Add('Y', "Y"); + + map.Add('1', "1"); + map.Add('2', "2"); + map.Add('3', "3"); + map.Add('4', "4"); + map.Add('5', "5"); + map.Add('6', "6"); + map.Add('7', "7"); + map.Add('8', "8"); + map.Add('9', "9"); + map.Add('0', "0"); + + map.Add('-', "-"); + map.Add(' ', " "); + map.Add('.', "."); + map.Add('_', "_"); + + #endregion + + var sb = new StringBuilder(sourceString.Count()); + sourceString.Where(x => map.ContainsKey(x)).ForEach(x => sb.Append(x)); + + return sb.ToString(); + } + + public static string TranslitToEnglish( + this string russian, + int maxLength = 512, + bool replaceOthers = true, + char otherSubstitute = '-', + char spaceSubstitute = '-', + bool toLower = true, + bool deleteDoubleSpace = true) + { + + #region chars map + + var map = new Dictionary(33); + map.Add('а', "a"); + map.Add('б', "b"); + map.Add('в', "v"); + map.Add('г', "g"); + map.Add('д', "d"); + map.Add('е', "e"); + map.Add('ё', "e"); + map.Add('ж', "zh"); + map.Add('з', "z"); + map.Add('и', "i"); + map.Add('й', "y"); + map.Add('к', "k"); + map.Add('л', "l"); + map.Add('м', "m"); + map.Add('н', "n"); + map.Add('о', "o"); + map.Add('п', "p"); + map.Add('р', "r"); + map.Add('с', "s"); + map.Add('т', "t"); + map.Add('у', "u"); + map.Add('ф', "f"); + map.Add('х', "h"); + map.Add('ц', "c"); + map.Add('ч', "ch"); + map.Add('ш', "sh"); + map.Add('щ', "sh"); + map.Add('ь', ""); + map.Add('ы', "i"); + map.Add('ъ', ""); + map.Add('э', "e"); + map.Add('ю', "u"); + map.Add('я', "ya"); + + map.Add('А', "A"); + map.Add('Б', "B"); + map.Add('В', "V"); + map.Add('Г', "G"); + map.Add('Д', "D"); + map.Add('Е', "E"); + map.Add('Ё', "E"); + map.Add('Ж', "Zh"); + map.Add('З', "Z"); + map.Add('И', "I"); + map.Add('Й', "Y"); + map.Add('К', "K"); + map.Add('Л', "L"); + map.Add('М', "M"); + map.Add('Н', "N"); + map.Add('О', "O"); + map.Add('П', "P"); + map.Add('Р', "R"); + map.Add('С', "S"); + map.Add('Т', "T"); + map.Add('У', "U"); + map.Add('Ф', "F"); + map.Add('Х', "H"); + map.Add('Ц', "C"); + map.Add('Ч', "Ch"); + map.Add('Ш', "Sh"); + map.Add('Щ', "Sh"); + map.Add('Ь', ""); + map.Add('Ы', "I"); + map.Add('Ъ', ""); + map.Add('Э', "E"); + map.Add('Ю', "U"); + map.Add('Я', "Ya"); + + map.Add('a', "a"); + map.Add('b', "b"); + map.Add('c', "c"); + map.Add('d', "d"); + map.Add('e', "e"); + map.Add('f', "f"); + map.Add('g', "g"); + map.Add('h', "h"); + map.Add('i', "i"); + map.Add('j', "j"); + map.Add('k', "k"); + map.Add('l', "l"); + map.Add('m', "m"); + map.Add('n', "n"); + map.Add('o', "o"); + map.Add('p', "p"); + map.Add('q', "q"); + map.Add('r', "r"); + map.Add('s', "s"); + map.Add('t', "t"); + map.Add('u', "u"); + map.Add('v', "v"); + map.Add('w', "w"); + map.Add('x', "x"); + map.Add('y', "y"); + map.Add('z', "z"); + + map.Add('A', "A"); + map.Add('B', "B"); + map.Add('C', "C"); + map.Add('D', "D"); + map.Add('E', "E"); + map.Add('F', "F"); + map.Add('G', "G"); + map.Add('H', "H"); + map.Add('I', "I"); + map.Add('J', "J"); + map.Add('K', "K"); + map.Add('L', "L"); + map.Add('M', "M"); + map.Add('N', "N"); + map.Add('O', "O"); + map.Add('P', "P"); + map.Add('Q', "Q"); + map.Add('R', "R"); + map.Add('S', "S"); + map.Add('T', "T"); + map.Add('U', "U"); + map.Add('V', "V"); + map.Add('W', "W"); + map.Add('X', "X"); + map.Add('Y', "Y"); + + map.Add('1', "1"); + map.Add('2', "2"); + map.Add('3', "3"); + map.Add('4', "4"); + map.Add('5', "5"); + map.Add('6', "6"); + map.Add('7', "7"); + map.Add('8', "8"); + map.Add('9', "9"); + map.Add('0', "0"); + + #endregion + + var result = new StringBuilder(); + int i = 0; + foreach (char c in russian) + { + ++i; + if (c == ' ') + { + if (i>=maxLength) + { + break; + } + result.Append(spaceSubstitute); + } + else if (map.ContainsKey(c)) result.Append(map[c]); + else if (!replaceOthers) result.Append(c); + else result.Append(otherSubstitute); + } + string resultKey = result.ToString() + .Trim(spaceSubstitute); + + if (toLower) resultKey = resultKey.ToLower(); + if (deleteDoubleSpace) + { + string doubleSpace = "{0}{0}".ApplyFormat(spaceSubstitute); + while (resultKey.Contains(doubleSpace)) resultKey = resultKey.Replace(doubleSpace, spaceSubstitute.ToString()); + } + return resultKey; + } + + public static string Convert(this string source, System.Text.Encoding encoding) + { + if (string.IsNullOrEmpty(source)) return string.Empty; + Encoding utf8 = Encoding.Default; + byte[] utf8Bytes = encoding.GetBytes(source); + byte[] win1251Bytes = Encoding.Convert(utf8, encoding, utf8Bytes); + + return encoding.GetString(win1251Bytes); + } + + public static string ConvertToBase64(this string source) + { + if (string.IsNullOrEmpty(source)) return string.Empty; + return System.Convert.ToBase64String(new UTF8Encoding(false).GetBytes(source)); + } + + public static string ConvertFromBase64(this string source) + { + if (string.IsNullOrEmpty(source)) return string.Empty; + return new UTF8Encoding(false).GetString(System.Convert.FromBase64String(source)); + } + + public static bool CheckBase64Format(this string source) + { + if (string.IsNullOrEmpty(source)) return true; + + try + { + System.Convert.FromBase64CharArray(source.ToCharArray(), 0, source.Length); + } + catch + { + return false; + } + + return true; + } + + //public static string ContentBlockReplace(this string content) + //{ + // return content; + // //return content.Replace("", "") + // // .Replace("", "") + // // .Replace("", "
") + // // .Replace("", "
"); + //} + + + private static readonly Dictionary MIMETypesDictionary = new Dictionary + { + {"ai", "application/postscript"}, + {"aif", "audio/x-aiff"}, + {"aifc", "audio/x-aiff"}, + {"aiff", "audio/x-aiff"}, + {"asc", "text/plain"}, + {"atom", "application/atom+xml"}, + {"au", "audio/basic"}, + {"avi", "video/x-msvideo"}, + {"bcpio", "application/x-bcpio"}, + {"bin", "application/octet-stream"}, + {"bmp", "image/bmp"}, + {"cdf", "application/x-netcdf"}, + {"cgm", "image/cgm"}, + {"class", "application/octet-stream"}, + {"cpio", "application/x-cpio"}, + {"cpt", "application/mac-compactpro"}, + {"csh", "application/x-csh"}, + {"css", "text/css"}, + {"dcr", "application/x-director"}, + {"dif", "video/x-dv"}, + {"dir", "application/x-director"}, + {"djv", "image/vnd.djvu"}, + {"djvu", "image/vnd.djvu"}, + {"dll", "application/octet-stream"}, + {"dmg", "application/octet-stream"}, + {"dms", "application/octet-stream"}, + {"doc", "application/msword"}, + {"docx","application/vnd.openxmlformats-officedocument.wordprocessingml.document"}, + {"dotx", "application/vnd.openxmlformats-officedocument.wordprocessingml.template"}, + {"docm","application/vnd.ms-word.document.macroEnabled.12"}, + {"dotm","application/vnd.ms-word.template.macroEnabled.12"}, + {"dtd", "application/xml-dtd"}, + {"dv", "video/x-dv"}, + {"dvi", "application/x-dvi"}, + {"dxr", "application/x-director"}, + {"eps", "application/postscript"}, + {"etx", "text/x-setext"}, + {"exe", "application/octet-stream"}, + {"ez", "application/andrew-inset"}, + {"gif", "image/gif"}, + {"gram", "application/srgs"}, + {"grxml", "application/srgs+xml"}, + {"gtar", "application/x-gtar"}, + {"hdf", "application/x-hdf"}, + {"hqx", "application/mac-binhex40"}, + {"htm", "text/html"}, + {"html", "text/html"}, + {"ice", "x-conference/x-cooltalk"}, + {"ico", "image/x-icon"}, + {"ics", "text/calendar"}, + {"ief", "image/ief"}, + {"ifb", "text/calendar"}, + {"iges", "model/iges"}, + {"igs", "model/iges"}, + {"jnlp", "application/x-java-jnlp-file"}, + {"jp2", "image/jp2"}, + {"jpe", "image/jpeg"}, + {"jpeg", "image/jpeg"}, + {"jpg", "image/jpeg"}, + {"js", "application/x-javascript"}, + {"kar", "audio/midi"}, + {"latex", "application/x-latex"}, + {"lha", "application/octet-stream"}, + {"lzh", "application/octet-stream"}, + {"m3u", "audio/x-mpegurl"}, + {"m4a", "audio/mp4a-latm"}, + {"m4b", "audio/mp4a-latm"}, + {"m4p", "audio/mp4a-latm"}, + {"m4u", "video/vnd.mpegurl"}, + {"m4v", "video/x-m4v"}, + {"mac", "image/x-macpaint"}, + {"man", "application/x-troff-man"}, + {"mathml", "application/mathml+xml"}, + {"me", "application/x-troff-me"}, + {"mesh", "model/mesh"}, + {"mid", "audio/midi"}, + {"midi", "audio/midi"}, + {"mif", "application/vnd.mif"}, + {"mov", "video/quicktime"}, + {"movie", "video/x-sgi-movie"}, + {"mp2", "audio/mpeg"}, + {"mp3", "audio/mpeg"}, + {"mp4", "video/mp4"}, + {"mpe", "video/mpeg"}, + {"mpeg", "video/mpeg"}, + {"mpg", "video/mpeg"}, + {"mpga", "audio/mpeg"}, + {"ms", "application/x-troff-ms"}, + {"msh", "model/mesh"}, + {"mxu", "video/vnd.mpegurl"}, + {"nc", "application/x-netcdf"}, + {"oda", "application/oda"}, + {"ogg", "application/ogg"}, + {"pbm", "image/x-portable-bitmap"}, + {"pct", "image/pict"}, + {"pdb", "chemical/x-pdb"}, + {"pdf", "application/pdf"}, + {"pgm", "image/x-portable-graymap"}, + {"pgn", "application/x-chess-pgn"}, + {"pic", "image/pict"}, + {"pict", "image/pict"}, + {"png", "image/png"}, + {"pnm", "image/x-portable-anymap"}, + {"pnt", "image/x-macpaint"}, + {"pntg", "image/x-macpaint"}, + {"ppm", "image/x-portable-pixmap"}, + {"ppt", "application/vnd.ms-powerpoint"}, + {"pptx","application/vnd.openxmlformats-officedocument.presentationml.presentation"}, + {"potx","application/vnd.openxmlformats-officedocument.presentationml.template"}, + {"ppsx","application/vnd.openxmlformats-officedocument.presentationml.slideshow"}, + {"ppam","application/vnd.ms-powerpoint.addin.macroEnabled.12"}, + {"pptm","application/vnd.ms-powerpoint.presentation.macroEnabled.12"}, + {"potm","application/vnd.ms-powerpoint.template.macroEnabled.12"}, + {"ppsm","application/vnd.ms-powerpoint.slideshow.macroEnabled.12"}, + {"ps", "application/postscript"}, + {"qt", "video/quicktime"}, + {"qti", "image/x-quicktime"}, + {"qtif", "image/x-quicktime"}, + {"ra", "audio/x-pn-realaudio"}, + {"ram", "audio/x-pn-realaudio"}, + {"ras", "image/x-cmu-raster"}, + {"rdf", "application/rdf+xml"}, + {"rgb", "image/x-rgb"}, + {"rm", "application/vnd.rn-realmedia"}, + {"roff", "application/x-troff"}, + {"rtf", "text/rtf"}, + {"rtx", "text/richtext"}, + {"sgm", "text/sgml"}, + {"sgml", "text/sgml"}, + {"sh", "application/x-sh"}, + {"shar", "application/x-shar"}, + {"silo", "model/mesh"}, + {"sit", "application/x-stuffit"}, + {"skd", "application/x-koan"}, + {"skm", "application/x-koan"}, + {"skp", "application/x-koan"}, + {"skt", "application/x-koan"}, + {"smi", "application/smil"}, + {"smil", "application/smil"}, + {"snd", "audio/basic"}, + {"so", "application/octet-stream"}, + {"spl", "application/x-futuresplash"}, + {"src", "application/x-wais-source"}, + {"sv4cpio", "application/x-sv4cpio"}, + {"sv4crc", "application/x-sv4crc"}, + {"svg", "image/svg+xml"}, + {"swf", "application/x-shockwave-flash"}, + {"t", "application/x-troff"}, + {"tar", "application/x-tar"}, + {"tcl", "application/x-tcl"}, + {"tex", "application/x-tex"}, + {"texi", "application/x-texinfo"}, + {"texinfo", "application/x-texinfo"}, + {"tif", "image/tiff"}, + {"tiff", "image/tiff"}, + {"tr", "application/x-troff"}, + {"tsv", "text/tab-separated-values"}, + {"txt", "text/plain"}, + {"ustar", "application/x-ustar"}, + {"vcd", "application/x-cdlink"}, + {"vrml", "model/vrml"}, + {"vxml", "application/voicexml+xml"}, + {"wav", "audio/x-wav"}, + {"wbmp", "image/vnd.wap.wbmp"}, + {"wbmxl", "application/vnd.wap.wbxml"}, + {"wml", "text/vnd.wap.wml"}, + {"wmlc", "application/vnd.wap.wmlc"}, + {"wmls", "text/vnd.wap.wmlscript"}, + {"wmlsc", "application/vnd.wap.wmlscriptc"}, + {"wrl", "model/vrml"}, + {"xbm", "image/x-xbitmap"}, + {"xht", "application/xhtml+xml"}, + {"xhtml", "application/xhtml+xml"}, + {"xls", "application/vnd.ms-excel"}, + {"xml", "application/xml"}, + {"xpm", "image/x-xpixmap"}, + {"xsl", "application/xml"}, + {"xlsx","application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, + {"xltx","application/vnd.openxmlformats-officedocument.spreadsheetml.template"}, + {"xlsm","application/vnd.ms-excel.sheet.macroEnabled.12"}, + {"xltm","application/vnd.ms-excel.template.macroEnabled.12"}, + {"xlam","application/vnd.ms-excel.addin.macroEnabled.12"}, + {"xlsb","application/vnd.ms-excel.sheet.binary.macroEnabled.12"}, + {"xslt", "application/xslt+xml"}, + {"xul", "application/vnd.mozilla.xul+xml"}, + {"xwd", "image/x-xwindowdump"}, + {"xyz", "chemical/x-xyz"}, + {"zip", "application/zip"} + }; + + public static string GetMimeType(this string fileName) + { + //get file extension + string extension = Path.GetExtension(fileName).ToLowerInvariant(); + + if (extension.Length > 0 && + MIMETypesDictionary.ContainsKey(extension.Remove(0, 1))) + { + return MIMETypesDictionary[extension.Remove(0, 1)]; + } + return "unknown/unknown"; + } + + public static string AddPrifixIsNotEmpty(this string input, string prefix) + { + if (!input.IsNullOrEmpty()) return "{0}{1}".ApplyFormat(prefix, input); + return string.Empty; + } + + public static string AddPrifixIsNotEmpty(this string input, string prefix,int count) + { + if (input.IsNullOrEmpty()) return string.Empty; + + StringBuilder builder = new StringBuilder(); + for(int i=0;i < count; i++) builder.Append(prefix); + return "{0}{1}".ApplyFormat(builder.ToString(), input); + } + public static string AddSufixIsNotEmpty(this string input, string sufix) + { + if (!input.IsNullOrEmpty()) return "{1}{0}".ApplyFormat(sufix, input); + return string.Empty; + } + + public static byte TryParseToByte(this string input, byte defaultValue) + { + byte result; + if (byte.TryParse(input, out result)) return result; + return defaultValue; + } + + public static Decimal? TryParseToDecimalNulable(this string input) + { + Decimal decimalValue = 0; + if (!Decimal.TryParse(input, out decimalValue)) return null; + return decimalValue; + } + + public static Double? TryParseToDoubleNulable(this string input) + { + Double decimalValue = 0; + if (!Double.TryParse(input, out decimalValue)) return null; + return decimalValue; + } + + public static decimal GetValueDecimal(this string input) + { + decimal value; + if (decimal.TryParse(input, NumberStyles.Any, ci.NumberFormat, out value)) + return value; + return decimal.MinValue; + } + + public static Guid TryParseToGuid(this string value, Guid defaultValue) + { + Guid td; + if (Guid.TryParse(value, out td)) return td; + return defaultValue; + } + + public static Guid? TryParseToGuidNulable(this string value, Guid? defaultValue) + { + Guid td; + if (Guid.TryParse(value, out td)) return td; + return defaultValue; + } + + public static DateTime TryParseToDateXMLNullable(this string value, DateTime defaultValue) + { + if (value.IsNullOrEmpty()) return defaultValue; + var collections = value.ParseToSplitCollection('T'); + if (collections.Count() == 1) + { + collections = collections.AddToNewList("00:00:00"); + } + var dtvalues = collections.FirstOrDefault().ParseToInt32Collection("-").ToArray(); + var timevalues = collections.LastOrDefault().ParseToInt32Collection(":").ToArray(); + DateTime dt = DateTime.MaxValue; + if (dtvalues.Count() == 3) + { + try + { + defaultValue = new DateTime(dtvalues[0], dtvalues[1], dtvalues[2], timevalues[0], timevalues[1], timevalues[2]); + } + catch (Exception) { } + } + return defaultValue; + } + + public static DateTime? TryParseToDateXMLNullable(this string value) + { + var result = value.TryParseToDateXMLNullable(DateTime.MinValue); + if (result == DateTime.MinValue) return null; + return result; + } + + public static string GetShortFIO(this string value) + { + string result = ""; + if (value == null || value.Trim() == "") + return result; + string[] arrayString = value.Split(' '); + for (int i = 0; i < arrayString.Length; i++) + { + if (i == 0) + { + result = arrayString[i]; + } + else + { + result += " " + arrayString[i].ToUpper().FirstOrDefault() + "."; + } + } + return result; + } + + public static string GetDigit(this string value) + { + return string.Join("", value.Where(c => char.IsDigit(c))); ; + } + + public static string GZipCompress(this string s, CompressionMode mode, Encoding encoding) + { + if (mode == CompressionMode.Compress) + { + using (var outputStream = new MemoryStream()) + { + using (var compressionStream = new GZipStream(outputStream, CompressionMode.Compress)) + using (var inputStream = new MemoryStream(encoding.GetBytes(s))) + inputStream.CopyTo(compressionStream); + + return System.Convert.ToBase64String(outputStream.ToArray()); + } + } + else + { + using (var outputStream = new MemoryStream()) + { + using (var inputStream = new MemoryStream(System.Convert.FromBase64String(s))) + using (var compressionStream = new GZipStream(inputStream, mode)) + compressionStream.CopyTo(outputStream); + + return encoding.GetString(outputStream.ToArray()); + } + } + } + + public static string ReadFileTextToEnd(this string fileName) + { + var reader = new StreamReader(fileName); + return reader.ReadToEnd(); + } + + public static void WriteText(this string fileName, string text) + { + using (StreamWriter writer = new StreamWriter(fileName)) + { + writer.Write(text); + } + } + + + public static string GetMD5Hash(this string value) + { + byte[] hash = Encoding.UTF8.GetBytes(value); + MD5 md5 = new MD5CryptoServiceProvider(); + string hashenc = Encoding.UTF8.GetString(md5.ComputeHash(hash)); + return hashenc; + } + + + public static string ToKeyHashMD5(this string value,int size = 256) + { + string hash = value.GetMD5Hash(); + string trans = value.TranslitToEnglish(); + string result = trans; + if (trans.Length > size) + { + result = trans.Substring(0, (size - hash.Length - 1)) + "_" + hash; + } + + return result; + } + + + public static string ToKeyHash(this string value, int size = 256) + { + string hash = value.GetHashString(); + string trans = value.TranslitToEnglish(); + string result = trans; + if (trans.Length > size) + { + result = trans.Substring(0, (size - hash.Length - 1)) + "_" + hash; + } + + return result; + } + + + public static byte[] GetHash(this string inputString) + { + using (HashAlgorithm algorithm = SHA256.Create()) + return algorithm.ComputeHash(Encoding.UTF8.GetBytes(inputString)); + } + + public static string GetHashString(this string inputString) + { + StringBuilder sb = new StringBuilder(); + foreach (byte b in GetHash(inputString)) + sb.Append(b.ToString("X2")); + + return sb.ToString(); + } + + public static string ServerMapPath(this string virtualPath) + { + if (virtualPath.StartsWith("~/") == false) + { + return virtualPath; + } + + string result = Environment.CurrentDirectory; + virtualPath = virtualPath.Remove("~/"); + string[] directories = virtualPath.Split('/'); + foreach (string directory in directories) + { + result = Path.Combine(result, directory); + } + return result; + } + public static string CreateSQLiteConnectionString(this string filePath, string mode = "ReadWrite") + { + return $"Data Source={filePath};Mode={mode};"; + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Extension/System.Web.Mvc.cs b/3-app/1-lib/Kit.Helpers/Extension/System.Web.Mvc.cs new file mode 100644 index 0000000..057e6dd --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/System.Web.Mvc.cs @@ -0,0 +1,129 @@ +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Kit.Helpers +{ + public static class SystemWebMvcExtensions + { + /// + /// Преобразует перечень объектов в перечень элементов html-списка + /// + /// Тип преобразуемого объекта + /// Перечень преобразуемых объектов + /// Функция, возвращающая значение, используемое в качестве Value для элемента списка. + /// Для преобразования возвращаемого значения в строку вызывается стантартная реализация ToString() + /// Функция, возвращающая значение, используемое в качестве Text для элемента списка. + /// Для преобразования возвращаемого значения в строку вызывается стантартная реализация ToString() + public static IEnumerable ToSelectList( + this IEnumerable items, + Func valueSelector, + Func textSelector) + { + if (valueSelector == null) throw new ArgumentNullException("valueSelector"); + if (textSelector == null) throw new ArgumentNullException("textSelector"); + + return items.Select(i => new SelectListItem + { + Value = valueSelector(i).ToString(), + Text = textSelector(i).ToString() + }); + } + + /// + /// Преобразует перечень объектов в перечень элементов html-списка + /// + /// Тип преобразуемого объекта + /// Перечень преобразуемых объектов + /// Функция, возвращающая значение, используемое в качестве Value для элемента списка. + /// Функция, возвращающая значение, используемое в качестве Text для элемента списка. + public static IEnumerable ToSelectList( + this IEnumerable items, + Func valueSelector, + Func textSelector) + { + if (valueSelector == null) throw new ArgumentNullException("valueSelector"); + if (textSelector == null) throw new ArgumentNullException("textSelector"); + + return items.Select(i => new SelectListItem + { + Value = valueSelector(i), + Text = textSelector(i) + }); + } + + /// + /// Преобразует перечень объектов в перечень элементов html-списка + /// + /// Тип преобразуемого объекта + /// Перечень преобразуемых объектов + /// Функция, возвращающая значение, используемое в качестве Value для элемента списка. + /// Функция, возвращающая значение, используемое в качестве Text для элемента списка. + /// Список значений, которые необходимо отметить в результирующем списке как выбранные. + public static IEnumerable ToSelectList( + this IEnumerable items, + Func valueSelector, + Func textSelector, + Func disabled) + { + if (valueSelector == null) throw new ArgumentNullException("valueSelector"); + if (textSelector == null) throw new ArgumentNullException("textSelector"); + + return items.Select(i => new SelectListItem + { + Value = (valueSelector(i) != null ? valueSelector(i).ToString() : string.Empty), + Text = (textSelector(i) != null ? textSelector(i).ToString() : string.Empty), + Selected = disabled(i) + }); + } + + + /// + /// Преобразует перечень объектов в перечень элементов html-списка + /// + /// Тип преобразуемого объекта + /// Перечень преобразуемых объектов + /// Функция, возвращающая значение, используемое в качестве Value для элемента списка. + /// Функция, возвращающая значение, используемое в качестве Text для элемента списка. + /// Список значений, которые необходимо отметить в результирующем списке как выбранные. + public static IEnumerable ToSelectList( + this IEnumerable items, + Func valueSelector, + Func textSelector, + TValue selectedValue) + { + if (valueSelector == null) throw new ArgumentNullException("valueSelector"); + if (textSelector == null) throw new ArgumentNullException("textSelector"); + + return items.Select(i => new SelectListItem + { + Value = (valueSelector(i) != null ? valueSelector(i).ToString() : string.Empty), + Text = (textSelector(i) != null ? textSelector(i).ToString() : string.Empty), + Selected = (selectedValue != null && selectedValue.Equals(valueSelector(i))) + }); + } + + /// + /// Преобразует перечень объектов в перечень элементов html-списка + /// + /// Тип преобразуемого объекта + /// Перечень преобразуемых объектов + /// Функция, возвращающая значение, используемое в качестве Value для элемента списка. + /// Функция, возвращающая значение, используемое в качестве Text для элемента списка. + /// Список значений, которые необходимо отметить в результирующем списке как выбранные. + public static IEnumerable ToSelectList( + this IEnumerable items, + Func valueSelector, + Func textSelector, + IEnumerable selectedValues) + { + if (valueSelector == null) throw new ArgumentNullException("valueSelector"); + if (textSelector == null) throw new ArgumentNullException("textSelector"); + + return items.Select(i => new SelectListItem + { + Value = (valueSelector(i) != null ? valueSelector(i).ToString() : string.Empty), + Text = (textSelector(i) != null ? textSelector(i).ToString() : string.Empty), + Selected = selectedValues != null && selectedValues.Contains(valueSelector(i)) + }); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Extension/System.Xml.XmlNode.cs b/3-app/1-lib/Kit.Helpers/Extension/System.Xml.XmlNode.cs new file mode 100644 index 0000000..51fd0fe --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/System.Xml.XmlNode.cs @@ -0,0 +1,455 @@ +using System.Globalization; + +namespace Kit.Helpers +{ + using System; + using System.Xml; + using System.Linq; + using System.Collections.Generic; + + public static class SystemXmlNodeExtensionMethods + { + private static readonly CultureInfo ci = new CultureInfo("en-US"); + + public static void RemoveAttribute(this XmlDocument input, string name) + { + foreach (XmlNode item in input.SelectNodes("//*[@{0}]".ApplyFormat(name))) + { + item.Attributes.Remove(item.Attributes[name]); + } + } + + public static XmlNode SetElementValue(this XmlNode input,string name,string value) + { + var element = input.SelectSingleNode(name); + if (element == null) + { + element = input.OwnerDocument.CreateElement(name); + input.AppendChild(element); + } + element.InnerText = value; + return element; + } + + public static XmlNode SetElement(this XmlNode input, string name, Object value) + { + var element = input.SelectSingleNode(name); + if (element == null) + { + element = input.OwnerDocument.CreateElement(name); + input.AppendChild(element); + } + element.InnerText = value.ToString(); + return element; + } + + + public static XmlNode SetElementCDataValue(this XmlNode input, string name, string value) + { + var element = input.SelectSingleNode(name); + if (element == null) + { + var root = input.OwnerDocument.CreateElement(name); + element = input.OwnerDocument.CreateNode(XmlNodeType.CDATA, name, null); + root.AppendChild(element); + input.AppendChild(root); + } + element.InnerText = value; + return element; + } + + + public static XmlNode SetElementValue(this XmlNode input, string name, decimal value) + { + return input.SetElementValue(name, value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + public static XmlNode SetElementValue(this XmlNode input, string name, decimal? value, string elementsAfterPostion) + { + if (value.HasValue) + { + return input.SetElementNullableValue(name, elementsAfterPostion, value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + else + { + return input.SetElementNullableValue(name, null); + } + } + + public static XmlNode SetElementValue(this XmlNode input, string name, int? value, string elementsAfterPostion) + { + if (value.HasValue && value != 0) + { + return input.SetElementNullableValue(name, elementsAfterPostion, value.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + else + { + return input.SetElementNullableValue(name, null); + } + } + + private static XmlNode GetElementAfterInsert(XmlNode root, string elementsAfterPostion) + { + if (!elementsAfterPostion.IsNullOrEmpty()) + { + var elements = root.SelectNodes(elementsAfterPostion); + return elements.Cast().LastOrDefault(); + } + return null; + } + + public static XmlNode SetElementNullableValue(this XmlNode input, string name, string value) + { + return input.SetElementNullableValue(name, "", value); + } + + public static XmlNode SetElementNullableValue(this XmlNode input, string name, string elementsAfterPostion, string value) + { + if (!value.IsNullOrEmpty()) + { + var element = input.SelectSingleNode(name); + if (element != null) + { + input.RemoveChild(element); + } + + element = input.OwnerDocument.CreateElement(name); + var after = GetElementAfterInsert(input, elementsAfterPostion); + if (after == null) + input.InsertBefore(element, input.FirstChild); + else + input.InsertAfter(element, after); + + element.InnerText = value; + + return element; + } + else + { + var child = input.SelectSingleNode(name); + if (child != null) + input.RemoveChild(child); + return null; + } + } + + public static XmlNode CreateElement(this XmlNode input, string name) + { + //var element = input.SelectSingleNode(name); + //if (element == null) + //{ + var element = input.OwnerDocument.CreateElement(name); + input.AppendChild(element); + //} + return element; + } + + public static XmlAttribute CreateAttributeValue(this XmlNode input, string name, string value) + { + XmlAttribute element; + //try + { + element = input.Attributes[name]; + } + //catch + { + + if (element == null) + { + element = input.OwnerDocument.CreateAttribute(name); + input.Attributes.Append(element); + } + element.InnerText = value; + } + return element; + } + + public static void SetAttribute(this XmlNode input, string name, Object value) + { + XmlAttribute element = null; + //try + { + element = input.Attributes[name]; + } + //catch { } + if (element == null) + { + element = input.OwnerDocument.CreateAttribute(name); + input.Attributes.Append(element); + } + element.InnerText = value.ToString(); + } + + + public static void SetAttributeValue(this XmlNode input, string name, string value) + { + XmlAttribute element = null; + //try + { + element = input.Attributes[name]; + } + //catch { } + if (element == null) + { + element = input.OwnerDocument.CreateAttribute(name); + input.Attributes.Append(element); + } + element.InnerText = value; + } + + public static void SetAttributeValue(this XmlNode input, string name, Guid? value) + { + XmlAttribute element = null; + //try + { + element = input.Attributes[name]; + } + //catch { } + if (element == null) + { + element = input.OwnerDocument.CreateAttribute(name); + input.Attributes.Append(element); + } + element.InnerText = value.ToStringOrEmpty(); + } + public static string GetValueAttribute(this XmlNode input, string name) + { + return input.GetValueAttribute(name, string.Empty); + } + + public static string GetValueAttribute(this XmlNode input, string name, string empty) + { + XmlAttribute element = null; + //try + { + if (input == null) return empty; + element = input.Attributes[name]; + + if (element != null) + return element.InnerText; + else + return empty; + } + //catch + //{ + // return empty; + //} + } + public static string GetValueAttribute(this XmlNode input, string name, string empty, XmlNamespaceManager namespaces) + { + XmlNode element = null; + //try + { + if (input == null) return empty; + element = input.SelectSingleNode(name, namespaces); + + if (element != null) + return element.InnerText; + else + return empty; + } + //catch + //{ + // return empty; + //} + } + public static string GetValue(this XmlNode input, string name) + { + return input.GetValue(name,String.Empty); + } + + public static string GetValue(this XmlNode input, string name, string empty) + { + var element = input.SelectSingleNode(name); + if (element == null) return empty; + return element.InnerText; + } + + public static string GetValue(this XmlNode input, string name, XmlNamespaceManager namespaces) + { + return input.GetValue(name, String.Empty, namespaces); + } + + public static string GetValue(this XmlNode input, string name, string empty, XmlNamespaceManager namespaces) + { + var element = input.SelectSingleNode(name, namespaces); + if (element == null) return empty; + return element.InnerText; + } + + public static string GetValueCData(this XmlNode input, string name) + { + return input.GetValueCData(name, string.Empty); + } + + public static string GetValueCData(this XmlNode input, string name, string empty) + { + var element = input.SelectSingleNode(name); + if (element == null) return empty; + if (element.FirstChild == null) return element.InnerText; + return element.FirstChild.InnerText; + } + + public static DateTime? GetValueDate(this XmlNode input, string name) + { + return input.GetValue(name, String.Empty).TryParseToDateXMLNullable(); + } + + public static DateTime GetValueDate(this XmlNode input, string name,DateTime empty) + { + return input.GetValue(name, String.Empty).TryParseToDateXMLNullable(empty); + } + + public static short GetValueShort(this XmlNode input, string name) + { + return input.GetValue(name, String.Empty).TryParseToShort(); + } + + public static bool GetValueBool(this XmlNode input, string name) + { + return input.GetValue(name, String.Empty).TryParseToBool(false); + } + public static bool GetValueBool(this XmlNode input, string name, bool empty) + { + return input.GetValue(name, String.Empty).TryParseToBool(empty); + } + public static Int32? GetValueInt32Nulable(this XmlNode input, string name) + { + return input.GetValue(name, String.Empty).TryParseToInt32Nulable(); + } + + public static Int32 GetValueInt32(this XmlNode input, string name) + { + return input.GetValue(name, String.Empty).TryParseToInt32(); + } + public static Int32 GetValueInt32(this XmlNode input, string name, int empty) + { + return input.GetValue(name, String.Empty).TryParseToInt32(empty); + } + public static byte GetValueByte(this XmlNode input, string name, byte empty) + { + return input.GetValue(name, String.Empty).TryParseToByte(empty); + } + public static decimal? GetValueDecimal(this XmlNode input, string name) + { + return input.GetValue(name, String.Empty).TryParseToDecimalNulable(); + } + public static decimal GetValueDecimal(this XmlNode input, string name, decimal empty) + { + return input.GetValue(name, String.Empty).TryParseToDecimal(empty); + } + public static Guid GetValueGuid(this XmlNode input, string name, Guid empty) + { + return input.GetValue(name, String.Empty).TryParseToGuid(empty); + } + + public static Guid GetValueGuid(this XmlNode input, string name) + { + return input.GetValue(name, String.Empty).TryParseToGuid(Guid.Empty); + } + public static Guid? GetValueGuidNullable(this XmlNode input, string name) + { + return input.GetValue(name, String.Empty).TryParseToGuidNulable(null); + } + + public static Guid GetValueAttributeGuid(this XmlNode input, string name) + { + return input.GetValueAttributeGuid(name,null) ?? Guid.Empty; + } + + public static Guid? GetValueAttributeGuid(this XmlNode input, string name, Guid? empty) + { + return input.GetValueAttribute(name, String.Empty).TryParseToGuidNulable(empty); + } + + public static Guid? GetValueAttributeGuid(this XmlNode input, string name, XmlNamespaceManager namespaces, Guid? empty ) + { + return input.GetValueAttribute(name, String.Empty).TryParseToGuidNulable(empty); + } + + + public static int GetValueAttributeInt32(this XmlNode input, string name) + { + return input.GetValueAttribute(name, String.Empty).TryParseToInt32(0); + } + + public static int? GetValueAttributeInt32Nullable(this XmlNode input, string name) + { + return input.GetValueAttribute(name, String.Empty).TryParseToInt32Nulable(); + } + + public static bool GetValueAttributeBool(this XmlNode input, string name) + { + return input.GetValueAttribute(name, String.Empty).TryParseToBool(false); + } + public static bool GetValueAttributeBool(this XmlNode input, string name, bool empty) + { + return input.GetValueAttribute(name, String.Empty).TryParseToBool(empty); + } + + + public static string ToStringXMLShema(this DateTime dt) + { + if (dt == null) return String.Empty; + return dt.ToString("yyyy-MM-ddTHH:mm:ss"); + } + + public static string ToStringXMLShema(this DateTime? dt) + { + if (dt == null) return String.Empty; + return (dt ?? DateTime.UtcNow).ToString("yyyy-MM-ddTHH:mm:ss"); + } + + public static string ToStringSoutXML(this DateTime dt) + { + if (dt == null) return String.Empty; + return dt.ToString("yyyy-MM-dd"); + } + + public static string ToStringSoutXML(this DateTime? dt) + { + if (dt == null) return String.Empty; + return (dt??DateTime.UtcNow).ToString("yyyy-MM-dd"); + } + + public static double GetValueDouble_(this XmlNode input, string name) + { + double value; + if (double.TryParse(input.GetValue(name), NumberStyles.Any, ci.NumberFormat, out value)) + return value; + return double.MinValue; + } + public static double GetValueDouble_(this XmlNode input) + { + double value; + if (double.TryParse(input.InnerText, NumberStyles.Any, ci.NumberFormat, out value)) + return value; + return double.MinValue; + } + public static void ForEach(this XmlNodeList nodeList, Action action) + { + foreach(XmlNode node in nodeList) + { + action(node); + } + } + public static IEnumerable ToList(this XmlNodeList nodeList) + { + var resultList = new List(); + foreach (XmlNode node in nodeList) + { + resultList.Add(node); + } + return resultList; + } + public static IEnumerable Select(this XmlNodeList nodeList, Func action) + { + var resultList = new List(); + foreach (XmlNode node in nodeList) + { + resultList.Add(action(node)); + } + return resultList; + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Extension/SystemExceptionExtensionMethods.cs b/3-app/1-lib/Kit.Helpers/Extension/SystemExceptionExtensionMethods.cs new file mode 100644 index 0000000..55a2a6a --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Extension/SystemExceptionExtensionMethods.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Kit.Core.Kit.Helpers.Extension +{ + public static class SystemExceptionExtensionMethods + { + public static string GetInfoAsPlainText(this Exception ex) + { + if (ex == null) return string.Empty; + StringBuilder sb = new StringBuilder(); + sb.AppendFormat("Source: {0}", ex.Source); sb.AppendLine(); + sb.AppendFormat("Message: {0}", ex.Message); sb.AppendLine(); + sb.AppendFormat("StackTrace: {0}", ex.StackTrace); sb.AppendLine(); + if (ex.InnerException != null) + { + sb.AppendLine("InnerException:"); + sb.Append(ex.InnerException.GetInfoAsPlainText()); + } + + return sb.ToString(); + } + + public static string GetInfoAsHtml(this Exception ex) + { + return ex.GetInfoAsPlainText().Replace(Environment.NewLine, "
"); + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Http/FluentHttpClient.cs b/3-app/1-lib/Kit.Helpers/Http/FluentHttpClient.cs new file mode 100644 index 0000000..72bee42 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Http/FluentHttpClient.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Kit.Helpers.JsonResult; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Kit.Helpers +{ + /// + /// Тонкий fluent-builder HTTP-вызова поверх — порт + /// RiskProf.Helpers.FluentHttpClient. Стиль вызова: + /// uri.PrepareHttpPost().WithSecurityKey(..).WithParameter(..) + /// .ExecuteRequestJsonResultModel().JsonDeserialize<T>(). + /// + /// POST-параметры () уходят JSON-объектом (контроллеры на [FromBody]). + /// Два способа получить ответ: + /// + /// — разворачивает конверт + /// : Header.IsSuccess=false → исключение с Header.Message, + /// иначе наружу отдаётся JSON поля Result. + /// — отдаёт СЫРОЕ тело без разворота конверта. + /// + /// + /// + public class FluentHttpClient + { + private readonly Uri _requestUrl; + private readonly HttpMethod _httpMethod; + private readonly List> _parameters = new(); + private string _securityKey; + private string _jsonContent; + + public FluentHttpClient(Uri requestUrl, HttpMethod httpMethod) + { + _requestUrl = requestUrl; + if (httpMethod != HttpMethod.Get && httpMethod != HttpMethod.Post) + throw new ArgumentOutOfRangeException(nameof(httpMethod), "only POST and GET methods supported"); + _httpMethod = httpMethod; + } + + /// Ключ межсервисного доступа (заголовок X-Requested-Token). + public FluentHttpClient WithSecurityKey(string securityKey) + { + _securityKey = securityKey; + return this; + } + + /// Параметр запроса: для GET — query-строка, для POST — поле JSON-тела. + public FluentHttpClient WithParameter(string name, TValue value) + { + _parameters.Add(new KeyValuePair(name, value?.ToString() ?? string.Empty)); + return this; + } + + /// Готовое JSON-тело POST (приоритетнее набора ). + public FluentHttpClient WithJsonContent(TObject obj) + { + if (_httpMethod != HttpMethod.Post) + throw new ArgumentOutOfRangeException(nameof(obj), "JSON content only supported for POST"); + _jsonContent = obj.JsonSerialize(); + return this; + } + + /// Отправить запрос и вернуть JSON поля Result развёрнутого конверта + /// (Header.IsSuccess=false → исключение). Для эндпоинтов, + /// помеченных JsonResultModelWrapFilter (отдают конверт). + public Task ExecuteRequestJsonResultModel() + { + return HttpHelper.ExecuteRequest(BuildRequest(), Unwrap, errorHandler: null, securityKey: _securityKey); + } + + /// Отправить запрос и вернуть СЫРОЕ тело ответа БЕЗ разворота конверта. Для эндпоинтов, + /// которые не проходят через JsonResultModelWrapFilter и отдают значение напрямую (без + /// ). Бизнес-ошибки тут не распознаются — на не-2xx + /// бросит HttpRequestException. + public Task ExecuteRequest() + { + return HttpHelper.ExecuteRequest(BuildRequest(), responseHandler: null, errorHandler: null, securityKey: _securityKey); + } + + // Сборка HTTP-запроса (общая для обёрнутого и сырого вариантов). + private Func> BuildRequest() + { + if (_httpMethod == HttpMethod.Get) + { + Uri uri = AppendQuery(_requestUrl, _parameters); + return client => client.GetAsync(uri); + } + + string json = _jsonContent ?? ParametersToJson(_parameters); + return client => client.PostAsync(_requestUrl, + new StringContent(json, Encoding.UTF8, "application/json")); + } + + // ---- разворот конверта JsonResultModel: Header.IsSuccess=false → исключение, иначе поле Body ---- + private static Task Unwrap(HttpResponseMessage response, string body) + { + JsonResultModel envelope = body.JsonDeserialize>(); + + JsonResultHeader header = envelope?.Header; + if (header == null || !header.IsSuccess) + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(header?.Message) ? "сервис вернул ошибку" : header.Message); + + // SerializeObject возвращает валидный JSON и для примитивов ("token"/true/5), и для объектов. + return Task.FromResult(envelope.Body == null ? "null" : JsonConvert.SerializeObject(envelope.Body)); + } + + // POST-параметры → JSON-объект (контроллеры принимают [FromBody]). + private static string ParametersToJson(IEnumerable> parameters) + { + var obj = new JObject(); + foreach (KeyValuePair p in parameters) obj[p.Key] = p.Value; + return obj.ToString(Formatting.None); + } + + private static Uri AppendQuery(Uri uri, List> parameters) + { + if (parameters.Count == 0) return uri; + + string query = string.Join("&", parameters.Select(p => + $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value ?? string.Empty)}")); + + var builder = new UriBuilder(uri); + builder.Query = string.IsNullOrEmpty(builder.Query) + ? query + : builder.Query.TrimStart('?') + "&" + query; + return builder.Uri; + } + } + + /// Точки входа в fluent-цепочку (как RiskProf.Helpers.FluentHttpClient_Extensions). + public static class FluentHttpClientExtensions + { + public static FluentHttpClient PrepareHttpGet(this Uri requestUri) + { + return new FluentHttpClient(requestUri, HttpMethod.Get); + } + + public static FluentHttpClient PrepareHttpPost(this Uri requestUri) + { + return new FluentHttpClient(requestUri, HttpMethod.Post); + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/HttpContext.cs b/3-app/1-lib/Kit.Helpers/HttpContext.cs new file mode 100644 index 0000000..395e08a --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/HttpContext.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using System; +using System.Linq; + +namespace Kit.Helpers +{ + public static class HttpContextCore + { + private static IServiceProvider _serviceProvider; + private static bool _notWebApp; + + public static void Configure(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + _notWebApp = false; + } + + public static void ConfigureNotWebApp() + { + _notWebApp = true; + } + + public static HttpContext Current + { + get + { + if (_notWebApp) + { + return null; + } + object factory = _serviceProvider.GetService(typeof(IHttpContextAccessor)); + + HttpContext context = ((HttpContextAccessor)factory).HttpContext; + + return context; + } + } + + private static readonly string _contextHostUrl = "HostUrl"; + + public static string GetHostUrl(this HttpContext context) + { + context.Items[_contextHostUrl] = context.Items[_contextHostUrl] as string ?? context.GenerateHostUrl(); + return context.Items[_contextHostUrl]!.ToString()!; + } + + public static string GetRootUrl(this HttpContext context) + { + return $"{GetHostUrl(context)}{context.Request.PathBase}{context.Request.Path}"; + } + + private static string GenerateHostUrl(this HttpContext context) + { + HttpRequest request = context.Request; + + // Формируем RootUrl + string scheme = request.Scheme; + + if (request.Headers.TryGetValue("X-Forwarded-Proto", out StringValues forwardedProtoValues) && forwardedProtoValues.Count > 0) + { + scheme = forwardedProtoValues.First(); + } + + return $"{scheme}://{request.Host}"; + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/HttpHelper.cs b/3-app/1-lib/Kit.Helpers/HttpHelper.cs new file mode 100644 index 0000000..7070e03 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/HttpHelper.cs @@ -0,0 +1,310 @@ +namespace Kit.Helpers +{ + using System; + using System.Collections.Generic; + using System.Collections.Specialized; + using System.IO; + using System.Linq; + using System.Net.Http; + using System.Net.Http.Headers; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Http; + using System.Web; + + public class HttpRequestException : Exception + { + public HttpRequestException(HttpResponseMessage response, string responseBody) + : base(response.ReasonPhrase) + { + this.Response = response; + this.ResponseBody = responseBody; + } + public string ResponseBody { get; protected set; } + public HttpResponseMessage Response { get; protected set; } + } + + public static class HttpHelper_Extensions + { + public static Uri ToUri(this string url) + { + return new Uri(url); + } + + public static string Append(this string url, params string[] paths) + { + if (url == null) url = string.Empty; + string[] urlParts = url.Split('?'); + url = paths.Aggregate(urlParts[0], (current, path) => string.Format("{0}/{1}", current.TrimEnd('/'), path.TrimStart('/'))); + if (urlParts.Length > 1) url += "?" + urlParts[1]; + return url; + } + + public static Uri Append(this Uri uri, params string[] paths) + { + return new Uri(uri.AbsoluteUri.Append(paths)); + } + + public static Uri SetQueryParameters(this Uri uri, IEnumerable> parameters) + { + var ub = new UriBuilder(uri); + NameValueCollection nvc = HttpUtility.ParseQueryString(ub.Query); + parameters.ToList().ForEach(x => + { + nvc[x.Key] = x.Value; + }); + ub.Query = nvc.ToString(); + return ub.Uri; + } + + public static void WriteStream(this HttpResponse response, Stream stream, string fileName, string contentType = null) + { + //response.Clear(); + //response.ContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType; + //response.Headers.Add("Content-Disposition", $"attachment; filename={fileName}"); + //response.SendFileAsync() + //stream.CopyTo(response.OutputStream); + //response.Flush(); + //response.End(); + } + + public static void WriteStream(this HttpResponse response, string filePath, string fileName, string contentType = null) + { + response.Clear(); + response.ContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType; + response.Headers.Add("Content-Disposition", $"attachment; filename={fileName}"); + response.SendFileAsync(filePath); + } + + public static void SetAuthHeader(this HttpClient client, string securityKey) + { + client.DefaultRequestHeaders.Add("X-Requested-Token", securityKey); // = new AuthenticationHeaderValue() + } + } + + public static class HttpHelper + { + public static async Task ExecuteGetUrl(Uri uri + , IEnumerable> parameters + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + string responseBody = await HttpHelper.ExecuteGetUrl(uri, parameters, responseHandler, errorHandler, securityKey).ConfigureAwait(false); + return responseBody.JsonDeserialize(); + } + + public static async Task ExecuteGetUrl(Uri uri + , IEnumerable> parameters + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + return await HttpHelper.ExecuteRequest(async x => await x.GetAsync(uri.SetQueryParameters(parameters)).ConfigureAwait(false), responseHandler, errorHandler, securityKey).ConfigureAwait(false); + } + + public static async Task ExecuteGetStreamUrl(Uri uri + , IEnumerable> parameters + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + return await HttpHelper.ExecuteStreamRequest(async x => await x.GetAsync(uri.SetQueryParameters(parameters)).ConfigureAwait(false), responseHandler, errorHandler, securityKey).ConfigureAwait(false); + } + + public static async Task ExecutePostUrl(Uri uri + , IEnumerable> parameters = null + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + return await HttpHelper.ExecuteRequest(async x => await x.PostAsync(uri, new FormUrlEncodedContent(parameters)).ConfigureAwait(false), responseHandler, errorHandler, securityKey).ConfigureAwait(false); + } + + public static async Task ExecutePostUrl(Uri uri + , IEnumerable> parameters = null + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + string responseBody = await HttpHelper.ExecuteRequest(async x => await x.PostAsync(uri, new FormUrlEncodedContent(parameters)).ConfigureAwait(false), responseHandler, errorHandler, securityKey).ConfigureAwait(false); + return responseBody.JsonDeserialize(); + } + + public static async Task ExecutePostString(Uri uri + , string content = null + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + return await HttpHelper.ExecuteRequest(async x => await x.PostAsync(uri, new StringContent(content)).ConfigureAwait(false), responseHandler, errorHandler, securityKey).ConfigureAwait(false); + } + + public static async Task ExecutePostString(Uri uri + , string content = null + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + string responseBody = await HttpHelper.ExecuteRequest(async x => await x.PostAsync(uri, new StringContent(content)).ConfigureAwait(false), responseHandler, errorHandler, securityKey).ConfigureAwait(false); + return responseBody.JsonDeserialize(); + } + + public static async Task ExecutePostObject(Uri uri + , TObject obj + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + string requestBody = obj.JsonSerialize(); + return await HttpHelper.ExecutePostString(uri, requestBody, responseHandler, errorHandler, securityKey).ConfigureAwait(false); + } + + public static async Task ExecutePostObject(Uri uri + , TRequest obj + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + string requestBody = obj.JsonSerialize(); + string responseBody = await HttpHelper.ExecutePostString(uri, requestBody, responseHandler, errorHandler, securityKey).ConfigureAwait(false); + return responseBody.JsonDeserialize(); + } + + public static async Task ExecutePostFiles(Uri uri + , IEnumerable files + , IEnumerable> parameters = null + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + var formData = new MultipartFormDataContent(); + if (files != null) + { + int i = 1; + foreach (FileStream fileStream in files) + { + HttpContent fileStreamContent = new StreamContent(fileStream); + formData.Add(fileStreamContent, string.Concat("file_", i++.ToString()), fileStream.Name); + } + } + + if (parameters != null) + { + foreach (var keyValuePair in parameters) + { + HttpContent stringContent = new StringContent(keyValuePair.Value); + formData.Add(stringContent, keyValuePair.Key); + } + } + + string responseBody = await HttpHelper.ExecuteRequest(async x => await x.PostAsync(uri, formData).ConfigureAwait(false), responseHandler, errorHandler, securityKey).ConfigureAwait(false); + return responseBody.JsonDeserialize(); + } + public static async Task ExecutePostStreamUrl(Uri uri + , IEnumerable files + , IEnumerable> parameters + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + var formData = new MultipartFormDataContent(); + if (files != null) + { + int i = 1; + foreach (FileStream fileStream in files) + { + HttpContent fileStreamContent = new StreamContent(fileStream); + formData.Add(fileStreamContent, string.Concat("file_", i++.ToString()), fileStream.Name); + } + } + if (parameters != null) + { + foreach (var keyValuePair in parameters) + { + HttpContent stringContent = new StringContent(keyValuePair.Value); + formData.Add(stringContent, keyValuePair.Key); + } + } + return await HttpHelper.ExecuteStreamRequest(async x => await x.PostAsync(uri, formData).ConfigureAwait(false), responseHandler, errorHandler, securityKey).ConfigureAwait(false); + } + + public static async Task ExecuteRequest( + Func> createRequest + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + using (var client = new HttpClient()) + { + client.Timeout = TimeSpan.FromSeconds(3600); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // add auth header + client.SetAuthHeader(securityKey); + + using (HttpResponseMessage response = await createRequest(client).ConfigureAwait(false)) + { + // считываем содержимое ответа + string responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + if (response.Content != null) response.Content.Dispose(); + + // если что-то произошло на стороне сервера + if (!response.IsSuccessStatusCode) + { + if (errorHandler != null) return await errorHandler(response, responseBody).ConfigureAwait(false); + throw new HttpRequestException(response, responseBody); + } + + return responseHandler != null + ? await responseHandler(response, responseBody).ConfigureAwait(false) + : responseBody; + } + } + } + + + public static async Task ExecuteStreamRequest( + Func> createRequest + , Func> responseHandler = null + , Func> errorHandler = null + , string securityKey = null + ) + { + using (var client = new HttpClient()) + { + client.Timeout = TimeSpan.FromSeconds(3600); + + // add auth header + client.SetAuthHeader(securityKey); + HttpResponseMessage response = await createRequest(client).ConfigureAwait(false); + + // считываем содержимое ответа + Stream responseBody = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); + string responseBodyString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + // если что-то произошло на стороне сервера + if (!response.IsSuccessStatusCode) + { + if (errorHandler != null) return await errorHandler(response, responseBody).ConfigureAwait(false); + throw new HttpRequestException(response, responseBodyString); + } + + return responseHandler != null + ? await responseHandler(response, responseBody).ConfigureAwait(false) + : responseBody; + } + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/JsonHelper.cs b/3-app/1-lib/Kit.Helpers/JsonHelper.cs new file mode 100644 index 0000000..7961ca3 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/JsonHelper.cs @@ -0,0 +1,31 @@ +namespace Kit.Helpers +{ + using Newtonsoft.Json; + using System.Threading.Tasks; + + public static class JsonHelper + { + public static System.Text.Json.JsonSerializerOptions GlobalJsonSettings = new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = false, + }; + + public static TObject JsonDeserialize(this string json) + { + if (json == null || string.IsNullOrWhiteSpace(json)) return default(TObject); + return JsonConvert.DeserializeObject(json, new JsonSerializerSettings { DateFormatHandling = DateFormatHandling.IsoDateFormat }); + } + + public static string JsonSerialize(this TObject serializeObject) + { + if (serializeObject == null) return "{}"; + return JsonConvert.SerializeObject(serializeObject, new JsonSerializerSettings { DateFormatHandling = DateFormatHandling.IsoDateFormat }); + } + + public static async Task JsonDeserialize(this Task task) + { + string json = await task.ConfigureAwait(false); + return await Task.Run(() => json.JsonDeserialize()).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/JsonResult/JsonResultModel.cs b/3-app/1-lib/Kit.Helpers/JsonResult/JsonResultModel.cs new file mode 100644 index 0000000..d7467b0 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/JsonResult/JsonResultModel.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Kit.Helpers.JsonResult +{ + public class JsonResultHeader + { + public bool IsSuccess { get; set; } + public string Message { get; set; } + } + + public class JsonResultModel + { + public JsonResultModel() { + Header = new JsonResultHeader(); + } + public JsonResultHeader Header { get; set; } + public T Body { get; set; } + } + + // Закрытые (non-generic) наследники — без собственных членов, + // только фиксируют T (удобно для десериализации конкретного типа): + public class JsonResultModelString : JsonResultModel { } + public class JsonResultModelBool : JsonResultModel { } + public class JsonResultModelObject : JsonResultModel { } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/JsonResult/JsonResultModelWrapFilter.cs b/3-app/1-lib/Kit.Helpers/JsonResult/JsonResultModelWrapFilter.cs new file mode 100644 index 0000000..c6ee9d2 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/JsonResult/JsonResultModelWrapFilter.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Kit.Helpers.JsonResult +{ + /// + /// Глобальный фильтр-обёртка ответа (правило 05). Если у маршрута выставлен признак + /// Route.DataTokens["jsonResultModel"] — он ставится fluent-методами + /// RouteFluent.JsonResultModel() / ControllerRouteFluent.JsonResultModel_All() — + /// результат экшена оборачивается в конверт : + /// • успех: Body = значение, Header.IsSuccess = true; + /// • исключение: Header.IsSuccess = false, Header.Message = ex.Message (всегда HTTP 200). + /// Уже сформированный повторно не оборачивается — обратная + /// совместимость с экшенами, которые сами возвращают конверт через Json(result). + /// Регистрируется как глобальный MVC-фильтр, как и SecurityKeyAuthorizationFilter. + /// Маршруты без признака проходят без изменений. + /// + public sealed class JsonResultModelWrapFilter : IActionFilter + { + /// Ключ DataToken, который ставит RouteFluent.JsonResultModel. + public const string DataTokenKey = "jsonResultModel"; + + public void OnActionExecuting(ActionExecutingContext context) { } + + public void OnActionExecuted(ActionExecutedContext context) + { + if (!IsEnabled(context)) return; + + // 1. Ошибка в экшене -> конверт с сообщением (статус 200). + if (context.Exception != null && !context.ExceptionHandled) + { + context.Result = Wrap(null, ok: false, message: context.Exception.Message); + // Без этого context.Result отбрасывается, а исключение всплывает в HTTP 500. + context.ExceptionHandled = true; + return; + } + + // 2. «Сырое» значение результата (ObjectResult / JsonResult). Прочие типы не оборачиваем. + object value; + switch (context.Result) + { + case ObjectResult o: value = o.Value; break; + case Microsoft.AspNetCore.Mvc.JsonResult j: value = j.Value; break; + default: return; // Redirect/File/Content/StatusCode/Empty — оставляем как есть + } + + // 3. Уже конверт -> не оборачиваем повторно. + if (IsEnvelope(value)) return; + + context.Result = Wrap(value, ok: true, message: null); + } + + private static bool IsEnabled(FilterContext context) + => context.RouteData?.DataTokens != null + && context.RouteData.DataTokens.TryGetValue(DataTokenKey, out var v) + && (v is not bool b || b); // признак-флаг: true либо просто наличие ключа + + private static bool IsEnvelope(object value) + { + for (var t = value?.GetType(); t != null && t != typeof(object); t = t.BaseType) + { + if (t.IsGenericType + && t.GetGenericTypeDefinition().FullName == "Kit.Helpers.JsonResult.JsonResultModel`1") + { + return true; + } + } + return false; + } + + private static Microsoft.AspNetCore.Mvc.JsonResult Wrap(object body, bool ok, string message) + { + var model = new JsonResultModel + { + Body = body, + Header = new JsonResultHeader { IsSuccess = ok, Message = message }, + }; + // Обёрнутый ответ ВСЕГДА HTTP 200 (и успех, и ошибка) — исход несёт Header.IsSuccess, + // а не статус-код; клиент сам бросает по Header.IsSuccess=false. + return new Microsoft.AspNetCore.Mvc.JsonResult(model) { StatusCode = StatusCodes.Status200OK }; + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Kit.Helpers.csproj b/3-app/1-lib/Kit.Helpers/Kit.Helpers.csproj new file mode 100644 index 0000000..d8d8098 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Kit.Helpers.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + diff --git a/3-app/1-lib/Kit.Helpers/Log/CallStackLog.cs b/3-app/1-lib/Kit.Helpers/Log/CallStackLog.cs new file mode 100644 index 0000000..ab66231 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Log/CallStackLog.cs @@ -0,0 +1,192 @@ + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text; + +namespace Kit.Helpers.Log +{ + public interface ICallStackLog + { + string LogMessage { get; } + ICallStackLog NextLevel(); + void FinishLevel(); + void BeginCall(string message); + void EndCall(); + } + + public class EmptyCallStackLog : ICallStackLog + { + public string LogMessage + { + get { return ""; } + } + + public ICallStackLog NextLevel() + { + return CallStackLog.Empty; + } + + public void FinishLevel() + { + return; + } + + public void BeginCall(string message) + { + return; + } + + public void EndCall() + { + return; + } + } + + public class CallStackLog : ICallStackLog + { + protected ConcurrentDictionary Logs { get; } + protected ConcurrentBag ChildCallStackLogs { get; set; } + protected readonly int _level; + protected readonly string _path; + protected string _currentCall; + protected int _number; + protected readonly Stopwatch _stopwatch = new Stopwatch(); + public static ICallStackLog Empty { get { return new EmptyCallStackLog(); } } + + protected string Number + { + get + { + if (_number <= 9) + return "0" + _number; + return _number.ToString(); + } + } + public CallStackLog() + { + Logs = new ConcurrentDictionary(); + ChildCallStackLogs = new ConcurrentBag(); + _level = 0; + _path = ""; + _number = 1; + } + + public CallStackLog(int level, string path) + { + Logs = new ConcurrentDictionary(); + ChildCallStackLogs = new ConcurrentBag(); + _level = level; + _path = path; + _number = 1; + } + + public string LogMessage + { + get + { + StringBuilder stringBuilder = new StringBuilder(); + foreach (var logLine in Logs.OrderBy(x => x.Key)) + { + stringBuilder.AppendLine(logLine.Key + $". Time - {logLine.Value} ms"); + } + return stringBuilder.ToString(); + } + } + public ConcurrentDictionary GetLogs + { + get { + return this.Logs; + } + } + + public ICallStackLog NextLevel() + { + var childCallStackLog = new CallStackLog(_level + 1, _path + this.Number + "."); + ChildCallStackLogs.Add(childCallStackLog); + return childCallStackLog; + } + + public void FinishLevel() + { + foreach (CallStackLog childCallStackLog in ChildCallStackLogs) + { + lock (Logs) + { + foreach (var logLine in childCallStackLog.Logs) + { + if (Logs.ContainsKey(logLine.Key)) + { + Logs[logLine.Key] += logLine.Value; + continue; + } + + Logs.TryAdd(logLine.Key, logLine.Value); + } + } + } + + ChildCallStackLogs = new ConcurrentBag(); + } + + public void BeginCall(string message) + { + _currentCall = message; + _stopwatch.Restart(); + } + + public void EndCall() + { + _stopwatch.Stop(); + lock (Logs) + { + Logs.TryAdd($"{_path}{this.Number} {_currentCall.ToStringOrEmpty()}", _stopwatch.ElapsedMilliseconds); + _number++; + } + } + + + } + + + public class CallStackLogDB: CallStackLog, IDisposable + { + protected string _sessionKey; + protected LogMessageType _type; + protected string _target; + protected string _action; + protected DateTime _dateCreated; + private bool disposed = false; + public CallStackLogDB(string sessionKey, LogMessageType type, string target, string action):base() + { + _sessionKey = sessionKey; + _type = type; + _target = target; + _action = action; + _dateCreated = DateTime.UtcNow; + } + + public void Dispose() + { + Dispose(true); + // подавляем финализацию + GC.SuppressFinalize(this); + + + } + + protected virtual void Dispose(bool disposing) + { + if (!disposed) + { + if (disposing) + { + this.Logs.TryAdd($"Full Time", Convert.ToInt64((DateTime.UtcNow - _dateCreated).TotalMilliseconds)); + + Kit.Helpers.Log.LoggerDB.Commit(this._sessionKey, this._type, this._target, this._action, _dateCreated, this.Logs.ToDictionary(x => x.Key, x => x.Value)); + } + // освобождаем неуправляемые объекты + disposed = true; + } + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Log/Logger.cs b/3-app/1-lib/Kit.Helpers/Log/Logger.cs new file mode 100644 index 0000000..6a5e7c6 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Log/Logger.cs @@ -0,0 +1,152 @@ +namespace Kit.Helpers.Log +{ + using System; + using System.Collections.Generic; + using System.Configuration; + using System.IO; + using System.Linq; + using System.Net.Mail; + using System.Text; + using System.Threading.Tasks; + + + public enum LogMessageType + { + Debug = 1, + Info, + Warn, + Error, + } + + public static class Logger + { + private static string _pathLogFolder; + private static string _emailas; + private static bool _isSendEmail; + static Logger() + { + //var config = new ConfigurationHelper("Log"); + //_pathLogFolder = config.GetAppSettingValue("PathFolder"); + + //if (_pathLogFolder.IsNullOrEmpty() || Directory.Exists(_pathLogFolder) == false) + // _pathLogFolder = Path.GetTempPath(); + + //_isSendEmail = config.GetAppSettingValue("IsSendMail").TryParseToBool(false); + //_emailas = config.GetAppSettingValue("Emails"); + } + + public static string GetCurrentFullFileName() + { + var dt = DateTime.UtcNow; + return Path.Combine(_pathLogFolder, String.Format("log_{0}_{1}_{2}.log",dt.Day, dt.Month, dt.Year)); + } + + public static string GetNameMessageType(LogMessageType type) + { + switch (type) + { + case LogMessageType.Debug: + return "Дебаг"; + case LogMessageType.Error: + return "Ошибка"; + case LogMessageType.Info: + return "Информация"; + case LogMessageType.Warn: + return "Предупреждение"; + } + return String.Empty; + } + + public static void Log(LogMessageType type, string log) + { + string path = GetCurrentFullFileName(); + using (var file = File.AppendText(path)) + { + var str = String.Format("{0}:({1}) {2}" + Environment.NewLine, DateTime.UtcNow, GetNameMessageType(type), log); + file.WriteLine(str); + if(_isSendEmail && type == LogMessageType.Error) + SendMail( new StringBuilder(str)); + } + } + + public static void Log(LogMessageType type, string log, params object[] args) + { + Log(type,String.Format(log, args)); + } + + public static void LogError(string header, string error) + { + using (var file = File.AppendText(GetCurrentFullFileName())) + { + file.WriteLine(String.Format("{0}:({1}) {2}", DateTime.UtcNow, GetNameMessageType(LogMessageType.Error), header)); + file.WriteLine(String.Format("Trace: {0}" + Environment.NewLine, error)); + } + } + + + public static void LogInfo(string log, params object[] args) + { + Log(LogMessageType.Info, String.Format(log, args)); + } + + public static void LogWarn(string log, params object[] args) + { + Log(LogMessageType.Warn, String.Format(log, args)); + } + + + public static void SendMail(StringBuilder errorText) + { + List gettersMailMessages = new List(); + List Getters = new List(); + + foreach (var mail in _emailas.Replace(",", ";").Split(';')) + { + Getters.Add(mail); + } + + if (!string.IsNullOrEmpty(errorText.ToString())) + { + + foreach (var getter in Getters) + { + var mail = new MailMessage + { + From = new MailAddress("noreply@aetalon.ru", "Ошибка Эталон"), + Subject = "Ошибка Эталон", + IsBodyHtml = false, + Body = errorText.ToString() + }; + + mail.Body = mail.Body.Replace("_EMAIL_RECEIVER_", Base64Encode(errorText.ToString())); + mail.To.Add(getter); + gettersMailMessages.Add(mail); + } + } + + + using (var smtp = new SmtpClient()) + { + foreach (var mail in gettersMailMessages) + { + try + { + smtp.Send(mail); + } + catch (Exception e) + { + + } + } + + } + + } + + public static string Base64Encode(string plainText) + { + var plainTextBytes = System.Text.Encoding.UTF8.GetBytes(plainText); + return System.Convert.ToBase64String(plainTextBytes); + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Log/LoggerDB.cs b/3-app/1-lib/Kit.Helpers/Log/LoggerDB.cs new file mode 100644 index 0000000..ec35ea4 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Log/LoggerDB.cs @@ -0,0 +1,145 @@ +namespace Kit.Helpers.Log +{ + using Kit.Helpers.MsSql; + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Configuration; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + + public enum TypeInfo { Debug = 1, Info, Warn, Error, } + + + public class LoggerKey : IDisposable + { + private bool disposed = false; + public Guid Key {get;set;} + public void Dispose() + { + + Dispose(true); + // подавляем финализацию + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposed) + { + if (disposing) + { + LoggerDB.Commit(this); + } + // освобождаем неуправляемые объекты + disposed = true; + } + } + + // Деструктор + ~LoggerKey() + { + Dispose(false); + } + } + + public class LoggerItems + { + public string SessionKey { get; set; } + public LogMessageType LogMessageType { get; set; } + public DateTime DateCreated { get; set; } + public string Target { get; set; } + public string Action { get; set; } + public string Message { get; set; } + public double Duration { get; set; } + } + + public static class LoggerDB + { + private static string _connectionString; + private static bool _isRun; + + private static ConcurrentDictionary _items = new ConcurrentDictionary(); + + + static LoggerDB() + { + _connectionString = ConfigurationManager.ConnectionStrings["Logger.Db"].ConnectionString; + //var config = new ConfigurationHelper("LogDb"); + //_isRun = config.GetAppSettingValue("IsRun").TryParseToBool(false); + } + + public static LoggerKey Log(string sessionKey, LogMessageType type,string target, string action, string message) + { + return Log(sessionKey, type, DateTime.UtcNow, target, action, message); + } + public static LoggerKey Log(string sessionKey,LogMessageType type,DateTime dateCreated, string target,string action, string message) + { + if (_isRun) + { + Guid key = Guid.NewGuid(); + _items.TryAdd(key, new LoggerItems + { + SessionKey = sessionKey, + Action = action, + DateCreated = dateCreated, + Duration = 0, + LogMessageType = type, + Message = message, + Target = target + }); + + return new LoggerKey { Key = key }; + } + return new LoggerKey { Key = Guid.Empty }; ; + } + + public static void Commit(LoggerKey key,DateTime? finishDate = null) + { + if(_items.ContainsKey(key.Key)) + { + LoggerItems item = _items.GetByKey(key.Key); + + double duration = ((finishDate ?? DateTime.UtcNow) - item.DateCreated).TotalMilliseconds; + _connectionString.ExecuteScalar("[Logs].[Insert_Logger]", true, new List> + { + new KeyValuePair("@LogMessageType", (int)item.LogMessageType), + new KeyValuePair("@SessionKey", item.SessionKey), + new KeyValuePair("@DateCreate", item.DateCreated), + new KeyValuePair("@Target", item.Target), + new KeyValuePair("@Action", item.Action), + new KeyValuePair("@Message", item.Message), + new KeyValuePair("@Duration", duration), + }); + + _items.TryRemove(key.Key, out item); + } + } + public static void Commit(params LoggerKey[] keys) + { + foreach (LoggerKey key in keys) + { + Commit(key); + } + } + + public static void Commit(string sessionKey, LogMessageType type, string target, string action, DateTime dateTime, Dictionary logs) + { + foreach (var log in logs) + { + _connectionString.ExecuteScalar("[Logs].[Insert_Logger]", true, new List> + { + new KeyValuePair("@LogMessageType", (int)type), + new KeyValuePair("@SessionKey", sessionKey), + new KeyValuePair("@DateCreate", dateTime), + new KeyValuePair("@Target", target), + new KeyValuePair("@Action", action), + new KeyValuePair("@Message", log.Key), + new KeyValuePair("@Duration", log.Value), + }); + } + + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Repository/Converter/IConverter.cs b/3-app/1-lib/Kit.Helpers/Repository/Converter/IConverter.cs new file mode 100644 index 0000000..e4c4a26 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/Converter/IConverter.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Kit.Helpers.Repository.Converter +{ + public interface IConverter + { + TOutput Convert(TInput input); + } + public interface IEnumerableConverter : + IConverter, IEnumerable> { } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Repository/DictionaryRepository.cs b/3-app/1-lib/Kit.Helpers/Repository/DictionaryRepository.cs new file mode 100644 index 0000000..935a6cf --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/DictionaryRepository.cs @@ -0,0 +1,74 @@ +#if NETCOREAPP +using Microsoft.Extensions.Caching.Memory; +#endif +using Kit.Helpers.Cache; +using System; +using System.Collections.Generic; + +namespace Kit.Helpers.Repository +{ + public abstract class DictionaryRepository:IDictionaryRepository + where TEntity:IPersistentKey + { + private string _cacheKey; + private ICacheProvider _cacheProvider; + public DictionaryRepository(ICacheProvider cacheProvider, string cacheKey) + { + _cacheProvider = cacheProvider; + _cacheKey = cacheKey; + } + + // лучше сделать абстрактным + protected abstract IEnumerable GetData(); + + public IDictionary Select() + { + IDictionary values = null; + if(_cacheKey.IsNullOrEmpty()) + { + throw new ArgumentNullException("Не задан ключ кеширования для репозитория: {0}".ApplyFormat(this.GetType().ToString())); + } + + if (_cacheProvider.Contains(_cacheKey)) + { + values = (IDictionary)_cacheProvider.GetCacheData(_cacheKey); + } + else + { + + IEnumerable data = this.GetData(); + values = new Dictionary(); + // добавить обработку дубликатов + foreach (var item in data) + { + TKey key = item.PersistentKey; + if (values.ContainsKey(key)) + { + throw new Exception("Элемент с ключом {0} уже добавлен в коллекцию".ApplyFormat(key)); + } + values.Add(item.PersistentKey, item); + } + +#if NETFRAMEWORK + _cacheProvider.SetCacheData(_cacheKey, values, new CacheSetProperties { DurationSeconds = 3600, EnableCaching = true }); +#elif NETCOREAPP + _cacheProvider.SetCacheData(_cacheKey, values, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(3600), Priority = CacheItemPriority.Normal }); +#endif + } + return values; + } + public TEntity Get(TKey key) + { + IDictionary values = this.Select(); + if (values.ContainsKey(key)) + return values[key]; + else + return default(TEntity); + } + + public void ClearCache() + { + _cacheProvider.PurgeCacheItems(_cacheKey); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Repository/DictionaryRepositoryLazy.cs b/3-app/1-lib/Kit.Helpers/Repository/DictionaryRepositoryLazy.cs new file mode 100644 index 0000000..c9b3fca --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/DictionaryRepositoryLazy.cs @@ -0,0 +1,97 @@ +#if NETCOREAPP +using Microsoft.Extensions.Caching.Memory; +#endif +using Kit.Helpers.Cache; +using System; +using System.Collections.Generic; + +namespace Kit.Helpers.Repository +{ + public abstract class DictionaryRepositoryLazy : IDictionaryRepository + where TEntity : IPersistentKey + { + private readonly string _cacheKey; + private readonly ICacheProvider _cacheProvider; +#if NETCOREAPP + private readonly MemoryCacheEntryOptions _cacheOptions = new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), + Priority = CacheItemPriority.Normal + }; +#elif NETFRAMEWORK + private readonly CacheSetProperties _cacheOptions = new CacheSetProperties { DurationSeconds = 3600, EnableCaching = true }; +#endif + private readonly object _lockObject = new object(); + + public DictionaryRepositoryLazy(ICacheProvider cacheProvider, string cacheKey) + { + _cacheProvider = cacheProvider; + _cacheKey = cacheKey; + } + + // лучше сделать абстрактным + protected abstract IEnumerable GetData(); + protected abstract TEntity GetData(TKey key); + + public IDictionary Select() + { + IDictionary values = null; + if (_cacheKey.IsNullOrEmpty()) + { + throw new ArgumentNullException("Не задан ключ кеширования для репозитория: {0}".ApplyFormat(this.GetType().ToString())); + } + + lock (_lockObject) + { + if (_cacheProvider.Contains(_cacheKey)) + { + values = (IDictionary)_cacheProvider.GetCacheData(_cacheKey); + return values; + } + + IEnumerable data = this.GetData(); + values = new Dictionary(); + // добавить обработку дубликатов + foreach (var item in data) + { + TKey key = item.PersistentKey; + if (values.ContainsKey(key)) + { + throw new Exception("Элемент с ключом {0} уже добавлен в коллекцию".ApplyFormat(key)); + } + values.Add(item.PersistentKey, item); + } + + _cacheProvider.SetCacheData(_cacheKey, values, _cacheOptions); + return values; + } + } + + public TEntity Get(TKey key) + { + lock (_lockObject) + { + IDictionary values = this.Select(); + if (values.ContainsKey(key)) + { + return values[key]; + } + + TEntity entity = this.GetData(key); + if (entity == null) + { + return default(TEntity); + } + + values.Add(key, entity); + _cacheProvider.SetCacheData(_cacheKey, values, _cacheOptions); + return entity; + } + } + + public void ClearCache() + { + _cacheProvider.PurgeCacheItems(_cacheKey); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Repository/HttpDictionaryCacheHelper.cs b/3-app/1-lib/Kit.Helpers/Repository/HttpDictionaryCacheHelper.cs new file mode 100644 index 0000000..707f898 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/HttpDictionaryCacheHelper.cs @@ -0,0 +1,52 @@ +namespace Kit.Helpers.Repository +{ + using System.Collections.Generic; + using Microsoft.AspNetCore.Http; + + public interface IDictionaryCacheProvider + { + void SetByKey(string cacheKey, TKey key, TValue value); + TValue GetByKey(string cacheKey, TKey key); + TValue GetByKey(string cacheKey, TKey key, TValue defaultValue); + } + + public static class HttpDictionaryCacheHelper + { + public static bool IsConfigured => _isConfigured; + public static IDictionaryCacheProvider Instance => _instance; + + private static bool _isConfigured = false; + private static IDictionaryCacheProvider _instance; + + public static void Configure(IDictionaryCacheProvider instance) + { + _instance = instance; + _isConfigured = _instance != null; + } + } + + public class HttpDictionaryCacheProvider : IDictionaryCacheProvider + { + public static HttpDictionaryCacheProvider Instance { get; } = new HttpDictionaryCacheProvider(); + public static void InitHelpers() => HttpDictionaryCacheHelper.Configure(Instance); + private HttpDictionaryCacheProvider() { } + private IDictionary GetOrCreateDictionary(string cacheKey) + { + HttpContext httpContext = HttpContextCore.Current; + if (httpContext == null) return null; + var cacheDictionary = httpContext.Items.ContainsKey(cacheKey) ? (Dictionary)httpContext.Items[cacheKey] : null; + + if (cacheDictionary == null) + { + cacheDictionary = new Dictionary(); + httpContext.Items[cacheKey] = cacheDictionary; + } + return cacheDictionary; + } + + public void SetByKey(string cacheKey, TKey key, TValue value) => GetOrCreateDictionary(cacheKey).SetByKey(key, value); + public TValue GetByKey(string cacheKey, TKey key) => GetOrCreateDictionary(cacheKey).GetByKey(key); + public TValue GetByKey(string cacheKey, TKey key, TValue defaultValue) => GetOrCreateDictionary(cacheKey).GetByKey(key, defaultValue); + + } +} diff --git a/3-app/1-lib/Kit.Helpers/Repository/IDictionaryRepository.cs b/3-app/1-lib/Kit.Helpers/Repository/IDictionaryRepository.cs new file mode 100644 index 0000000..39f4bb6 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/IDictionaryRepository.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Kit.Helpers.Repository +{ + public interface IDictionaryRepository + { + IDictionary Select(); + TEntity Get(TKey key); + void ClearCache(); + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Repository/IPersistentRepository.cs b/3-app/1-lib/Kit.Helpers/Repository/IPersistentRepository.cs new file mode 100644 index 0000000..376e7c1 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/IPersistentRepository.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace Kit.Helpers.Repository +{ + public interface IPersistentKey + { + TKey PersistentKey { get; } + } + + public interface IInt32PersistenKey : IPersistentKey { } + + public interface ISelectRepository + { + IEnumerable Select(TFilter filter); + + int Count(TFilter filter); + IEnumerable Select(TFilter filter, int page, int sizepage); + } + + public class NothingFilter { } + + public interface IPersistentRepository : ISelectRepository + { + TKey Insert(TEntity entity); + void Update(TKey key, TEntity entity); + void Delete(TKey key); + TEntity Get(TKey key); + // IEnumerable Get(IEnumerable keys); + } + + public interface IPersistentRepository : IPersistentRepository + { + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Repository/ISelectContext.cs b/3-app/1-lib/Kit.Helpers/Repository/ISelectContext.cs new file mode 100644 index 0000000..3afc55f --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/ISelectContext.cs @@ -0,0 +1,58 @@ +using Kit.Helpers.Log; +using System.Collections; +using System.Collections.Generic; + +namespace Kit.Helpers.Repository +{ + public interface ISelectContext : IDictionaryCacheProvider + { +#if NETCOREAPP + IDictionary Items { get; } +#elif NETFRAMEWORK + IDictionary Items { get; } +#endif + ICallStackLog CallStackLog { get; set; } + } + + public class SelectContext : ISelectContext + { + public SelectContext() + { + Items = new Dictionary(); + CallStackLog = Log.CallStackLog.Empty; + } + +#if NETCOREAPP + public IDictionary Items { get; } +#elif NETFRAMEWORK + public IDictionary Items { get; } +#endif + public ICallStackLog CallStackLog { get; set; } + + private Dictionary GetOrCreateDictionary(string cacheKey) + { +#if NETCOREAPP + Dictionary cacheDictionary = Items.ContainsKey(cacheKey) ? (Dictionary)Items[cacheKey] : null; +#elif NETFRAMEWORK + Dictionary cacheDictionary = Items.Contains(cacheKey) ? (Dictionary)Items[cacheKey] : null; +#endif + + if (cacheDictionary == null) + { + cacheDictionary = new Dictionary(); + Items[cacheKey] = cacheDictionary; + } + + return cacheDictionary; + } + + public void SetByKey(string cacheKey, TKey key, TValue value) => GetOrCreateDictionary(cacheKey).SetByKey(key, value); + public TValue GetByKey(string cacheKey, TKey key) => GetOrCreateDictionary(cacheKey).GetByKey(key); + public TValue GetByKey(string cacheKey, TKey key, TValue defaultValue) => GetOrCreateDictionary(cacheKey).GetByKey(key, defaultValue); + + public bool TryGet(string cacheKey, TKey key, TValue defaultValue, out TValue value) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Repository/Paging/IPageFilter.cs b/3-app/1-lib/Kit.Helpers/Repository/Paging/IPageFilter.cs new file mode 100644 index 0000000..3fb871b --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/Paging/IPageFilter.cs @@ -0,0 +1,9 @@ +using System.Linq; + +namespace Kit.Helpers.Repository.Paging +{ + public interface IPageFilter + { + IQueryable ChangePage(IQueryable query); + } +} diff --git a/3-app/1-lib/Kit.Helpers/Repository/Paging/ISqlPageFilter.cs b/3-app/1-lib/Kit.Helpers/Repository/Paging/ISqlPageFilter.cs new file mode 100644 index 0000000..127a321 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/Paging/ISqlPageFilter.cs @@ -0,0 +1,19 @@ +namespace Kit.Helpers.Repository.Paging +{ + + public interface ISqlPageFilter + { + int TotalRows { get; set; } + int? PageNo { get; set; } + int? PageSize { get; set; } + string Sort { get; set; } + } + + public class SqlPageFilter:ISqlPageFilter + { + public int TotalRows { get; set; } + public int? PageNo { get; set; } + public int? PageSize { get; set; } + public string Sort { get; set; } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Repository/Paging/PagingByPage.cs b/3-app/1-lib/Kit.Helpers/Repository/Paging/PagingByPage.cs new file mode 100644 index 0000000..1cdf6b1 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/Paging/PagingByPage.cs @@ -0,0 +1,72 @@ +using System.Linq; + +namespace Kit.Helpers.Repository.Paging +{ + /// + /// Фильтр с полями постраничной фильтрации. + /// Применяется, например в PagingController + /// + public class PagingByPage : IPageFilter + { + public int? Start { get; set; } + public int? Lenght { get; set; } + public string SortColumnName { get; set; } + public string SortDirection { get; set; } + public string Tabs { get; set; } + public string pageParameterId { get; set; } + + /// + /// Номер страницы. Нумерация с 1-цы + /// + public int? PageNo { get; set; } + public int? PageSize { get; set; } + public int? TotalRows { get; set; } + public int? CountPages { get; set; } + + /// + /// Есть значения для разбиения на страницы + /// + public bool HasSize + { + get + { + return + this.PageNo.HasValue && + (this.PageNo > 0) && + this.PageSize.HasValue && + (this.PageSize > 0); + } + } + + public string FormId + { + get + { + return _formId; + } + + set + { + _formId = value; + } + } + + public IQueryable ChangePage(IQueryable query) + { + if (query == null) return null; + + if (!(this.PageNo.HasValue && this.PageSize.HasValue)) return query; + + return query.Skip((this.PageNo.Value - 1) * this.PageSize.Value).Take(this.PageSize.Value); + } + + #region Fields --------------------------------------- + + /// + /// Для передачи в частичное представление Id формы, которая владеет просмотром страниц + /// + private string _formId = ""; + + #endregion + } +} diff --git a/3-app/1-lib/Kit.Helpers/Repository/Paging/PagingDatatable.cs b/3-app/1-lib/Kit.Helpers/Repository/Paging/PagingDatatable.cs new file mode 100644 index 0000000..b265ed1 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Repository/Paging/PagingDatatable.cs @@ -0,0 +1,21 @@ +namespace Kit.Helpers.Repository.Paging +{ + public class PagingDatatable + { + //public int? Start { get; set; } + //public int? Length { get; set; } + public string SearchValue { get; set; } + //public string SortColumnName { get; set; } + //public string SortDirection { get; set; } + + + //public int TotalCount { get; set; } + //public int FilterCount { get; set; } + public int? Start { get; set; } + public int? Length { get; set; } + public int FilterRow { get; set; } + public int TotalRow { get; set; } + public string SortColumnName { get; set; } + public string SortDirection { get; set; } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Routes/ControllerRouteFluent.cs b/3-app/1-lib/Kit.Helpers/Routes/ControllerRouteFluent.cs new file mode 100644 index 0000000..6303df4 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Routes/ControllerRouteFluent.cs @@ -0,0 +1,405 @@ +namespace Kit.Helpers.Routes +{ + using Microsoft.AspNetCore.Mvc; + using Microsoft.AspNetCore.Routing; + //using Microsoft.AspNetCore.Routing; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Linq.Expressions; + using System.Reflection; + using System.Xml.Linq; + + + public class ControllerRouteFluent : IEndPointFluent> + where TController : ControllerBase + { + private readonly IRouteBuilder _routeTable; + private readonly List _routes; + + public ControllerRouteFluent(IRouteBuilder routeTable) + { + _routeTable = routeTable; + _routes = new List(); + } + + #region Generic Result Actions + + private void AddRouteLambda(string url, LambdaExpression actionSelector) + { + // получаем имя контроллера + string controllerName = typeof(TController).Name; + if (controllerName.EndsWith("Controller", StringComparison.OrdinalIgnoreCase) && controllerName.Length > "Controller".Length) + { + controllerName = controllerName.Remove(controllerName.Length - "Controller".Length, "Controller".Length); + } + + // получаем имя действия + var unaryExpression = (UnaryExpression)actionSelector.Body; + var methodCallExpression = (MethodCallExpression)unaryExpression.Operand; + var constantExpression = (ConstantExpression)methodCallExpression.Object; + var methodInfo = (MethodInfo)constantExpression.Value; + string actionName = methodInfo.Name; + + _routes.Add(_routeTable.AddRoute($"{controllerName}_{actionName}_{Guid.NewGuid()}", url, controllerName, actionName)); + } + + public delegate TResult GenericResult0Delegate(); + public delegate TResult GenericResult1Delegate(TInput1 input1); + public delegate TResult GenericResult2Delegate(TInput1 input1, TInput2 input2); + public delegate TResult GenericResult3Delegate(TInput1 input1, TInput2 input2, TInput3 input3); + public delegate TResult GenericResult4Delegate(TInput1 input1, TInput2 input2, TInput3 input3, TInput4 input4); + public delegate TResult GenericResult5Delegate(TInput1 input1, TInput2 input2, TInput3 input3, TInput4 input4, TInput5 input5); + + public ControllerRouteFluent GenericAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent GenericAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent GenericAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent GenericAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent GenericAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + public ControllerRouteFluent GenericAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + #endregion + + #region JsonResult Actions + + public ControllerRouteFluent JsonAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent JsonAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent JsonAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent JsonAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent JsonAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent JsonAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + #endregion + + #region EmptyResult Actions + + public ControllerRouteFluent EmptyAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent EmptyAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent EmptyAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent EmptyAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent EmptyAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent EmptyAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + #endregion + + #region ContentResult Actions + + public ControllerRouteFluent ContentAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent ContentAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent ContentAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent ContentAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent ContentAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent ContentAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + #endregion + + #region PartialViewResult Actions + + public ControllerRouteFluent PartialViewAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent PartialViewAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent PartialViewAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent PartialViewAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent PartialViewAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent PartialViewAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + #endregion + + #region ActionResult Actions + + public ControllerRouteFluent ActionAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent ActionAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent ActionAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent ActionAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent ActionAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + public ControllerRouteFluent ActionAction(string url, Expression>> actionSelector) + { + this.AddRouteLambda(url, actionSelector); + return this; + } + + #endregion + + #region IRouteFluent> + + public ControllerRouteFluent NeedHttpGet(bool required = true) + { + if (_routes.Any()) _routes.Last().NeedHttpGet(required); + return this; + } + + public ControllerRouteFluent NeedHttpPost(bool required = true) + { + if (_routes.Any()) _routes.Last().NeedHttpPost(required); + return this; + } + + public ControllerRouteFluent WithDefaults(object defaults) + { + if (_routes.Any()) _routes.Last().WithDefaults(defaults); + return this; + } + + public ControllerRouteFluent WithConstraints(object constraints) + { + if (_routes.Any()) _routes.Last().WithConstraints(constraints); + return this; + } + + public ControllerRouteFluent WithNamespaces(string[] namespaces) + { + if (_routes.Any()) _routes.Last().WithNamespaces(namespaces); + return this; + } + + public ControllerRouteFluent NeedSecurityKey(string securityKeyName) + { + if (_routes.Any()) _routes.Last().NeedSecurityKey(securityKeyName); + return this; + } + + public ControllerRouteFluent WithDataTokens(object dataTokens) + { + if (_routes.Any()) _routes.Last().WithDataTokens(dataTokens); + return this; + } + + public ControllerRouteFluent WithDelegate(Action routeFluentAction) + { + if (_routes.Any()) routeFluentAction(_routes.Last()); + return this; + } + + #endregion + + #region IRouteFluent> for ALL + + public ControllerRouteFluent NeedHttpGet_All(bool required = true) + { + _routes.ForEach(x => x.NeedHttpGet(required)); + return this; + } + + public ControllerRouteFluent NeedHttpPost_All(bool required = true) + { + _routes.ForEach(x => x.NeedHttpPost(required)); + return this; + } + + public ControllerRouteFluent WithDefaults_All(object defaults) + { + _routes.ForEach(x => x.WithDefaults(defaults)); + return this; + } + + public ControllerRouteFluent WithConstraints_All(object constraints) + { + _routes.ForEach(x => x.WithConstraints(constraints)); + return this; + } + + public ControllerRouteFluent WithNamespaces_All(string[] namespaces) + { + _routes.ForEach(x => x.WithNamespaces(namespaces)); + return this; + } + + public ControllerRouteFluent NeedSecurityKey_All(string securityKeyName) + { + _routes.ForEach(x => x.NeedSecurityKey(securityKeyName)); + return this; + } + + public ControllerRouteFluent WithDataTokens_All(object dataTokens) + { + _routes.ForEach(x => x.WithDataTokens(dataTokens)); + return this; + } + + public ControllerRouteFluent WithDelegate_All(Action routeFluentAction) + { + _routes.ForEach(x => routeFluentAction(x)); + return this; + } + public ControllerRouteFluent JsonResultModel_All() + { + _routes.ForEach(x =>x.JsonResultModel()); + return this; + } + + public ControllerRouteFluent JsonResultModel() + { + if (_routes.Any()) _routes.Last().JsonResultModel(); + return this; + } + #endregion + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Routes/IRouteFluent.cs b/3-app/1-lib/Kit.Helpers/Routes/IRouteFluent.cs new file mode 100644 index 0000000..b9032fc --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Routes/IRouteFluent.cs @@ -0,0 +1,13 @@ +namespace Kit.Helpers.Routes +{ + public interface IEndPointFluent + { + TRouteFluent NeedHttpGet(bool required = true); + TRouteFluent NeedHttpPost(bool required = true); + TRouteFluent NeedSecurityKey(string securityKeyName); + TRouteFluent WithDefaults(object defaults); + TRouteFluent WithConstraints(object constraints); + TRouteFluent WithNamespaces(string[] namespaces); + TRouteFluent WithDataTokens(object dataTokens); + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Routes/RouteFluent.cs b/3-app/1-lib/Kit.Helpers/Routes/RouteFluent.cs new file mode 100644 index 0000000..6e238a1 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Routes/RouteFluent.cs @@ -0,0 +1,72 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Constraints; + +namespace Kit.Helpers.Routes +{ + + public class RouteFluent : IEndPointFluent + { + public Route Route { get; set; } + + public RouteFluent NeedHttpGet(bool required = true) + { + this.Route.Constraints.Add("httpMethod", new HttpMethodRouteConstraint("GET")); + return this; + } + + public RouteFluent NeedHttpPost(bool required = true) + { + this.Route.Constraints.Add("httpMethod", new HttpMethodRouteConstraint("POST")); + return this; + } + + public RouteFluent NeedSecurityKey(string securityKeyName) + { + this.Route.DataTokens["APP_SecurityKeyName"] = securityKeyName; + return this; + } + + public RouteFluent WithDefaults(object defaults) + { + if (defaults == null) return this; + foreach (var pair in new RouteValueDictionary(defaults)) + { + this.Route.Defaults.Add(pair.Key, pair.Value); + } + return this; + } + + public RouteFluent WithConstraints(object constraints) + { + if (constraints == null) return this; + foreach (var pair in new RouteValueDictionary(constraints)) + { + this.Route.Constraints.Add(pair.Key, (IRouteConstraint)pair.Value); + } + return this; + } + + public RouteFluent WithNamespaces(string[] namespaces) + { + if (namespaces == null) return this; + this.Route.DataTokens["Namespaces"] = namespaces; + return this; + } + + public RouteFluent WithDataTokens(object dataTokens) + { + if (dataTokens == null) return this; + foreach (var pair in new RouteValueDictionary(dataTokens)) + { + this.Route.DataTokens[pair.Key] = pair.Value; + } + return this; + } + + public RouteFluent JsonResultModel() + { + this.Route.DataTokens["jsonResultModel"] = true; + return this; + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Routes/RouteHelpers.cs b/3-app/1-lib/Kit.Helpers/Routes/RouteHelpers.cs new file mode 100644 index 0000000..dcb1030 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Routes/RouteHelpers.cs @@ -0,0 +1,179 @@ +namespace Kit.Helpers.Routes +{ + using Microsoft.AspNetCore.Routing; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.AspNetCore.Http; + + public static class RouteHelper_Extensions + { + + private static IInlineConstraintResolver CreateInlineConstraintResolver(IServiceProvider serviceProvider) + { + var inlineConstraintResolver = serviceProvider + .GetRequiredService(); + + var parameterPolicyFactory = serviceProvider + .GetRequiredService(); + + // This inline constraint resolver will return a null constraint for non-IRouteConstraint + // parameter policies so Route does not error + return new BackCompatInlineConstraintResolver(inlineConstraintResolver, parameterPolicyFactory); + } + + internal class NullRouteConstraint : IRouteConstraint + { + public static readonly NullRouteConstraint Instance = new NullRouteConstraint(); + + private NullRouteConstraint() + { + } + + public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) + { + return true; + } + } + private class BackCompatInlineConstraintResolver : IInlineConstraintResolver + { + private readonly IInlineConstraintResolver _inner; + private readonly ParameterPolicyFactory _parameterPolicyFactory; + + public BackCompatInlineConstraintResolver(IInlineConstraintResolver inner, ParameterPolicyFactory parameterPolicyFactory) + { + _inner = inner; + _parameterPolicyFactory = parameterPolicyFactory; + } + + public IRouteConstraint ResolveConstraint(string inlineConstraint) + { + var routeConstraint = _inner.ResolveConstraint(inlineConstraint); + if (routeConstraint != null) + { + return routeConstraint; + } + + var parameterPolicy = _parameterPolicyFactory.Create(null, inlineConstraint); + if (parameterPolicy != null) + { + // Logic inside Route will skip adding NullRouteConstraint + return NullRouteConstraint.Instance; + } + + return null; + } + } + + + + public static RouteFluent AddRoute(this IRouteBuilder routeBuilder, string name, string url) + { + var inline = CreateInlineConstraintResolver(routeBuilder.ServiceProvider); + var route = new Route(routeBuilder.DefaultHandler, name, url, null, null, null, inline); + routeBuilder.Routes.Add(route); + return new RouteFluent { Route = route }; + } + + public static RouteFluent AddRoute(this IRouteBuilder routeBuilder, string name, string url, string controller, string action) + { + var inline = CreateInlineConstraintResolver(routeBuilder.ServiceProvider); + var route = new Route(routeBuilder.DefaultHandler, name, url, new RouteValueDictionary(new { controller = controller, action = action }), null, null, inline); + routeBuilder.Routes.Add(route); + return new RouteFluent { Route = route }; + } + + public static ControllerRouteFluent AddController(this IRouteBuilder routeTable) + where TController : ControllerBase + { + return new ControllerRouteFluent(routeTable); + } + + public static RouteBase GetBaseRoute(this ControllerContext context) + { + return context.GetBaseRoute(); + //ParentActionViewContext != null + // ? context.ParentActionViewContext.GetBaseRoute() + // : context.GetBaseRoute(); + } + } +} + +//public class PermissionGroup +//{ +// public RouteEx Route { get; set; } +// public string Group { get; set; } +//} + + + +//public static class RouteHelpers +//{ +// public static RouteEx IsHttpGet(this RouteEx route) +// { +// route.Route.DataTokens.Add("APP_IsHttpGet", true); +// return route; +// } + +// public static RouteEx IsHttpPost(this RouteEx route) +// { +// route.Route.DataTokens.Add("APP_IsHttpPost", true); +// return route; +// } + +// public static RouteEx AddRoute(this RouteCollection routes, string url) +// { +// return new RouteEx +// { +// Route = routes.MapRoute(null, url) +// }; +// } + +// public static RouteEx AddRoute(this RouteCollection routes, string url, object defaults) +// { +// return new RouteEx +// { +// Route = routes.MapRoute(null, url, defaults) +// }; +// } + + +// public static PermissionGroup AddGroupPermission(this RouteEx route, string key, string permission) +// { +// var group = new PermissionGroup +// { +// Route = route, +// Group = $"{Guid.NewGuid()}" +// .Replace("-", string.Empty) +// .Replace("{", string.Empty) +// .Replace("}", string.Empty) +// .ToLower() +// }; + +// Dictionary>> permissions = null; + +// if (route.Route.DataTokens.ContainsKey("APP_RoutePermission")) +// { +// permissions = route.Route.DataTokens["APP_RoutePermission"] as Dictionary>>; +// } +// else +// { +// permissions = new Dictionary>>(); +// route.Route.DataTokens.Add("APP_RoutePermission", permissions); +// } +// var item = new List>(); +// item.Add(new KeyValuePair(key, permission)); +// permissions.Add(group.Group, item); + +// route.Route.DataTokens["APP_RoutePermission"] = permissions; + +// return group; +// } + +// public static PermissionGroup AddPermission(this PermissionGroup route, string key, string permission) +// { +// var permissions = route.Route.Route.DataTokens["APP_RoutePermission"] as Dictionary>>; +// permissions[route.Group].Add(new KeyValuePair(key, permission)); +// route.Route.Route.DataTokens["APP_RoutePermission"] = permissions; +// return route; +// } +//} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Security/SecurityKeyAuthorizationFilter.cs b/3-app/1-lib/Kit.Helpers/Security/SecurityKeyAuthorizationFilter.cs new file mode 100644 index 0000000..6a69c25 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Security/SecurityKeyAuthorizationFilter.cs @@ -0,0 +1,54 @@ +using Kit.Helpers.JsonResult; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Configuration; + +namespace Kit.Helpers.Security +{ + /// + /// Общая (платформенная) серверная проверка security-key для межсервисных вызовов. + /// RouteFluent.NeedSecurityKey("Имя") кладёт имя ключа в + /// Route.DataTokens["APP_SecurityKeyName"], но саму сверку Kit.Core не делает — её + /// выполняет этот глобальный фильтр: сравнивает заголовок X-Requested-Token (его шлёт + /// клиент через HttpHelper.SetAuthHeader) с ожидаемым ключом из секции конфига + /// {Имя}:SecurityKey. Маршруты без APP_SecurityKeyName проверку пропускают. + /// Регистрируется глобально: mvcOptions.Filters.Add<SecurityKeyAuthorizationFilter>(). + /// + public class SecurityKeyAuthorizationFilter : IAuthorizationFilter + { + /// Заголовок, в котором клиент передаёт security-key (см. HttpHelper.SetAuthHeader). + public const string HeaderName = "X-Requested-Token"; + + /// Ключ DataTokens маршрута с именем требуемого security-key (ставит NeedSecurityKey). + public const string DataTokenKey = "APP_SecurityKeyName"; + + private readonly IConfiguration _configuration; + + public SecurityKeyAuthorizationFilter(IConfiguration configuration) => _configuration = configuration; + + public void OnAuthorization(AuthorizationFilterContext context) + { + if (!context.RouteData.DataTokens.TryGetValue(DataTokenKey, out object? raw) + || raw is not string keyName + || string.IsNullOrEmpty(keyName)) + { + return; // маршрут не требует security-key + } + + string? expected = _configuration[$"{keyName}:SecurityKey"]; + string? provided = context.HttpContext.Request.Headers[HeaderName].FirstOrDefault(); + + if (string.IsNullOrEmpty(expected) || !string.Equals(expected, provided, StringComparison.Ordinal)) + { + var result = new JsonResultModel(); + result.Header.IsSuccess = false; + result.Header.Message = "Неверный или отсутствующий security-key"; + context.Result = new Microsoft.AspNetCore.Mvc.JsonResult(result) + { + StatusCode = StatusCodes.Status401Unauthorized, + }; + } + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Service/ConfigurationService.cs b/3-app/1-lib/Kit.Helpers/Service/ConfigurationService.cs new file mode 100644 index 0000000..0f86ff6 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/ConfigurationService.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace Kit.Helpers +{ + public interface IConfigurationService + { + public string ConnectionString { get; set; } + public bool IsNotVerifyXsd { get; set; } + public string CurrentFileVersion { get; set; } + public string XsdDirectory { get; set; } + public string TemplateFilePath { get; set; } + public string FormsStorePath { get; set; } + public string FileStorageRootFolder { get; set; } + public string TempDirectory { get; set; } + public int TaskLimit { get; set; } + public int TaskTimeout { get; set; } + public int TaskCancelWaitTime { get; set; } + public int TimerInterval { get; set; } + } + public class ConfigurationService: IConfigurationService + { + public string ConnectionString { get; set; } + public bool IsNotVerifyXsd { get; set; } + public string CurrentFileVersion { get; set; } + public string XsdDirectory { get; set; } + public string TemplateFilePath { get; set; } + public string FormsStorePath { get; set; } + public string FileStorageRootFolder { get; set; } + public string TempDirectory { get; set; } + public int TaskLimit { get; set; } + public int TaskTimeout { get; set; } + public int TaskCancelWaitTime { get; set; } + public int TimerInterval { get; set; } + + + public ConfigurationService(IConfiguration configuration, IHttpContextAccessor contextAccessor) + { + HttpContext httpContext = contextAccessor.HttpContext; + + ConnectionString = configuration["ConnectionStrings:RiskDb"]; + //Risk + IsNotVerifyXsd = configuration["Risk:IsNotVerifyXSD"].TryParseToBool(false); + CurrentFileVersion = configuration["Risk:FileVersion"]; + XsdDirectory = configuration["Risk:XsdDirectory"].ServerMapPath(httpContext); + TemplateFilePath = configuration["Risk:TemplateFilePath"].ServerMapPath(httpContext); + FormsStorePath = configuration["Risk:FormsStorePath"].ServerMapPath(httpContext); + FileStorageRootFolder = configuration["Risk:FileStorageRootFolder"].ServerMapPath(httpContext); + TempDirectory = configuration["Risk:TempDirectory"].ServerMapPath(httpContext); + + //Task + TaskLimit = configuration["Task:TaskLimit"].TryParseToInt32(4); + TaskTimeout = configuration["Task:TaskTimeout"].TryParseToInt32(3600000); + TaskCancelWaitTime = configuration["Task:TaskCancelWaitTime"].TryParseToInt32(10000); + TimerInterval = configuration["Task:TimerInterval"].TryParseToInt32(5000); + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Service/DistributedPg/IConfigurationDistributedPg.cs b/3-app/1-lib/Kit.Helpers/Service/DistributedPg/IConfigurationDistributedPg.cs new file mode 100644 index 0000000..4e555c4 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/DistributedPg/IConfigurationDistributedPg.cs @@ -0,0 +1,13 @@ +namespace Kit.Helpers; + +public interface IConfigurationDistributedPg +{ + string DistributedConnectionString { get; } +} + +public class ConfigurationDistributedPg : IConfigurationDistributedPg +{ +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + public string DistributedConnectionString { get; init; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Service/DistributedPg/PgTableDistributedCacheService.cs b/3-app/1-lib/Kit.Helpers/Service/DistributedPg/PgTableDistributedCacheService.cs new file mode 100644 index 0000000..5238be1 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/DistributedPg/PgTableDistributedCacheService.cs @@ -0,0 +1,80 @@ +using Kit.Helpers.Postgres; + +namespace Kit.Helpers; + +/// +/// Провайдер кэша через самописную схему (и её элементы) distributed.
+///
+public class PgTableDistributedCacheService : IDistributedCacheService +{ + private readonly IDistributedLockService _distributedLockService; + private readonly string _connectionString; + + private const string _lockType = "PgTableDistributedCacheService"; + + public PgTableDistributedCacheService( + IConfigurationDistributedPg configuration, + IDistributedLockService distributedLockService) + { + _distributedLockService = distributedLockService; + _connectionString = configuration.DistributedConnectionString; + } + + public void ExecuteOnLock(string key, Action action) + { + _distributedLockService.ExecuteOnLock(_lockType, key, action); + } + + public TResult ExecuteOnLock(string key, Func action) + { + return _distributedLockService.ExecuteOnLock(_lockType, key, action); + } + + public void SetCacheData(string cacheName, TObject data, DistributedCacheOptions setProperties) + { + this.ExecuteOnLock(cacheName, () => + { + var parameters = new List> + { + new KeyValuePair("_key", cacheName), + new KeyValuePair("_value", data.JsonSerialize()) + }; + if (setProperties.AbsoluteExpirationRelativeToNow.HasValue) + { + parameters.Add(new KeyValuePair("_absolute_expiration_seconds", (int)setProperties.AbsoluteExpirationRelativeToNow.Value.TotalSeconds)); + } + if (setProperties.SlidingExpiration.HasValue) + { + parameters.Add(new KeyValuePair("_sliding_expiration_seconds", (int)setProperties.SlidingExpiration.Value.TotalSeconds)); + } + + _connectionString.ExecuteNonQuery("distributed.cache_insert", isStoredProcedure: true, parameters); + }); + } + + public TResult? GetCacheData(string cacheName) + { + return this.ExecuteOnLock(cacheName, () => + { + var parameters = new List> + { + new KeyValuePair("_key", cacheName) + }; + object? result = _connectionString.ExecuteScalar("distributed.cache_select", isStoredProcedure: true, parameters:parameters); + + if (result is not string jsonString) + { + return default; + } + + try + { + return jsonString.JsonDeserialize(); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Error while deserializing json value of cache. cacheName: {cacheName}", ex); + } + }); + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Service/DistributedPg/PgTableDistributedLockService.cs b/3-app/1-lib/Kit.Helpers/Service/DistributedPg/PgTableDistributedLockService.cs new file mode 100644 index 0000000..c894002 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/DistributedPg/PgTableDistributedLockService.cs @@ -0,0 +1,170 @@ +using Kit.Helpers.Postgres; + +namespace Kit.Helpers; + +/// +/// Провайдер блокировок через самописную схему (и её элементы) distributed.
+/// Все активные блокировки хрянятся в таблице в явном виде.
+/// У активной блокировки заполянется время начала (acquired_at_utc timestamptz) и обновляется раз в .
+/// Если приложение упадёт и перестанет обновлять время начала, через (timeout_ms int default) миллисекунд блокировка будет считаться недействительной.
+/// При следующей операции, связанной с любой блокировкой, запись о недействительной блокировке будет удалена. +///
+public class PgTableDistributedLockService : IDistributedLockService +{ + private readonly string _connectionString; + + private const int _sleepIntervalMs = 10; + private const int _refreshIntervalMs = 100; + private readonly object _ownersLock = new object(); + private readonly IDictionary _owners = new Dictionary(); + + public PgTableDistributedLockService(IConfigurationDistributedPg configurationPgLock) + { + _connectionString = configurationPgLock.DistributedConnectionString; + } + + private class LockOwner + { + public string Id { get; set; } + public int ThreadId { get; set; } + /// + /// Уровень вложенности блокировок в рамках одного потока и объекта. + /// 0 - активных блокировок нет. + /// 1 - одна блокировка. + /// >1 - есть вложенные блокировки. + /// + public IDictionary LockedObjects { get; set; } + public LockOwner(int threadId) + { + ThreadId = threadId; + Id = Guid.NewGuid().ToString(); + LockedObjects = new Dictionary(); + } + } + + private IList> GetParameters(string type, string key, string ownerId) + { + return new List> + { + new KeyValuePair("_type", type), + new KeyValuePair("_key", key), + new KeyValuePair("_owner_id", ownerId), + }; + } + + private bool TryAcquireLock(string type, string key, string ownerId) + { + return _connectionString.ExecuteScalar("distributed.try_acquire_lock", + isStoredProcedure: true, this.GetParameters(type, key, ownerId)); + } + + private void Tick(string type, string key, string ownerId) + { + _connectionString.ExecuteNonQuery("distributed.lock_tick_for_owner_id", + isStoredProcedure: true, this.GetParameters(type, key, ownerId)); + } + + private void Free(string type, string key, string ownerId) + { + _connectionString.ExecuteNonQuery("distributed.lock_free_for_owner_id", + isStoredProcedure: true, this.GetParameters(type, key, ownerId)); + } + + private int Count(string type, string? key) + { + string sql; + var parameters = new List> + { + new KeyValuePair("_type", type) + }; + if (key == null) + { + sql = "distributed.lock_count_by_type"; + } + else + { + sql = "distributed.lock_count_by_type_key"; + parameters.Add(new KeyValuePair("_key", key)); + } + return _connectionString.ExecuteScalar(sql, isStoredProcedure: true, parameters); + } + + private LockOwner GetLockOwner(string lockedObjectKey) + { + lock (_ownersLock) + { + LockOwner lockOwner; + int threadId = Environment.CurrentManagedThreadId; + if (_owners.ContainsKey(threadId)) + { + lockOwner = _owners[threadId]; + } + else + { + lockOwner = new LockOwner(threadId); + _owners.Add(threadId, lockOwner); + } + + if (lockOwner.LockedObjects.ContainsKey(lockedObjectKey)) + { + lockOwner.LockedObjects[lockedObjectKey]++; + } + else + { + lockOwner.LockedObjects.Add(lockedObjectKey, 1); + } + + return lockOwner; + } + } + + public void ExecuteOnLock(string type, string key, Action action) => ExecuteOnLock(type, key, () => + { + action(); + return true; + }); + + public TResult ExecuteOnLock(string type, string key, Func action) + { + string lockedObjectKey = $"{type};;;{key}"; + // Формируем Id владельца блокировки + LockOwner owner = this.GetLockOwner(lockedObjectKey); + try + { + // Ждём момента, когда наш запрос займёт блокировку + while (TryAcquireLock(type, key, owner.Id) == false) + { + Thread.Sleep(_sleepIntervalMs); + } + // Периодически обновляя дату начала блокировки, выполняем операцию + using (var timer = new Timer(_ => Tick(type, key, owner.Id), null, 0, _refreshIntervalMs)) + { + return action(); + } + } + finally + { + lock (_ownersLock) + { + //Если это не вложенная блокировка + if (owner.LockedObjects[lockedObjectKey] < 2) + { + // Освобождаем блокировку для владельца + Free(type, key, owner.Id); + owner.LockedObjects.Remove(lockedObjectKey); + if (owner.LockedObjects.Count < 1) + { + _owners.Remove(owner.ThreadId); + } + } + else + { + owner.LockedObjects[lockedObjectKey]--; + } + } + } + } + + public int GetLockCount(string type) => Count(type, null); + public int GetLockCount(string type, string key) => Count(type, key); +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Service/IDistributedCacheService.cs b/3-app/1-lib/Kit.Helpers/Service/IDistributedCacheService.cs new file mode 100644 index 0000000..ab747ba --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/IDistributedCacheService.cs @@ -0,0 +1,14 @@ +namespace Kit.Helpers; + +public class DistributedCacheOptions +{ + public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; } + public TimeSpan? SlidingExpiration { get; set; } +} + +/// Интерфейс сервиса распеределённого кэша +public interface IDistributedCacheService +{ + void SetCacheData(string cacheName, TObject data, DistributedCacheOptions setProperties); + TResult? GetCacheData(string cacheName); +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Service/IDistributedLockService.cs b/3-app/1-lib/Kit.Helpers/Service/IDistributedLockService.cs new file mode 100644 index 0000000..a350bc8 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/IDistributedLockService.cs @@ -0,0 +1,12 @@ +namespace Kit.Helpers +{ + /// Интерфейс сервиса для синхронизации потоков по ключу + public interface IDistributedLockService + { + void ExecuteOnLock(string type, string key, Action action); + TResult ExecuteOnLock(string type, string key, Func action); + + int GetLockCount(string type); + int GetLockCount(string type, string key); + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/Service/ILockDictionaryServiceT.cs b/3-app/1-lib/Kit.Helpers/Service/ILockDictionaryServiceT.cs new file mode 100644 index 0000000..7d82c91 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/ILockDictionaryServiceT.cs @@ -0,0 +1,10 @@ +namespace Kit.Helpers +{ + /// Интерфейс сервиса для синхронизации потоков по ключу + /// ключ синхронизации + public interface ILockDictionaryService where TKey : notnull + { + void ExecuteOnLock(TKey lockKey, Action action); + TResult ExecuteOnLock(TKey lockKey, Func action); + } +} diff --git a/3-app/1-lib/Kit.Helpers/Service/LockService.cs b/3-app/1-lib/Kit.Helpers/Service/LockService.cs new file mode 100644 index 0000000..c623920 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/LockService.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; + +namespace Kit.Helpers +{ + public interface ILockService + { + void Lock(TKey lockKey, Action action); + TResult Lock(TKey lockKey, Func action); + + } + public class LockService : ILockService + { + private object _lockObjectLock = new object(); + private IDictionary _lockObjects = new Dictionary(); + + /// Блокировка операции по ключу + /// Ключ для блокировки + /// Выполняемая операция + public void Lock(TKey lockKey, Action action) + { + try + { + lock (GetLockObject(lockKey)) + { + action(); + } + } + finally + { + RemoveLockObject(lockKey); + } + } + + /// Блокировка операции по ключу + /// Ключ для блокировки + /// Выполняемая операция + public TResult Lock(TKey lockKey, Func action) + { + try + { + lock (GetLockObject(lockKey)) + { + return action(); + } + } + finally + { + RemoveLockObject(lockKey); + } + } + + + /// Получение объекта для блокировки по ключу + /// Ключ для блокировки + /// Объект блокировки, соответствующий ключу + private object GetLockObject(TKey lockKey) + { + lock (_lockObjectLock) + { + if (_lockObjects.ContainsKey(lockKey) == false) + { + _lockObjects.Add(lockKey, new object()); + } + + return _lockObjects[lockKey]; + } + } + + /// Удаление объекта блокировки, соответствующего ключу + /// Ключ для блокировки + private void RemoveLockObject(TKey lockKey) + { + /// дожидаемся освобождения объекта + lock (this.GetLockObject(lockKey)) { } + + /// удаляем объект блокировки из очереди + lock (_lockObjectLock) + { + _lockObjects.Remove(lockKey); + } + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/Service/Memory/MemoryLockDictionaryService_1.cs b/3-app/1-lib/Kit.Helpers/Service/Memory/MemoryLockDictionaryService_1.cs new file mode 100644 index 0000000..9ae8ec0 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/Memory/MemoryLockDictionaryService_1.cs @@ -0,0 +1,84 @@ +//namespace Kit.Helpers +//{ +// /// Сервис для синхронизации потоков по ключу на основе словаря в памяти приложения +// /// ключ синхронизации +// public sealed class MemoryLockDictionaryService : ILockDictionaryService +// { +// private object _lockObjectLock; +// private IDictionary _lockObjects; + +// public MemoryLockDictionaryService() +// { +// _lockObjectLock = new object(); +// _lockObjects = new Dictionary(); +// } + +// private string GetTypeMask(string type) => $"$t${(type ?? "no_type")}$t$"; +// private string GetKeyMask(string key) => $"$k${(key ?? "no_key")}$k$"; +// private string GetLockKey(string type, string key) => $"{GetTypeMask(type)}_{GetKeyMask(key)}"; + +// public int GetLockCount(string type) => _lockObjects.Count(x => x.Key.StartsWith(GetTypeMask(type))); +// public int GetLockCount(string type, string key) => _lockObjects.Count(x => x.Key.Equals(GetLockKey(type, key))); + +// private object GetLockObject(string lockKey) +// { +// lock (_lockObjectLock) +// { +// if (_lockObjects.ContainsKey(lockKey) == false) +// _lockObjects.Add(lockKey, new object()); + +// return _lockObjects[lockKey]; +// } +// } + +// private void RemoveLockObject(string lockKey) +// { +// // дожидаемся освобождения объекта +// lock (this.GetLockObject(lockKey)) +// { +// } + +// // удаляем объект блокировки из очереди +// lock (_lockObjectLock) +// { +// try +// { +// _lockObjects.Remove(lockKey); +// } +// catch { } +// } +// } + +// public void ExecuteOnLock(string type, string key, Action action) +// { +// string lockKey = GetLockKey(type, key); +// lock (GetLockObject(lockKey)) +// { +// try +// { +// action(); +// } +// finally +// { +// RemoveLockObject(lockKey); +// } +// } +// } + +// public TResult ExecuteOnLock(string type, string key, Func action) +// { +// string lockKey = GetLockKey(type, key); +// lock (GetLockObject(lockKey)) +// { +// try +// { +// return action(); +// } +// finally +// { +// RemoveLockObject(lockKey); +// } +// } +// } +// } +//} diff --git a/3-app/1-lib/Kit.Helpers/Service/Memory/MemoryLockDictionaryService_2.cs b/3-app/1-lib/Kit.Helpers/Service/Memory/MemoryLockDictionaryService_2.cs new file mode 100644 index 0000000..172ffe2 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/Memory/MemoryLockDictionaryService_2.cs @@ -0,0 +1,137 @@ +//using System.Collections.Concurrent; + +//namespace Kit.Helpers +//{ +// /// Сервис для синхронизации потоков по ключу на основе словаря в памяти приложения +// /// ключ синхронизации +// public sealed class MemoryLockDictionaryService : ILockDictionaryService +// { +// private readonly ConcurrentDictionary _lockObjects = new ConcurrentDictionary(); +// private readonly TimeSpan _cleanupInterval = TimeSpan.FromSeconds(45); +// private readonly TimeSpan _expirationTime = TimeSpan.FromSeconds(10); +// private const int _refreshIntervalMs = 100; + +// private struct LockValue +// { +// public object LockObject { get; } +// public DateTime DateStart { get; private set; } +// public LockValue() +// { +// LockObject = new object(); +// DateStart = DateTime.UtcNow; +// } + +// public void UpdateDate() => DateStart = DateTime.UtcNow; +// } + +// public MemoryLockDictionaryService() +// { +// StartCleanupTask(); +// } + +// private string GetTypeMask(string type) => $"$t${(type ?? "no_type")}$t$"; +// private string GetKeyMask(string key) => $"$k${(key ?? "no_key")}$k$"; +// private string GetLockKey(string type, string key) => $"{GetTypeMask(type)}_{GetKeyMask(key)}"; + +// public int GetLockCount(string type) +// { +// return _lockObjects.Count(x => x.Key.StartsWith(GetTypeMask(type))); +// } + +// public int GetLockCount(string type, string key) +// { +// return _lockObjects.Count(x => x.Key.Equals(GetLockKey(type, key))); +// } + +// public void ExecuteOnLock(string type, string key, Action action) +// { +// if (type == null) +// { +// throw new ArgumentNullException(nameof(type)); +// } +// if (key == null) +// { +// throw new ArgumentNullException(nameof(key)); +// } +// if (action == null) +// { +// throw new ArgumentNullException(nameof(action)); +// } + +// string lockKey = GetLockKey(type, key); + +// var lockObject = GetOrCreateLockObject(key); +// lock (lockObject) +// { +// // Периодически обновляя дату начала блокировки, выполняем операцию +// using (var timer = new Timer(_ => UpdateLastAccess(key), null, 0, _refreshIntervalMs)) +// { +// action(); +// } +// } +// } + +// public TResult ExecuteOnLock(string type, string key, Func action) +// { +// if (type == null) +// { +// throw new ArgumentNullException(nameof(type)); +// } +// if (key == null) +// { +// throw new ArgumentNullException(nameof(key)); +// } +// if (action == null) +// { +// throw new ArgumentNullException(nameof(action)); +// } + +// string lockKey = GetLockKey(type, key); + +// var lockObject = GetOrCreateLockObject(key); +// lock (lockObject) +// { +// // Периодически обновляя дату начала блокировки, выполняем операцию +// using (var timer = new Timer(_ => UpdateLastAccess(key), null, 0, _refreshIntervalMs)) +// { +// return action(); +// } +// } +// } + +// private object GetOrCreateLockObject(string key) +// { +// return _lockObjects.GetOrAdd(key, _ => new LockValue()).LockObject; +// } + +// private void UpdateLastAccess(string key) +// { +// if (_lockObjects.TryGetValue(key, out var value)) +// _lockObjects[key].UpdateDate(); +// } + +// private void StartCleanupTask() +// { +// Task.Run(async () => +// { +// while (true) +// { +// await Task.Delay(_cleanupInterval); +// CleanupExpiredLocks(); +// } +// }); +// } + +// private void CleanupExpiredLocks() +// { +// var now = DateTime.UtcNow; +// foreach (var key in _lockObjects.Keys) +// { +// if (_lockObjects.TryGetValue(key, out var value) && now - value.DateStart > _expirationTime) +// { +// _lockObjects.TryRemove(key, out _); +// } +// } +// } +// } +//} diff --git a/3-app/1-lib/Kit.Helpers/Service/MemoryLockDictionaryService.cs b/3-app/1-lib/Kit.Helpers/Service/MemoryLockDictionaryService.cs new file mode 100644 index 0000000..5b321c9 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Service/MemoryLockDictionaryService.cs @@ -0,0 +1,72 @@ +namespace Kit.Helpers +{ + /// Сервис для синхронизации потоков по ключу на основе словаря в памяти приложения + /// ключ синхронизации + public class MemoryLockDictionaryService: ILockDictionaryService where TKey : notnull + { + private object _lockObjectLock; + private IDictionary _lockObjects; + + public MemoryLockDictionaryService() + { + _lockObjectLock = new object(); + _lockObjects = new Dictionary(); + } + + private object GetLockObject(TKey lockKey) + { + lock (_lockObjectLock) + { + if (_lockObjects.ContainsKey(lockKey) == false) _lockObjects.Add(lockKey, new object()); + return _lockObjects[lockKey]; + } + } + + private void RemoveLockObject(TKey lockKey) + { + // дожидаемся освобождения объекта + lock (this.GetLockObject(lockKey)) { } + + // удаляем объект блокировки из очереди + lock (_lockObjectLock) { _lockObjects.Remove(lockKey); } + } + + public void ExecuteOnLock(TKey lockKey, Action action) + { + try + { + lock (GetLockObject(lockKey)) + { + action(); + } + RemoveLockObject(lockKey); + } + catch + { + throw; + } + finally + { + } + } + + public TResult ExecuteOnLock(TKey lockKey, Func action) + { + try + { + lock (GetLockObject(lockKey)) + { + return action(); + } + } + catch + { + throw; + } + finally + { + RemoveLockObject(lockKey); + } + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/ConnectionString.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/ConnectionString.cs new file mode 100644 index 0000000..591a1ef --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/ConnectionString.cs @@ -0,0 +1,63 @@ +using System; +using System.Text.Json.Serialization; + +namespace Kit.Helpers.SqlHelperAbstract +{ + public enum ConnectionStringType + { + Undefined = 0, + Postgres = 1, + SQLite = 2, + } + + public interface IConnectionString + { + ConnectionStringType Type { get; } + string Value { get; } + ISqlHelper GetSqlHelper(); + + } + + public class ConnectionString : IConnectionString + { + public ConnectionStringType Type { get; set; } = ConnectionStringType.Undefined; + + private string _value = string.Empty; + public string Value + { + get => _value; + set => _value = value.ServerMapPath(); + } + + public virtual ISqlHelper GetSqlHelper() + { + switch (Type) + { + case ConnectionStringType.Postgres: + return Postgres.SqlHelper.Instance; + case ConnectionStringType.SQLite: + return SQLite.SqlHelper.Instance; + default: + throw new NotImplementedException(); + } + } + + public static SqlHelperFluent PrepareExecute(string sqlText) + { + return new ConnectionString().PrepareExecute(sqlText); + } + } + + public static class ConnectionStringExtensions + { + public static SqlHelperFluent PrepareExecute(this IConnectionString connectionString, string sqlText) + { + return new SqlHelperFluent(connectionString, sqlText); + } + + public static SqlHelperFluent PrepareExecute(this IConnectionString connectionString, string sqlText) + { + return new SqlHelperFluent(connectionString, sqlText); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/CustomType.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/CustomType.cs new file mode 100644 index 0000000..d037d1b --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/CustomType.cs @@ -0,0 +1,16 @@ +using System; + +namespace Kit.Helpers.SqlHelperAbstract +{ + public class CustomType + { + public string Name { get; set; } + public Type Type { get; set; } + + public CustomType(string name, Type type) + { + Name = name; + Type = type; + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/DataRecordSpecified.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/DataRecordSpecified.cs new file mode 100644 index 0000000..7cf50dd --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/DataRecordSpecified.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics.CodeAnalysis; + +namespace Kit.Helpers.SqlHelperAbstract +{ + public interface IDataRecordSpecified + { + IDataReader Src { get; } + Npgsql.INpgsqlNameTranslator NameTranslator { get; } + TValue Convert(object value); + } + + internal class DataRecordSpecified : IDataRecordSpecified + { + public IDataReader Src { get; } + public Npgsql.INpgsqlNameTranslator NameTranslator { get; } + public virtual TValue Convert(object value) + { + return (TValue)value; + } + + public DataRecordSpecified(IDataReader dataReader, Npgsql.INpgsqlNameTranslator nameTranslator) + { + Src = dataReader; + NameTranslator = nameTranslator; + } + } + + public static class DataRecordSpecifiedExtensions + { + /// + /// Проверка на наличие столбца в запросе + /// + /// + /// + /// + public static bool HasColumn(this IDataRecordSpecified reader, string name) + { + string nameTranslated = reader.NameTranslator.TranslateMemberName(name).ToLower(); + for (int n = 0; n < reader.Src.FieldCount; n++) + { + string fieldNameTranslated = reader.NameTranslator.TranslateMemberName(reader.Src.GetName(n)).ToLower(); + if (fieldNameTranslated == nameTranslated) return true; + } + return false; + } + + private static string GetTranslatedName(this IDataRecordSpecified record, string fieldName) => record.NameTranslator.TranslateMemberName(fieldName); +#if NETFRAMEWORK + private static TValue TryGet(this IDataRecordSpecified record, string fieldName, bool isStrict) +#elif NETCOREAPP + private static TValue? TryGet(this IDataRecordSpecified record, string fieldName, bool isStrict) +#endif + { + fieldName = GetTranslatedName(record, fieldName); + object value = record.Src[fieldName]; + + if (value == DBNull.Value) + { + if (isStrict) + throw new InvalidCastException(string.Format("{0} {1} нельзя привести к {2}", fieldName, typeof(DBNull).FullName, typeof(TValue).FullName)); + + return default; + } + + try + { + return record.Convert(value); + } + catch (InvalidCastException ex) + { + throw new InvalidCastException(string.Format("{0} {1} нельзя привести к {2}", fieldName, value.GetType().FullName, typeof(TValue).FullName), ex); + } + } + + public static bool IsDbNull(this IDataRecordSpecified record, string fieldName) => record.Src[fieldName] == DBNull.Value; + public static bool IsNotDbNull(this IDataRecordSpecified record, string fieldName) => !record.IsDbNull(fieldName); + + public static TValue Get(this IDataRecordSpecified record, string fieldName) => record.TryGet(fieldName, isStrict: true); +#if NETFRAMEWORK + public static TValue GetN(this IDataRecordSpecified record, string fieldName) +#elif NETCOREAPP + public static TValue? GetN(this IDataRecordSpecified record, string fieldName) +#endif + => record.TryGet(fieldName, isStrict: false); + + internal static void ProcessExtraSelect(this IDataRecordSpecified readerSpecified, IList selectResult, SelectType extraSelectType = SelectType.Default) + { + switch (extraSelectType) + { + case SelectType.Page: + { + var result = (IEnumerableWithPage)selectResult; + // считывание данных постраничного вывода + while (readerSpecified.Src.Read()) + { + if (readerSpecified.HasColumn("FilteredRows")) result.FilterRow = readerSpecified.Get("filteredRows"); + if (readerSpecified.HasColumn("TotalRows")) result.TotalRows = readerSpecified.Get("totalRows"); + if (readerSpecified.HasColumn("PageNo")) result.PageNo = readerSpecified.Get("pageNo"); + if (readerSpecified.HasColumn("PageSize")) result.PageSize = readerSpecified.Get("pageSize"); + if (readerSpecified.HasColumn("Sort")) result.Sort = readerSpecified.Get("sort"); + //if (readerSpecified.HasColumn("Dir")) result.Dir = readerSpecified.Get("dir"); + } + } + break; + case SelectType.Offer: + { + var result = (IEnumerableWithOffer)selectResult; + // считывание данных постраничного вывода + while (readerSpecified.Src.Read()) + { + if (readerSpecified.HasColumn("FilteredRows")) result.FilterRow = readerSpecified.Get("FilteredRows"); + if (readerSpecified.HasColumn("TotalRows")) result.TotalRows = readerSpecified.Get("TotalRows"); + if (readerSpecified.HasColumn("Start")) result.Start = readerSpecified.Get("Start"); + if (readerSpecified.HasColumn("Length")) result.Lenght = readerSpecified.Get("Length"); + if (readerSpecified.HasColumn("Sort")) result.Sort = readerSpecified.Get("Sort"); + //if (readerSpecified.HasColumn("Dir")) result.Dir = readerSpecified.Get("Dir"); + } + } + break; + case SelectType.Default: + default: + break; + } + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/RequestParams.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/RequestParams.cs new file mode 100644 index 0000000..c7842ef --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/RequestParams.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; + +namespace Kit.Helpers.SqlHelperAbstract +{ + public class RequestParams + { + public IConnectionString ConnectionString { get; } + public string CommandText { get; } + public bool IsStoredProcedure { get; } + public bool WithStrictSyntax { get; } + public IEnumerable> Parameters { get; } + public IEnumerable CustomTypes { get; } + + public RequestParams( + IConnectionString connectionString, + string sqlText, + bool isStoredProcedure, + bool withStrictSyntax, + IEnumerable> parameters, + IEnumerable customTypes + ) + { + ConnectionString = connectionString; + CommandText = sqlText; + IsStoredProcedure = isStoredProcedure; + WithStrictSyntax = withStrictSyntax; + Parameters = parameters; + CustomTypes = customTypes; + } + } + + public class RequestParamsSelect : RequestParams + { + public RequestParamsSelect( + IConnectionString connectionString, + string sqlText, + bool isStoredProcedure, + bool withStrictSyntax, + IEnumerable> parameters, + IEnumerable customTypes, + params Action>[] converters + ) : base(connectionString, sqlText, isStoredProcedure, withStrictSyntax, parameters, customTypes) + { + Converters = converters; + } + + public IEnumerable>> Converters { get; } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SelectType.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SelectType.cs new file mode 100644 index 0000000..9e68a53 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SelectType.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace Kit.Helpers.SqlHelperAbstract +{ + internal enum SelectType + { + Default, + Page, + Offer + } + + internal static class SelectTypeExtensions + { + internal static IList CreateList(this SelectType extraSelectType) + { + switch (extraSelectType) + { + case SelectType.Page: + return new ListWithPage(); + case SelectType.Offer: + return new ListWithOffer(); + case SelectType.Default: + default: + return new List(); + } + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/Sql/sys_function_select.sql b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/Sql/sys_function_select.sql new file mode 100644 index 0000000..19f7663 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/Sql/sys_function_select.sql @@ -0,0 +1 @@ +select func_name, func_params, func_text from sys_functions where func_name = :func_name \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelper.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelper.cs new file mode 100644 index 0000000..c22d786 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelper.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; + +namespace Kit.Helpers.SqlHelperAbstract +{ + + public interface ISqlHelper + { + void ExecuteNonQuery(RequestParams requestParams); + object ExecuteScalar(RequestParams requestParams); + TResult ExecuteScalar(RequestParams requestParams); + IEnumerable ExecuteSelectMany(RequestParamsSelect requestParams); + IEnumerableWithPage ExecuteSelectManyWithPage(RequestParamsSelect requestParams); + IEnumerableWithOffer ExecuteSelectManyWithOffer(RequestParamsSelect requestParams); + } + + public static class SqlHelper + { + #region ExecuteNonQuery + + public static void ExecuteNonQuery(this IConnectionString connectionString, string sql, bool isStoredProcedure, + bool withStrictSyntax = false, IEnumerable> parameters = null, IEnumerable customTypes = null) + { + var requestParams = new RequestParams(connectionString, sql, isStoredProcedure, + withStrictSyntax, parameters, customTypes); + connectionString.GetSqlHelper().ExecuteNonQuery(requestParams); + } + + #endregion + + #region ExecuteScalar + + public static object ExecuteScalar(this IConnectionString connectionString, string sql, bool isStoredProcedure, + bool withStrictSyntax = false, IEnumerable> parameters = null, IEnumerable customTypes = null) + { + var requestParams = new RequestParams(connectionString, sql, isStoredProcedure, + withStrictSyntax, parameters, customTypes); + return connectionString.GetSqlHelper().ExecuteScalar(requestParams); + } + + public static TEntity ExecuteScalar(this IConnectionString connectionString, string sql, bool isStoredProcedure, + bool withStrictSyntax = false, IEnumerable> parameters = null, IEnumerable customTypes = null) + { + var requestParams = new RequestParams(connectionString, sql, isStoredProcedure, + withStrictSyntax, parameters, customTypes); + return connectionString.GetSqlHelper().ExecuteScalar(requestParams); + } + + #endregion + + #region ExecuteSelectMany + + public static IEnumerable ExecuteSelectMany(this IConnectionString connectionString, string sql, bool isStoredProcedure, + bool withStrictSyntax = false, IEnumerable> parameters = null, IEnumerable customTypes = null, + params Action>[] converters) + { + var requestParams = new RequestParamsSelect(connectionString, sql, isStoredProcedure, + withStrictSyntax, parameters, customTypes, converters); + return connectionString.GetSqlHelper().ExecuteSelectMany(requestParams); + } + + #endregion + + #region ExecuteSelectManyWithPage + + public static IEnumerableWithPage ExecuteSelectManyWithPage(this IConnectionString connectionString, string sql, bool isStoredProcedure, + bool withStrictSyntax = false, IEnumerable> parameters = null, IEnumerable customTypes = null, + params Action>[] converters) + { + var requestParams = new RequestParamsSelect(connectionString, sql, isStoredProcedure, + withStrictSyntax, parameters, customTypes, converters); + return connectionString.GetSqlHelper().ExecuteSelectManyWithPage(requestParams); + } + + #endregion + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelperFluent.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelperFluent.cs new file mode 100644 index 0000000..8f14d1f --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelperFluent.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Kit.Helpers.SqlHelperAbstract +{ + internal class OutputValue + { + public object Value; + } + + public class SqlHelperFluent + { + protected readonly IConnectionString _connectionString; + protected readonly string _sqlText; + protected bool _isStoredProcedure; + protected bool _isOutputParameter; + protected bool _withStrictSyntax; + protected List _customTypes; + protected List> _parameters; + + public SqlHelperFluent(IConnectionString connectionString, string sqlText) + { + _connectionString = connectionString; + _sqlText = sqlText; + _isStoredProcedure = true; + _isOutputParameter = false; + _withStrictSyntax = false; + _customTypes = new List(); + _parameters = new List>(); + } + + #region fluent methods + + public SqlHelperFluent AsStoredProcedure() + { + _isStoredProcedure = true; + return this; + } + + public SqlHelperFluent AsSqlText() + { + _isStoredProcedure = false; + return this; + } + + public SqlHelperFluent WithStrictSyntax() + { + _withStrictSyntax = true; + return this; + } + + public SqlHelperFluent AddCustomType(string name, Type type) + { + if (type != null) + { + _customTypes.Add(new CustomType(name, type)); + } + return this; + } + + public SqlHelperFluent AddCustomType(string name) + { + _customTypes.Add(new CustomType(name, typeof(TType))); + return this; + } + + public SqlHelperFluent AddFilter(string name, object value) + { + if (value != null) + { + _parameters.Add(new KeyValuePair(name, value)); + } + return this; + } + + public SqlHelperFluent AddFilter(string name, string value) + { + if (!value.IsNullOrEmpty()) + { + _parameters.Add(new KeyValuePair(name, value)); + } + return this; + } + + public SqlHelperFluent AddParameter(string name, object value) + { + _parameters.Add(new KeyValuePair(name, value)); + return this; + } + + public SqlHelperFluent AddParameter(string name, string value) + { + + _parameters.Add(new KeyValuePair(name, value ?? string.Empty)); + return this; + } + + public SqlHelperFluent AddParameterNullable(string name, object value) + { + _parameters.Add(new KeyValuePair(name, value ?? DBNull.Value)); + return this; + } + + public SqlHelperFluent AddParameterIfNotNull(string name, object value) + { + if (value != null) + { + _parameters.Add(new KeyValuePair(name, value)); + } + return this; + } + + public SqlHelperFluent AddOutputParameter(string name, object value) + { + _parameters.Add(new KeyValuePair(name, new OutputValue { Value = value ?? DBNull.Value })); + return this; + } + + public SqlHelperFluent AddParameters(IEnumerable> parameters) + { + _parameters.AddRange(parameters); + return this; + } + + public SqlHelperFluent AddFilters(IEnumerable> parameters) + { + _parameters.AddRange(parameters.Where(x => x.Value != null)); + return this; + } + + #endregion + + #region finish methods + + public RequestParams BuildParams() => new RequestParams(_connectionString, _sqlText, + _isStoredProcedure, _withStrictSyntax, _parameters, _customTypes); + + public void ExecuteNonQuery() + { + _connectionString.GetSqlHelper().ExecuteNonQuery(BuildParams()); + } + + public TResult ExecuteScalar() + { + return _connectionString.GetSqlHelper().ExecuteScalar(BuildParams()); + } + + #endregion + } + + public class SqlHelperFluent : SqlHelperFluent + { + protected List>> _converters; + + public SqlHelperFluent(IConnectionString connectionString, string sqlText) : base(connectionString, sqlText) + { + _converters = new List>>(); + } + #region base fluent methods + + public new SqlHelperFluent AddCustomType(string name, Type type) + { + base.AddCustomType(name, type); + return this; + } + + public new SqlHelperFluent AddCustomType(string name) + { + base.AddCustomType(name); + return this; + } + + public new SqlHelperFluent AddFilter(string name, object value) + { + base.AddFilter(name, value); + return this; + } + + public new SqlHelperFluent AddFilter(string name, string value) + { + base.AddFilter(name, value); + return this; + } + + public new SqlHelperFluent AddParameter(string name, object value) + { + base.AddParameter(name, value); + return this; + } + + public new SqlHelperFluent AddParameter(string name, string value) + { + base.AddParameter(name, value); + return this; + } + + public new SqlHelperFluent AddParameterNullable(string name, object value) + { + base.AddParameterNullable(name, value); + return this; + } + + public new SqlHelperFluent AddParameterIfNotNull(string name, object value) + { + base.AddParameterIfNotNull(name, value); + return this; + } + + public new SqlHelperFluent AddOutputParameter(string name, object value) + { + base.AddParameter(name, value); + return this; + } + + public new SqlHelperFluent AddParameters(IEnumerable> parameters) + { + base.AddParameters(parameters); + return this; + } + + public new SqlHelperFluent AddFilters(IEnumerable> parameters) + { + base.AddFilters(parameters); + return this; + } + + public new SqlHelperFluent AsStoredProcedure() + { + base.AsStoredProcedure(); + return this; + } + + public new SqlHelperFluent AsSqlText() + { + base.AsSqlText(); + return this; + } + + public new SqlHelperFluent WithStrictSyntax() + { + base.WithStrictSyntax(); + return this; + } + + #endregion + + private RequestParamsSelect BuildParams() => new RequestParamsSelect(_connectionString, _sqlText, + _isStoredProcedure, _withStrictSyntax, _parameters, _customTypes, _converters.ToArray()); + + public SqlHelperFluent AddConverter(Action> converter) + { + _converters.Add(converter); + return this; + } + + public IEnumerable ExecuteSelectMany() + { + return _connectionString.GetSqlHelper().ExecuteSelectMany(BuildParams()); + } + + public IEnumerableWithPage ExecuteSelectManyWithPage() + { + return _connectionString.GetSqlHelper().ExecuteSelectManyWithPage(BuildParams()); + } + + public IEnumerableWithOffer ExecuteSelectManyWithOffer() + { + return _connectionString.GetSqlHelper().ExecuteSelectManyWithOffer(BuildParams()); + } + + public TEntity ExecuteScalar() + { + return _connectionString.GetSqlHelper().ExecuteScalar(BuildParams()); + } + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelperPostgres.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelperPostgres.cs new file mode 100644 index 0000000..34389c4 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelperPostgres.cs @@ -0,0 +1,280 @@ +using Npgsql; +using Npgsql.NameTranslation; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; + +namespace Kit.Helpers.SqlHelperAbstract.Postgres +{ + public class SqlHelper : ISqlHelper + { + private SqlHelper() { } + + public INpgsqlNameTranslator Translator { get; } = new NpgsqlSnakeCaseNameTranslator(); + + public static SqlHelper Instance { get; } = new SqlHelper(); + + private IEnumerable> UpdateParamNames(IEnumerable> @params) + { + var result = new List>(); + if (@params.IsNullOrEmpty()) return result; + + @params.ForEach(_param => + { + // Имя параметра транслируется в snake_case как есть (без добавления "_"): + // должно совпадать с именем параметра хранимой функции (p_doc_code и т.п.), + // т.к. вызов строится именованной нотацией: select * from fn(p_doc_code => :p_doc_code). + string key = Translator.TranslateMemberName(_param.Key.Remove(" ").Trim('@', '_')); + result.Add(new KeyValuePair(key, _param.Value)); + }); + return result; + } + + private string GenerateParamsForQuery(IEnumerable> @params) + { + var sb = new StringBuilder(); + if (@params.IsNullOrEmpty() == false) + { + for (int i = 0; i < @params.Count(); i++) + { + string key = @params.ElementAt(i).Key; + sb.Append(key).Append(" => :").Append(key); + + if (i < @params.Count() - 1) + { + sb.Append(", "); + } + } + } + return sb.ToString(); + } + + private string TranslateName(string name, bool withStrictSyntax) + { + if (withStrictSyntax) + { + return name; + } + + return name.Remove("[").Remove("]").Split('.').Select(x => Translator.TranslateMemberName(x)).Join("."); + } + + // Композиты, уже зарегистрированные в глобальном маппере (Npgsql 10: + // повторный MapComposite той же пары тип/имя бросает исключение). + private static readonly System.Collections.Concurrent.ConcurrentDictionary<(Type Type, string Name), bool> MappedComposites + = new System.Collections.Concurrent.ConcurrentDictionary<(Type, string), bool>(); + + private NpgsqlCommand AppendTypes(NpgsqlCommand command, RequestParams requestParams) + { + requestParams.CustomTypes?.ForEach(x => + { + string name = TranslateName(x.Name, requestParams.WithStrictSyntax); + if (MappedComposites.TryAdd((x.Type, name), true)) + { + NpgsqlConnection.GlobalTypeMapper.MapComposite(x.Type, name, Translator); + } + }); + return command; + } + + private NpgsqlCommand CreateCommand(RequestParams requestParams) + { + string sql = TranslateName(requestParams.CommandText, requestParams.WithStrictSyntax); + + bool hasParameters = requestParams.Parameters != null && requestParams.Parameters.Count() > 0; + + var parameters = UpdateParamNames(requestParams.Parameters); + + + if (requestParams.ConnectionString.Value.IsNullOrEmpty()) throw new ArgumentNullException("connectionString"); + if (sql.IsNullOrEmpty()) throw new ArgumentNullException("sql"); + + var connection = new NpgsqlConnection(requestParams.ConnectionString.Value); + + NpgsqlCommand command = connection.CreateCommand(); + command.CommandTimeout = connection.ConnectionTimeout; + if (requestParams.IsStoredProcedure) + { + sql = $"select * from {sql}({GenerateParamsForQuery(parameters)});"; + } + + command.CommandType = System.Data.CommandType.Text; + command.CommandText = sql; + + if (parameters != null && parameters.Any()) + { + foreach (var keyValuePair in parameters) + { + string key = keyValuePair.Key.Remove("@").Remove(" ").ToLower(); + if (keyValuePair.Value is Guid) + { + command.Parameters.AddWithValue(key.ToLower(), keyValuePair.Value ?? Guid.Empty); + } + else + { + command.Parameters.AddWithValue(key.ToLower(), keyValuePair.Value ?? DBNull.Value); + } + } + } + + return command; + } + + public void ExecuteNonQuery(RequestParams requestParams) + { + NpgsqlCommand command = CreateCommand(requestParams); + using (command.Connection) + { + command.Connection.Open(); + AppendTypes(command, requestParams); + command.ExecuteNonQuery(); + command.Connection.Close(); + } + } + + public object ExecuteScalar(RequestParams requestParams) + { + object returnScalar; + + NpgsqlCommand command = CreateCommand(requestParams); + using (command.Connection) + { + command.Connection.Open(); + AppendTypes(command, requestParams); + returnScalar = command.ExecuteScalar(); + command.Connection.Close(); + } + + return returnScalar; + } + + public TResult ExecuteScalar(RequestParams requestParams) + { + object returnScalar; + + NpgsqlCommand command = CreateCommand(requestParams); + using (command.Connection) + { + command.Connection.Open(); + AppendTypes(command, requestParams); + returnScalar = command.ExecuteScalar(); + command.Connection.Close(); + } + return (TResult)returnScalar; + } + + private IEnumerable ExecuteSelectMany(SelectType extraSelectType, RequestParamsSelect requestParams) + { + if (requestParams.Converters == null || requestParams.Converters.Count() < 1) throw new ArgumentNullException("converters"); + + NpgsqlCommand command = CreateCommand(requestParams); + command.CommandTimeout = command.Connection.ConnectionTimeout; + + IList selectResult = extraSelectType.CreateList(); + + using (command.Connection) + { + command.Connection.Open(); + AppendTypes(command, requestParams); + using (NpgsqlTransaction tran = command.Connection.BeginTransaction()) + { + using (IDataReader readerFirst = command.ExecuteReader()) + { + var dataTypeName = readerFirst.GetDataTypeName(0); // Для первого столбца + if (dataTypeName == "refcursor") + { + ProcessForCursors(readerFirst, command, requestParams.Converters, selectResult, extraSelectType); + } + else + { + ProcessForSimple(readerFirst, requestParams.Converters, selectResult, extraSelectType); + } + + } + tran.Commit(); + } + } + return selectResult; + } + + private void ProcessForSimple(IDataReader reader, IEnumerable>> converters, IList selectResult, SelectType extraSelectType = SelectType.Default) + { + IDataRecordSpecified recordSpecified = new DataRecordSpecified(reader, Translator); + foreach (var converter in converters) + { + while (reader.Read()) + { + converter(recordSpecified, selectResult); + } + reader.NextResult(); + } + recordSpecified.ProcessExtraSelect(selectResult, extraSelectType); + } + + private void CheckCursorCount(int cursorCount, int converterCount, SelectType extraSelectType) + { + switch (extraSelectType) + { + case SelectType.Page: + case SelectType.Offer: + Check.IsTrue(cursorCount >= converterCount + 1, "Количество конвертеров больше количества возвращаемых курсоров"); + break; + case SelectType.Default: + default: + Check.IsTrue(cursorCount >= converterCount, "Количество конвертеров больше количества возвращаемых курсоров"); + break; + } + } + + private void ProcessForCursors(IDataReader readerFirst, NpgsqlCommand command, IEnumerable>> converters, IList selectResult, SelectType extraSelectType = SelectType.Default) + { + var cursors = new List(); + while (readerFirst.Read()) + { + cursors.Add(readerFirst[0].ToString()); + } + readerFirst.Close(); + + CheckCursorCount(cursors.Count, converters.Count(), extraSelectType); + + for (int i = 0; i < converters.Count(); i++) + { + command.CommandText = $"fetch all in \"{cursors[i]}\""; + command.CommandType = CommandType.Text; + + using (NpgsqlDataReader reader = command.ExecuteReader()) + { + IDataRecordSpecified recordSpecified = new DataRecordSpecified(reader, Translator); + while (reader.Read()) + { + converters.ElementAt(i)(recordSpecified, selectResult); + } + } + } + if (extraSelectType != SelectType.Default) + { + // считывание данных постраничного вывода + command.CommandText = $"fetch all in \"{cursors.Last()}\""; + command.CommandType = CommandType.Text; + + using (NpgsqlDataReader reader = command.ExecuteReader()) + { + IDataRecordSpecified recordSpecified = new DataRecordSpecified(reader, Translator); + recordSpecified.ProcessExtraSelect(selectResult, extraSelectType); + } + } + } + + public IEnumerable ExecuteSelectMany(RequestParamsSelect requestParams) => ExecuteSelectMany(SelectType.Default, requestParams); + public IEnumerableWithPage ExecuteSelectManyWithPage(RequestParamsSelect requestParams) => (ListWithPage)ExecuteSelectMany(SelectType.Page, requestParams); + public IEnumerableWithOffer ExecuteSelectManyWithOffer(RequestParamsSelect requestParams) => (ListWithOffer)ExecuteSelectMany(SelectType.Offer, requestParams); + } + + public static class SqlHelperExtensions + { + public static TValue Get(this IDataRecordSpecified record, string fieldName) => record.Get(fieldName); + public static TValue GetN(this IDataRecordSpecified record, string fieldName) => record.GetN(fieldName); + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelperSQLite.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelperSQLite.cs new file mode 100644 index 0000000..1c157ec --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlHelperSQLite.cs @@ -0,0 +1,443 @@ +using Microsoft.Data.Sqlite; +using Npgsql.NameTranslation; +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Kit.Helpers.SqlHelperAbstract.SQLite +{ + internal class SQLiteDataRecordSpecified : DataRecordSpecified + { + public SQLiteDataRecordSpecified(IDataReader dataReader, Npgsql.INpgsqlNameTranslator nameTranslator) : base(dataReader, nameTranslator) { } + + public override TValue Convert(object value) => SQliteConvert.Convert(value); + } + + internal static class SQliteConvert + { + private static readonly IEnumerable _typeConverters = new List + { + new ConverterItem(typeof(bool), typeof(long), (value) => System.Convert.ToBoolean(value)), + new ConverterItem(typeof(byte), typeof(long), (value) => System.Convert.ToByte(value)), + new ConverterItem(typeof(char), typeof(string), (value) => System.Convert.ToChar(value.ToString())), +#if NETCOREAPP + new ConverterItem(typeof(DateOnly), typeof(string), (value) => DateOnly.ParseExact(value.ToString(), "yyyy-MM-dd")), +#endif + new ConverterItem(typeof(DateTime), typeof(string), (value) => DateTime.ParseExact(value.ToString(), "yyyy-MM-dd HH:mm:ss.FFFFFFF", CultureInfo.InvariantCulture)), + new ConverterItem(typeof(DateTimeOffset), typeof(string), (value) => DateTimeOffset.ParseExact(value.ToString(), "yyyy-MM-dd HH:mm:ss.FFFFFFFzzz", CultureInfo.InvariantCulture)), + new ConverterItem(typeof(decimal), typeof(string), (value) => System.Convert.ToDecimal(value.ToString())), + new ConverterItem(typeof(double), typeof(double), (value) => value), + new ConverterItem(typeof(Guid), typeof(string), (value) => Guid.Parse(value.ToString())), + new ConverterItem(typeof(short), typeof(long), (value) => System.Convert.ToInt16(value)), + new ConverterItem(typeof(int), typeof(long), (value) => System.Convert.ToInt32(value)), + new ConverterItem(typeof(long), typeof(long), (value) => value), + new ConverterItem(typeof(sbyte), typeof(long), (value) => System.Convert.ToSByte(value)), + new ConverterItem(typeof(float), typeof(double), (value) => System.Convert.ToSingle(value)), + new ConverterItem(typeof(string), typeof(string), (value) => value.ToString()), +#if NETCOREAPP + new ConverterItem(typeof(TimeOnly), typeof(string), (value) => TimeOnly.ParseExact(value.ToString(), "HH:mm:ss.fffffff", CultureInfo.InvariantCulture)), +#endif + new ConverterItem(typeof(TimeSpan), typeof(string), (value) => TimeSpan.ParseExact(value.ToString(), "d.hh:mm:ss.fffffff", CultureInfo.InvariantCulture)), + new ConverterItem(typeof(ushort), typeof(long), (value) => System.Convert.ToUInt16(value)), + new ConverterItem(typeof(uint), typeof(long), (value) => System.Convert.ToUInt32(value)), + new ConverterItem(typeof(ulong), typeof(long), (value) => System.Convert.ToUInt64(value)), + }; + + private class ConverterItem + { + public Type Tgt { get; set; } + public Type Src { get; set; } + public Func Func { get; set; } + + public ConverterItem(Type tgt, Type src, Func func) + { + this.Tgt = tgt; + this.Src = src; + this.Func = func; + } + } + + public static TValue Convert(object value) + { + Type valueType = value.GetType(); + Type targetType = typeof(TValue); + + return (TValue)(_typeConverters.FirstOrDefault(x => x.Tgt == targetType && x.Src == valueType)?.Func(value) ?? value); + } + } + + public class SqlHelper : ISqlHelper + { + private readonly ILockService _lockService = new LockService(); + private SqlHelper() { } + private object _databaseConnection; + public Npgsql.INpgsqlNameTranslator Translator { get; } = new NpgsqlSnakeCaseNameTranslator(); + + public static SqlHelper Instance { get; } = new SqlHelper(); + + private List> UpdateParamNames(RequestParams requestParams) + { + var result = new List>(); + if (requestParams.Parameters.IsNullOrEmpty()) return result; + + requestParams.Parameters.ForEach(_param => + { + if ((_param.Value.GetType().IsGenericType && + _param.Value.GetType().GetGenericTypeDefinition() == typeof(List<>)) || + (_param.Value.GetType() == typeof(int[])) + ) + { + var jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false + }; + string jsonData = JsonSerializer.Serialize(_param.Value, jsonOptions); + string key = ":" + (requestParams.WithStrictSyntax ? _param.Key : Translator.TranslateMemberName(_param.Key.Remove(" ").Trim('@', '_'))); + result.Add(new KeyValuePair(key, jsonData)); + } + else + { + string key = ":" + (requestParams.WithStrictSyntax ? _param.Key : Translator.TranslateMemberName(_param.Key.Remove(" ").Trim('@', '_'))); + result.Add(new KeyValuePair(key, _param.Value)); + } + }); + return result; + } + + private SqliteConnection CreateConnection(IConnectionString fileName) + { + string connectionString = ""; + try + { + connectionString = fileName.Value.CreateSQLiteConnectionString(); + var conn = new SqliteConnection(connectionString); + conn.Open(); + return conn; + } + catch (Exception ex) + { + throw new Exception($"ConnectionString:{connectionString} Message:{ex.Message}", ex); + } + } + + private Func _funcStrToIntJsonArr = (string input, string delimiter) => + { + if (string.IsNullOrWhiteSpace(input)) + { + return new List().ToJSON(); + } + if (string.IsNullOrWhiteSpace(delimiter)) + { + delimiter = ","; + } + HashSet ints = input.Split(new string[] { delimiter }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).Select(x => int.TryParse(x.Trim(), out int result) ? result : 0).ToHashSet(); + return ints.ToJSON(); + }; + + private class SQLiteFunctionParam + { + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public bool IsOptional { get; set; } +#if NETCOREAPP + [MemberNotNullWhen(true, "DefaultValueParsed")] +#endif + public bool HasDefault { get; set; } + public string DefaultValue { get; set; } = string.Empty; + + public SqliteType TypeParsed { get; set; } = SqliteType.Blob; + public object DefaultValueParsed { get; set; } + } + + private void ValidateAndCompleteParams(string funcName, string funcParams, string funcText, List> parametersUpdated) + { + var errors = new List(); + IEnumerable textParams = Regex.Matches(funcText, @"(:\w+)").Cast().Select(x => x.Value.ToLower()).Distinct().ToList(); + var paramParsedList = new List(); + + funcParams.Split(',').Select(x => x.Trim()).ForEach(x => + { + // Обязательный, без значения по умолчанию + var regexReq = new Regex(@"^(:\w+)\s+(\S+)$"); + + // Необязательный, по умолчанию null + var regexOpt = new Regex(@"^(:\w+)\s+(\S+)(?i:\s*=\s*|\s+default\s+)(?i:null)$"); + // Необязательный, указано значение по умолчанию + var regexOptW = new Regex(@"^(:\w+)\s+(\S+)(?i:\s*=\s*|\s+default\s+)(.*)"); + + Match match = null; + var param = new SQLiteFunctionParam(); + + if ((match = regexReq.Match(x)).Success) + { + param.Name = match.Groups[1].Value; + param.Type = match.Groups[2].Value; + param.IsOptional = false; + param.HasDefault = false; + param.DefaultValue = string.Empty; + } + else if ((match = regexOpt.Match(x)).Success) + { + param.Name = match.Groups[1].Value; + param.Type = match.Groups[2].Value; + param.IsOptional = true; + param.HasDefault = false; + param.DefaultValue = string.Empty; + } + else if ((match = regexOptW.Match(x)).Success) + { + param.Name = match.Groups[1].Value; + param.Type = match.Groups[2].Value; + param.IsOptional = true; + param.HasDefault = true; + param.DefaultValue = match.Groups[3].Value; + } + else + { + errors.Add($"Invalid parameter: {x}"); + return; + } + + param.Name = param.Name.ToLower(); + + try + { + param.TypeParsed = param.Type.ParseToEnum(ignoreCase: true); + } + catch + { + errors.Add($"parameter '{param.Name}': invalid type ({param.Type})"); + return; + } + + try + { + if (param.HasDefault) + { + switch (param.TypeParsed) + { + case SqliteType.Integer: + param.DefaultValueParsed = int.Parse(param.DefaultValue); + break; + case SqliteType.Real: + param.DefaultValueParsed = double.Parse(param.DefaultValue); + break; + case SqliteType.Text: + if (param.DefaultValue.StartsWith("\'") == false || param.DefaultValue.EndsWith("\'") == false) + throw new Exception(); + + param.DefaultValueParsed = param.DefaultValue.Trim('\'').Replace("''", "'"); + if (((string)param.DefaultValueParsed).IndexOf("''") != -1) + throw new Exception(); + break; + case SqliteType.Blob: + default: + break; + } + } + } + catch + { + errors.Add($"parameter '{param.Name}': invalid default value ({param.DefaultValue})"); + param.DefaultValueParsed = null; + + return; + } + + paramParsedList.Add(param); + }); + + IEnumerable delacrationDuplicates = paramParsedList.GroupBy(x => x.Name.ToLower()).Where(x => x.Count() > 1).Select(x => x.Key).ToList(); + if (delacrationDuplicates.Count() > 0) + { + errors.AddRange(delacrationDuplicates.Select(x => $"There are duplicates of the parameter declaration: {x}")); + } + + IEnumerable notDescribedParams = textParams.Where(x => paramParsedList.Any(y => y.Name == x) == false).ToList(); + if (notDescribedParams.Count() > 0) + { + errors.AddRange(notDescribedParams.Select(x => $"parameter not declared: {x}")); + } + + if (errors.Count > 0) + { + throw new Exception(errors.Join(Environment.NewLine)); + } + + // Ловим неуказанные обязательные параметры + IEnumerable notSendedRequired = paramParsedList.Where(x => x.IsOptional == false && parametersUpdated.Any(y => y.Key == x.Name) == false).Select(x => x.Name).ToList(); + if (notSendedRequired.Count() > 0) + { + errors.AddRange(notSendedRequired.Select(x => $"required parameter is not sended: {x}")); + } + + if (errors.Count > 0) + { + throw new Exception(errors.Join(Environment.NewLine)); + } + + // Ловим неуказанные необязательные параметры + paramParsedList.Where(x => x.IsOptional && parametersUpdated.Any(y => y.Key == x.Name) == false).ForEach(x => + { + if (x.HasDefault) + { + parametersUpdated.Add(new KeyValuePair(x.Name, x.DefaultValueParsed)); + } + else + { + parametersUpdated.Add(new KeyValuePair(x.Name, DBNull.Value)); + } + }); + } + + private void GetStoredFunctionContent(SqliteCommand command, string input, out string funcName, out string funcParams, out string funcText) + { + string oldText = command.CommandText; + CommandType oldType = command.CommandType; + + command.CommandType = CommandType.Text; + command.CommandText = SqlTexts.SelectStoredFunction; + command.Parameters.AddWithValue(":func_name", input); + + using (var reader = command.ExecuteReader()) + { + if (reader.Read() == false) + { + throw new Exception($"function '{input}' not found"); + } + + var readerD = new SQLiteDataRecordSpecified(reader, Translator); + + funcName = readerD.Get("FuncName"); + funcParams = readerD.Get("FuncParams"); + funcText = readerD.Get("FuncText"); + + if (funcName != input) + { + throw new Exception($"invalid function name (requested: {input}; received: {funcName})"); + } + } + command.CommandText = oldText; + command.CommandType = oldType; + command.Parameters.Clear(); + } + + private string GetCommandText(RequestParams requestParams) + { + if (requestParams.WithStrictSyntax) + { + return requestParams.CommandText; + } + + return requestParams.CommandText.Remove("[").Remove("]").Split('.').Select(x => Translator.TranslateMemberName(x)).Join("_"); + } + + private SqliteCommand CreateCommand(SqliteConnection connection, RequestParams requestParams) + { + if (requestParams.CommandText.IsNullOrEmpty()) throw new ArgumentNullException("sql"); + + string sql = GetCommandText(requestParams); + + var parametersUpdated = UpdateParamNames(requestParams); + + SqliteCommand command = connection.CreateCommand(); + command.CommandTimeout = connection.ConnectionTimeout; + + // Регистрируем пользовательские функции + connection.CreateFunction("str_to_int_json_arr", _funcStrToIntJsonArr, true); + + if (requestParams.IsStoredProcedure) + { + GetStoredFunctionContent(command, sql, out string funcName, out string funcParams, out string funcText); + ValidateAndCompleteParams(funcName, funcParams, funcText, parametersUpdated); + + sql = funcText; + } + if (sql.IsNullOrEmpty()) throw new ArgumentNullException("sql"); + + command.CommandType = CommandType.Text; + command.CommandText = sql; + + if (parametersUpdated.Count > 0) + { + foreach (var keyValuePair in parametersUpdated) + { + command.Parameters.AddWithValue(keyValuePair.Key, keyValuePair.Value ?? (keyValuePair.Value is Guid ? (object)Guid.Empty : (object)DBNull.Value)); + } + } + + return command; + } + + private TResult ExecuteOnConnect(RequestParams requestParams, Func func) + { + return _lockService.Lock(requestParams.ConnectionString.Value, () => + { + bool needNewConnect = _databaseConnection == null || _databaseConnection is SqliteConnection == false; + SqliteConnection connection = needNewConnect ? CreateConnection(requestParams.ConnectionString) : (SqliteConnection)_databaseConnection; + + try + { + using (SqliteCommand command = CreateCommand(connection, requestParams)) + return func(command); + } + finally + { + if (needNewConnect) + connection?.Dispose(); + + connection = null; + } + }); + } + + public void ExecuteNonQuery(RequestParams requestParams) + { + ExecuteOnConnect(requestParams, (SqliteCommand command) => command.ExecuteNonQuery()); + } + + public object ExecuteScalar(RequestParams requestParams) + { + return ExecuteOnConnect(requestParams, (SqliteCommand command) => command.ExecuteScalar()); + } + + public TResult ExecuteScalar(RequestParams requestParams) + { + return ExecuteOnConnect(requestParams, (SqliteCommand command) => SQliteConvert.Convert(command.ExecuteScalar())); + } + + private IEnumerable ExecuteSelectMany(SelectType extraSelectType, RequestParamsSelect requestParams) + { + if (requestParams.Converters == null || requestParams.Converters.Count() < 1) throw new ArgumentNullException("converters"); + + return ExecuteOnConnect(requestParams, (SqliteCommand command) => + { + IList selectResult = extraSelectType.CreateList(); + using (IDataReader reader = command.ExecuteReader()) + { + IDataRecordSpecified readerSpecified = new SQLiteDataRecordSpecified(reader, Translator); + foreach (var converter in requestParams.Converters) + { + while (reader.Read()) + { + converter(readerSpecified, selectResult); + } + reader.NextResult(); + } + readerSpecified.ProcessExtraSelect(selectResult, extraSelectType); + } + return selectResult; + }); + } + + public IEnumerable ExecuteSelectMany(RequestParamsSelect requestParams) => ExecuteSelectMany(SelectType.Default, requestParams); + public IEnumerableWithPage ExecuteSelectManyWithPage(RequestParamsSelect requestParams) => (ListWithPage)ExecuteSelectMany(SelectType.Page, requestParams); + public IEnumerableWithOffer ExecuteSelectManyWithOffer(RequestParamsSelect requestParams) => (ListWithOffer)ExecuteSelectMany(SelectType.Offer, requestParams); + } +} \ No newline at end of file diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlTexts.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlTexts.cs new file mode 100644 index 0000000..faf7622 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/SqlTexts.cs @@ -0,0 +1,31 @@ +using System.Collections.Concurrent; +using System.Reflection; + +namespace Kit.Helpers.SqlHelperAbstract +{ + /// + /// SQL-тексты библиотеки, хранятся EmbeddedResource-файлами в SqlHelperAbstract\Sql\*.sql (см. Kit.Helpers.csproj). + /// + internal static class SqlTexts + { + private static readonly ConcurrentDictionary _cache = new(); + + /// Lookup «хранимой функции» в sys_functions (SQLite). + public static string SelectStoredFunction => Load("sys_function_select.sql"); + + private static string Load(string fileName) + { + return _cache.GetOrAdd(fileName, key => + { + Assembly assembly = typeof(SqlTexts).Assembly; + string resourceName = assembly.GetManifestResourceNames().SingleOrDefault(x => x.EndsWith(key, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Embedded sql resource not found: {key}"); + + using Stream stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Embedded sql resource is empty: {resourceName}"); + using var reader = new StreamReader(stream); + return reader.ReadToEnd().Trim(); + }); + } + } +} diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/Test/ConnectionStringTest.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/Test/ConnectionStringTest.cs new file mode 100644 index 0000000..61112b4 --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/Test/ConnectionStringTest.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Data; + +namespace Kit.Helpers.SqlHelperAbstract +{ + /// + /// Строка подключения для unit-тестов: единственный публичный вход в тестовое API (конвенция 1:1). + /// Фейк-транспорт (SqlHelperTest) — internal: недостижим из внешнего и прод-кода, + /// в том числе через new; тестирование репозиториев идёт только через этот интерфейс. + /// + public interface IConnectionStringTest : IConnectionString + { + /// Падает, если открытая заготовка не израсходована вызовом транспорта. + void VerifyAll(); + + /// Снапшоты всех запросов к транспорту, в порядке вызовов (ассерты снаружи). + IReadOnlyList Requests { get; } + } + + /// + /// Строка подключения для unit-тестов: вместо соединения с БД возвращает внутренний фейк-транспорт. + /// Один экземпляр на тест-метод, конвенция 1:1 (одна заготовка = один вызов транспорта = один метод репозитория): + /// var expArgs = new ConnectionString().PrepareExecute("req.requirement_select") + /// .AsStoredProcedure() + /// .AddParameter("p_doc_code", "TEST-DOC") + /// .AddParameter("p_rid", DBNull.Value) + /// .BuildParams(); + /// var cs = new ConnectionStringTest(expArgs, _fxRequirementTable); // один DataTable = один курсор + /// var rows = new RequirementRepository(cs).SelectRequirements(filter); + /// cs.VerifyAll(); + /// // дополнительные ассерты: cs.Requests — журнал вызовов транспорта. + /// Скалярный ответ (ExecuteScalar/NonQuery) — фабрика ConnectionStringTest.Scalar(expArgs, 1). + /// Заготовкаввввake_case, типизированные DataColumn — значения в CLR-типах, + /// которые вернул бы Npgsql (integer → int, bigint → long, boolean → bool, text → string, + /// timestamptz → DateTime); null-ячейка — DBNull.Value (NULL-строка LEFT JOIN). + /// Беззначная заявка параметра (только наличие, значение не сверяется) — маркер ParamExpect.Any. + /// + public class ConnectionStringTest : ConnectionString, IConnectionStringTest + { + private readonly SqlHelperTest _sqlHelper; + + /// + /// Заготовка выборки: таблицы DataTable — по одной на курсор (курсор ↔ конвертер), + /// порядок таблиц = порядок курсоров функции БД; пустой результат — таблица с колонками без строк. + /// + public ConnectionStringTest(RequestParams requestParams, params DataTable[] resultTables) + : this(requestParams, resultTables, scalarResult: null) + { + } + + /// + /// Заготовка скалярного ответа (ExecuteScalar; для ExecuteNonQuery результат не читается, + /// но заготовка обязана быть описана — конвенция «ответ обязан быть описан»). + /// + public static ConnectionStringTest Scalar(RequestParams requestParams, object scalarResult) => + new(requestParams, Array.Empty(), scalarResult); + + private ConnectionStringTest(RequestParams requestParams, DataTable[] resultTables, object? scalarResult) + { + _sqlHelper = new SqlHelperTest(requestParams, resultTables, scalarResult); + } + + public override ISqlHelper GetSqlHelper() => _sqlHelper; + + public void VerifyAll() => _sqlHelper.VerifyAll(); + + public IReadOnlyList Requests => _sqlHelper.Requests; + } + + /// Маркеры ожиданий параметров заготовки ConnectionStringTest. + public static class ParamExpect + { + /// Беззначная заявка: параметр обязан уйти в запрос, значение не сверяется. + public static readonly object Any = new(); + } +} diff --git a/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/Test/SqlHelperTest.cs b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/Test/SqlHelperTest.cs new file mode 100644 index 0000000..25c578a --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/Test/SqlHelperTest.cs @@ -0,0 +1,259 @@ +using Npgsql.NameTranslation; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; + +namespace Kit.Helpers.SqlHelperAbstract +{ + /// + /// Подмена ISqlHelper без БД — тестовое окружение репозиториев (ассерты снаружи + ожидания внутри фейка). + /// Внутренний тип: единственный вход в тестовое API — IConnectionStringTest / ConnectionStringTest + /// (снаружи сборки недостижим, в том числе через new — прод-код не может подменить себе транспорт по ошибке). + /// Конвенция 1:1: одна заготовка = один вызов транспорта = один тестируемый метод репозитория. + /// - заготовка собирается снаружи: expArgs (PrepareExecute(...).AddParameter(...).BuildParams(); + /// беззначная заявка — маркер ParamExpect.Any) + таблицы DataTable (по одной на курсор; колонки + /// типизированы, значения — в CLR-типах Npgsql) либо скалярный результат (Scalar); + /// ответ обязан быть описан, иначе вызов транспорта падает; + /// - вызов транспорта сверяет CommandText, IsStoredProcedure и строгий набор имён параметров + /// в обе стороны (SqlHelperTestException при расхождении); второй вызов транспорта — ошибка; + /// - выборка исполняется по-настоящему: DataTableReader + DataRecordSpecified + + /// NpgsqlSnakeCaseNameTranslator (по курсору на конвертер, строки построчно в общий список, + /// хвост Page/Offer читается ProcessExtraSelect после последнего конвертера); + /// - ноль вызовов ловит VerifyAll(); журнал запросов (Requests) — для ассертов снаружи. + /// + internal sealed class SqlHelperTest : ISqlHelper + { + private readonly RequestParams _requestParams; + private readonly DataTable[] _resultTables; + private readonly object? _scalarResult; + private readonly List _requests = new(); + private bool _consumed; + + public SqlHelperTest(RequestParams requestParams, DataTable[] resultTables, object? scalarResult) + { + _requestParams = requestParams ?? throw new ArgumentNullException(nameof(requestParams)); + _resultTables = resultTables ?? Array.Empty(); + _scalarResult = scalarResult; + } + + #region Журнал запросов (ассерты снаружи) + + /// Снапшоты всех запросов к транспорту, в порядке вызовов. + internal IReadOnlyList Requests => _requests; + + /// Падает, если открытая заготовка не израсходована: репозиторий не вызвал транспорт. + internal void VerifyAll() + { + if (HasResponse && !_consumed) + throw new SqlHelperTestException( + $"Заготовка \"{_requestParams.CommandText}\" не израсходована: репозиторий не вызвал транспорт (ожидался один вызов — конвенция 1:1)."); + } + + /// Ответ описан: хотя бы одна таблица курсоров или скалярный результат. + private bool HasResponse => _resultTables.Length > 0 || _scalarResult != null; + + #endregion + + #region ISqlHelper + + public void ExecuteNonQuery(RequestParams requestParams) => TakeCall(requestParams); + + /// Возвращает заготовленное значение; жёсткий каст — как в проде. + public object ExecuteScalar(RequestParams requestParams) => TakeCall(requestParams).ScalarResult!; + + public TResult ExecuteScalar(RequestParams requestParams) => + (TResult)TakeCall(requestParams).ScalarResult!; + + public IEnumerable ExecuteSelectMany(RequestParamsSelect requestParams) => + ExecuteSelectMany(SelectType.Default, requestParams); + + public IEnumerableWithPage ExecuteSelectManyWithPage(RequestParamsSelect requestParams) => + (IEnumerableWithPage)ExecuteSelectMany(SelectType.Page, requestParams); + + public IEnumerableWithOffer ExecuteSelectManyWithOffer(RequestParamsSelect requestParams) => + (IEnumerableWithOffer)ExecuteSelectMany(SelectType.Offer, requestParams); + + #endregion + + private IEnumerable ExecuteSelectMany(SelectType extraSelectType, RequestParamsSelect requestParams) + { + if (requestParams.Converters == null || !requestParams.Converters.Any()) + throw new ArgumentNullException("converters", "Список конвертеров пуст."); + + DataTable[] resultSets = TakeCall(requestParams).ResultTables; + + int convertersCount = requestParams.Converters.Count(); + int requiredCursors = convertersCount + (extraSelectType == SelectType.Default ? 0 : 1); + if (resultSets.Length < requiredCursors) + throw new SqlHelperTestException( + $"Количество конвертеров больше количества возвращаемых курсоров: конвертеров {convertersCount}" + + (extraSelectType == SelectType.Default ? "" : $" (+ хвост {extraSelectType})") + + $", а заготовок курсоров {resultSets.Length} ({Describe(requestParams)})."); + + IList selectResult = extraSelectType.CreateList(); + + // Как ProcessForSimple: по курсору (DataTable) на конвертер, строки подаются построчно, + // хвост Page/Offer читается ProcessExtraSelect после последнего конвертера. + Npgsql.INpgsqlNameTranslator translator = new NpgsqlSnakeCaseNameTranslator(); + int cursorIndex = 0; + foreach (Action> converter in requestParams.Converters) + { + using DataTableReader reader = resultSets[cursorIndex++].CreateDataReader(); + IDataRecordSpecified recordSpecified = new DataRecordSpecified(reader, translator); + while (reader.Read()) + converter(recordSpecified, selectResult); + } + + if (extraSelectType != SelectType.Default) + { + using DataTableReader reader = resultSets[cursorIndex].CreateDataReader(); + IDataRecordSpecified recordSpecified = new DataRecordSpecified(reader, translator); + recordSpecified.ProcessExtraSelect(selectResult, extraSelectType); + } + + return selectResult; + } + + private sealed class CallSetup + { + public DataTable[] ResultTables = Array.Empty(); + public object? ScalarResult; + } + + private CallSetup TakeCall(RequestParams requestParams) + { + if (!HasResponse) + throw new SqlHelperTestException( + $"Транспорт вызван без заготовленного ответа ({Describe(requestParams)}). " + + "Опишите ответ: таблицы DataTable (курсоры выборки) или скалярный результат — ConnectionStringTest.Scalar(...)."); + if (_consumed) + throw new SqlHelperTestException( + $"Повторный вызов транспорта ({Describe(requestParams)}): по конвенции 1:1 одна заготовка обслуживает ровно один вызов."); + + AssertExpected(requestParams); + _consumed = true; + _requests.Add(Snapshot(requestParams)); + + return new CallSetup { ResultTables = _resultTables, ScalarResult = _scalarResult }; + } + + + /// compare(repArgs, expArgs): сверяет фактические аргументы вызова с заготовкой. + private void AssertExpected(RequestParams requestParams) + { + List errors = new(); + + if (!string.Equals(_requestParams.CommandText, requestParams.CommandText, StringComparison.Ordinal)) + errors.Add($"CommandText: ожидалось \"{_requestParams.CommandText}\", фактически \"{requestParams.CommandText}\""); + + if (_requestParams.IsStoredProcedure != requestParams.IsStoredProcedure) + errors.Add($"IsStoredProcedure: ожидалось {_requestParams.IsStoredProcedure}, фактически {requestParams.IsStoredProcedure}"); + + Dictionary actual = new(StringComparer.Ordinal); + foreach (KeyValuePair parameter in requestParams.Parameters ?? Enumerable.Empty>()) + actual[parameter.Key] = parameter.Value; // дубликаты имён: остаётся последнее значение + + Dictionary declared = new(StringComparer.Ordinal); + foreach (KeyValuePair parameter in _requestParams.Parameters ?? Enumerable.Empty>()) + declared[parameter.Key] = parameter.Value; + + // Значения/наличие заявленных параметров (ParamExpect.Any — только наличие). + foreach (KeyValuePair expectation in declared) + { + if (!actual.TryGetValue(expectation.Key, out object? value)) + errors.Add($"{expectation.Key}: параметр не передан, ожидалось {DescribeExpectation(expectation.Value)}"); + else if (!ReferenceEquals(expectation.Value, ParamExpect.Any) && !ValueEquals(expectation.Value, value)) + errors.Add($"{expectation.Key}: ожидалось {DisplayValue(expectation.Value)}, фактически {DisplayValue(value)}"); + } + + // Строгий набор имён в обе стороны: незаявленные фактические параметры — ошибка. + foreach (KeyValuePair parameter in actual) + if (!declared.ContainsKey(parameter.Key)) + errors.Add($"{parameter.Key}: незаявленный параметр ({DisplayValue(parameter.Value)}); заявлены: {string.Join(", ", declared.Keys)}"); + + if (errors.Count > 0) + throw new SqlHelperTestException( + $"Вызов — аргументы не совпали с ожидаемыми ({Describe(requestParams)}):\n - " + + string.Join("\n - ", errors)); + } + + + /// Точное сравнение: null и DBNull взаимозаменяемы, перечисления (массивы) — поэлементно. + internal static bool ValueEquals(object? expected, object? actual) + { + bool expectedEmpty = expected is null or DBNull; + bool actualEmpty = actual is null or DBNull; + if (expectedEmpty || actualEmpty) + return expectedEmpty && actualEmpty; + + if (expected is string expectedString && actual is string actualString) + return string.Equals(expectedString, actualString, StringComparison.Ordinal); + + if (expected is IEnumerable expectedItems && actual is IEnumerable actualItems) + { + IEnumerator expectedEnumerator = expectedItems.GetEnumerator(); + IEnumerator actualEnumerator = actualItems.GetEnumerator(); + try + { + while (expectedEnumerator.MoveNext()) + { + if (!actualEnumerator.MoveNext()) + return false; + if (!ValueEquals(expectedEnumerator.Current, actualEnumerator.Current)) + return false; + } + return !actualEnumerator.MoveNext(); + } + finally + { + (expectedEnumerator as IDisposable)?.Dispose(); + (actualEnumerator as IDisposable)?.Dispose(); + } + } + + return expected!.Equals(actual); + } + + private static string DescribeExpectation(object? value) => + ReferenceEquals(value, ParamExpect.Any) ? "наличие (значение не проверяется)" : DisplayValue(value); + + /// Копия запроса с материализованными параметрами (отложенные перечисления не протухают). + private static RequestParams Snapshot(RequestParams r) => new( + r.ConnectionString, r.CommandText, r.IsStoredProcedure, r.WithStrictSyntax, + r.Parameters?.ToList() ?? new List>(), + r.CustomTypes?.ToList() ?? new List()); + + private static string Describe(RequestParams r) + { + StringBuilder sb = new(r.CommandText); + sb.Append('('); + bool first = true; + foreach (KeyValuePair parameter in r.Parameters ?? Enumerable.Empty>()) + { + sb.Append(first ? "" : ", ").Append(parameter.Key).Append('=').Append(DisplayValue(parameter.Value)); + first = false; + } + sb.Append(')'); + return sb.ToString(); + } + + private static string DisplayValue(object? value) => value switch + { + null => "null", + DBNull => "DBNull", + string s => $"\"{s}\"", + IEnumerable items when value is not string => "[" + string.Join(", ", items.Cast().Select(DisplayValue)) + "]", + _ => value.ToString() ?? "", + }; + } + + /// Нарушение дисциплины тестовой заготовки SqlHelperTest (слот/ответ/аргументы/курсоры). + public sealed class SqlHelperTestException : Exception + { + public SqlHelperTestException(string message) : base(message) { } + } +} + diff --git a/3-app/1-lib/Kit.Helpers/Validation/FormatCheck.cs b/3-app/1-lib/Kit.Helpers/Validation/FormatCheck.cs new file mode 100644 index 0000000..4cd2def --- /dev/null +++ b/3-app/1-lib/Kit.Helpers/Validation/FormatCheck.cs @@ -0,0 +1,147 @@ +using System.Text.RegularExpressions; + +namespace Kit.Helpers.Validation +{ + /// + /// Проверки форматов часто встречающихся реквизитов (ИНН, email, телефон, СНИЛС). Чистые + /// предикаты без побочных эффектов и текста ошибок: возвращают true/false, решение о + /// throw и формулировку сообщения принимает вызывающий код. Реализация — только BCL + /// (, LINQ), без сторонних зависимостей. null/пустая строка → false + /// (обязательность и «пропуск пустого опционального поля» — забота вызывающего). + /// + public static class FormatCheck + { + // Прагматичная проверка email: одна @, непустые локальная и доменная части, точка в домене. + private static readonly Regex EmailRegex = + new(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled); + + // Веса контрольных разрядов ИНН (ФНС): 10-й разряд; 11-й и 12-й — для 12-значного. + private static readonly int[] InnWeights10 = { 2, 4, 10, 3, 5, 9, 4, 6, 8 }; + private static readonly int[] InnWeights11 = { 7, 2, 4, 10, 3, 5, 9, 4, 6, 8 }; + private static readonly int[] InnWeights12 = { 3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8 }; + + /// ИНН: только цифры, длина 10 (ЮЛ) или 12 (ИП/ФЛ), контрольная сумма сходится. + public static bool IsInn(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + + var digits = value.Trim(); + if (!digits.All(char.IsDigit) || (digits.Length != 10 && digits.Length != 12)) + return false; + + return IsInnChecksumValid(digits); + } + + /// Email по прагматичному шаблону (одна @, точка в домене), длина ≤ 254. + public static bool IsEmail(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + + var email = value.Trim(); + return email.Length <= 254 && EmailRegex.IsMatch(email); + } + + /// Телефон: 10–15 цифр; допустимы «+», пробел, дефис, скобки как разделители. + public static bool IsPhone(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + + var phone = value.Trim(); + var hasInvalidChar = phone.Any(character => + !char.IsDigit(character) && character is not ('+' or '-' or ' ' or '(' or ')')); + if (hasInvalidChar) return false; + + var digitsCount = phone.Count(char.IsDigit); + return digitsCount is >= 10 and <= 15; + } + + /// СНИЛС: 11 цифр (разделители игнорируются), контрольная сумма сходится. + public static bool IsSnils(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + + var digits = new string(value.Where(char.IsDigit).ToArray()); + return digits.Length == 11 && IsSnilsChecksumValid(digits); + } + + /// + /// Строка идентификаторов для фильтров БД (формат fn_sys_range): целые числа + /// и диапазоны через «..» или «-», элементы через запятую, пробелы игнорируются. + /// Пример: «1,5,7..9, 11..15,19». У диапазона начало не больше конца. + /// + public static bool IsIdRange(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + + // Строгий разбор: пустой элемент (двойная запятая, хвостовая запятая) недопустим. + var items = value.Split(','); + return items.All(item => IsIdRangeItemValid(item.Trim())); + } + + // Элемент строки идентификаторов: «42», «7..9» или «7-9»; диапазон не обратный. + // Пробелы внутри элемента («2 .. 3») игнорируются. + private static bool IsIdRangeItemValid(string item) + { + item = item.Replace(" ", string.Empty); + if (item.Length == 0) return false; + + var separatorIndex = item.IndexOf("..", StringComparison.Ordinal); + string lo; + string hi; + + if (separatorIndex >= 0) + { + lo = item[..separatorIndex]; + hi = item[(separatorIndex + 2)..]; + } + else + { + var dashIndex = item.IndexOf('-', StringComparison.Ordinal); + if (dashIndex < 0) return IsPositiveInt(item); + + lo = item[..dashIndex]; + hi = item[(dashIndex + 1)..]; + } + + return IsPositiveInt(lo) && IsPositiveInt(hi) && int.Parse(lo) <= int.Parse(hi); + } + + // Целое неотрицательное число без знака и ведущих «+». + private static bool IsPositiveInt(string value) + => value.Length > 0 && value.All(char.IsDigit); + + // Контрольная сумма ИНН: для 10-значного — один контрольный разряд, для 12-значного — два. + private static bool IsInnChecksumValid(string inn) + { + var digits = inn.Select(character => character - '0').ToArray(); + + if (digits.Length == 10) + return ControlDigit(digits, InnWeights10) == digits[9]; + + return ControlDigit(digits, InnWeights11) == digits[10] + && ControlDigit(digits, InnWeights12) == digits[11]; + } + + private static int ControlDigit(int[] digits, int[] weights) + { + var sum = weights.Select((weight, index) => weight * digits[index]).Sum(); + return sum % 11 % 10; + } + + // Контрольная сумма СНИЛС: взвешенная сумма первых 9 цифр (веса 9..1) mod 101 → последние две. + private static bool IsSnilsChecksumValid(string snils) + { + var digits = snils.Select(character => character - '0').ToArray(); + + var sum = 0; + for (var i = 0; i < 9; i++) + sum += digits[i] * (9 - i); + + var control = sum % 101; + if (control == 100) control = 0; + + var provided = digits[9] * 10 + digits[10]; + return control == provided; + } + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core.Tests/AthleteRepositoryTests.cs b/3-app/2-cli/Server.WatchBody.Core.Tests/AthleteRepositoryTests.cs new file mode 100644 index 0000000..d0d5c31 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core.Tests/AthleteRepositoryTests.cs @@ -0,0 +1,238 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Core.Dal.Ref.Repository; +using Xunit; + +namespace Server.WatchBody.Core.Tests; + +// Тесты AthleteRepository / AthleteProfileRepository по repository_tests\AthleteRepository.md +// и AthleteProfileRepository.md (d-tst: тест → репозиторий → фейк-транспорт). + +public class AthleteRepositoryTests +{ + [Fact] + public void Insert_1Основной_Scalar7_ПараметрыИмяХэш() + { + var expArgs = new ConnectionString().PrepareExecute("src.athlete_insert") + .AsStoredProcedure() + .AddParameter("p_name", "TEST-Атлет") + .AddParameter("p_api_key_hash", "TEST-hash-1") + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 7); + + var result = new AthleteRepository(cs).Insert( + new Athlete { Name = "TEST-Атлет", ApiKeyHash = "TEST-hash-1" }); + + Assert.Equal(7, result); + Assert.Single(cs.Requests); + } + + [Fact] + public void Update_1Основной_Scalar1_ВсеПоля() + { + var expArgs = new ConnectionString().PrepareExecute("src.athlete_update") + .AsStoredProcedure() + .AddParameter("p_id", 1) + .AddParameter("p_name", "TEST-Атлет-новый") + .AddParameter("p_api_key_hash", "TEST-hash-новый") + .AddParameter("p_is_blocked", true) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 1); + + var result = new AthleteRepository(cs).Update( + new Athlete { Id = 1, Name = "TEST-Атлет-новый", ApiKeyHash = "TEST-hash-новый", IsBlocked = true }); + + Assert.Equal(1, result); + } + + [Fact] + public void Select_1Основной_ФильтрХэш_1Сущность() + { + var expArgs = new ConnectionString().PrepareExecute("src.athlete_select") + .AsStoredProcedure() + .AddParameter("p_api_key_hash", "TEST-hash-1") + .AddParameter("p_is_blocked", DBNull.Value) + .BuildParams(); + var table = TestInfra.Table(TestInfra.AthleteCols, + [1, "TEST-Атлет", "TEST-hash-1", false, new DateTime(2026, 10, 17)]); + var cs = new ConnectionStringTest(expArgs, table); + + var rows = new AthleteRepository(cs).Select( + new AthleteFilter { ApiKeyHash = "TEST-hash-1", IsBlocked = null }).ToList(); + + var a = Assert.Single(rows); + Assert.Equal(1, a.Id); + Assert.Equal("TEST-Атлет", a.Name); + Assert.False(a.IsBlocked); + } + + [Fact] + public void Select_2Граничный_ПустаяТаблица_ПустаяКоллекция() + { + var expArgs = new ConnectionString().PrepareExecute("src.athlete_select") + .AsStoredProcedure() + .AddParameter("p_api_key_hash", DBNull.Value) + .AddParameter("p_is_blocked", DBNull.Value) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.AthleteCols)); + + var rows = new AthleteRepository(cs).Select(new AthleteFilter()); + + Assert.Empty(rows); + } + + [Fact] + public void Get_1Основной_Сущность() + { + var expArgs = new ConnectionString().PrepareExecute("src.athlete_get") + .AsStoredProcedure() + .AddParameter("p_id", 1) + .BuildParams(); + var table = TestInfra.Table(TestInfra.AthleteCols, + [1, "TEST-Атлет", "TEST-hash-1", false, new DateTime(2026, 10, 17)]); + var cs = new ConnectionStringTest(expArgs, table); + + var a = new AthleteRepository(cs).Get(1); + + Assert.NotNull(a); + Assert.Equal("TEST-Атлет", a.Name); + } + + [Fact] + public void Get_2Граничный_ПустаяТаблица_Null() + { + var expArgs = new ConnectionString().PrepareExecute("src.athlete_get") + .AsStoredProcedure() + .AddParameter("p_id", 999) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.AthleteCols)); + + Assert.Null(new AthleteRepository(cs).Get(999)); + } +} + +public class AthleteProfileRepositoryTests +{ + [Fact] + public void Upsert_1Основной_HrMaxNull_КакDbNull() + { + var expArgs = new ConnectionString().PrepareExecute("src.athlete_profile_upsert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_sex", "m") + .AddParameter("p_birth_year", 1978) + .AddParameter("p_weight_kg", 158.0m) + .AddParameter("p_hr_max", DBNull.Value) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 3); + + var result = new AthleteProfileRepository(cs).Upsert( + new AthleteProfile { AthleteId = 1, Sex = "m", BirthYear = 1978, WeightKg = 158.0m, HrMax = null }); + + Assert.Equal(3, result); + } + + [Fact] + public void Get_1Основной_Сущность() + { + var expArgs = new ConnectionString().PrepareExecute("src.athlete_profile_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .BuildParams(); + var table = TestInfra.Table(TestInfra.ProfileCols, + [10, 1, "m", 1978, 158.0m, DBNull.Value, new DateTime(2026, 10, 17)]); + var cs = new ConnectionStringTest(expArgs, table); + + var p = new AthleteProfileRepository(cs).Get(1); + + Assert.NotNull(p); + Assert.Equal(158.0m, p.WeightKg); + Assert.Null(p.HrMax); + } + + [Fact] + public void Get_2Граничный_ПустаяТаблица_Null() + { + var expArgs = new ConnectionString().PrepareExecute("src.athlete_profile_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 2) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.ProfileCols)); + + Assert.Null(new AthleteProfileRepository(cs).Get(2)); + } +} + +public class ExerciseGroupRepositoryTests +{ + [Fact] + public void Select_1Основной_2Сущности() + { + var expArgs = new ConnectionString().PrepareExecute("ref.exercise_group_select") + .AsStoredProcedure() + .BuildParams(); + var table = TestInfra.Table(TestInfra.GroupCols, + [1, "push", "Жимовые"], [3, "legs", "Ноги"]); + var cs = new ConnectionStringTest(expArgs, table); + + var rows = new ExerciseGroupRepository(cs).Select().ToList(); + + Assert.Equal(2, rows.Count); + Assert.Equal("push", rows[0].Code); + } + + [Fact] + public void Select_2Граничный_ПустаяТаблица() + { + var expArgs = new ConnectionString().PrepareExecute("ref.exercise_group_select") + .AsStoredProcedure() + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.GroupCols)); + + Assert.Empty(new ExerciseGroupRepository(cs).Select()); + } +} + +public class MigrationRepositoryTests +{ + [Fact] + public void Insert_1Основной() + { + var expArgs = new ConnectionString().PrepareExecute("src.migration_insert") + .AsStoredProcedure() + .AddParameter("p_filename", "TEST-0001.sql") + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 1); + + Assert.Equal(1, new MigrationRepository(cs).Insert("TEST-0001.sql")); + } + + [Fact] + public void Select_1Основной_Сущность() + { + var expArgs = new ConnectionString().PrepareExecute("src.migration_select") + .AsStoredProcedure() + .AddParameter("p_filename", "TEST-0001.sql") + .BuildParams(); + var table = TestInfra.Table(TestInfra.MigrationCols, [1, "TEST-0001.sql", new DateTime(2026, 10, 17)]); + var cs = new ConnectionStringTest(expArgs, table); + + var m = new MigrationRepository(cs).Select("TEST-0001.sql"); + + Assert.NotNull(m); + Assert.Equal("TEST-0001.sql", m.Filename); + } + + [Fact] + public void Select_2Граничный_ПустаяТаблица_Null() + { + var expArgs = new ConnectionString().PrepareExecute("src.migration_select") + .AsStoredProcedure() + .AddParameter("p_filename", "TEST-нет.sql") + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.MigrationCols)); + + Assert.Null(new MigrationRepository(cs).Select("TEST-нет.sql")); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core.Tests/DailyLogRepositoryTests.cs b/3-app/2-cli/Server.WatchBody.Core.Tests/DailyLogRepositoryTests.cs new file mode 100644 index 0000000..e39ab04 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core.Tests/DailyLogRepositoryTests.cs @@ -0,0 +1,232 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Xunit; + +namespace Server.WatchBody.Core.Tests; + +// Тесты DailyLogRepository / WorkoutMetricsRepository / BreakRepository +// по repository_tests\DailyLogRepository.md, WorkoutMetricsRepository.md, BreakRepository.md. + +public class DailyLogRepositoryTests +{ + private static readonly Guid D1 = Guid.Parse("dddd0000-0000-0000-0000-000000000001"); + + [Fact] + public void Upsert_1Основной_8Параметров() + { + var expArgs = new ConnectionString().PrepareExecute("src.daily_log_upsert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", D1) + .AddParameter("p_date", new DateOnly(2026, 10, 17)) + .AddParameter("p_bp_sys", 130) + .AddParameter("p_bp_dias", 80) + .AddParameter("p_weight_kg", 157.5m) + .AddParameter("p_hr_resting", 60) + .AddParameter("p_wellbeing", 5) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, D1); + + var result = new DailyLogRepository(cs).Upsert(1, new DailyLog + { + Id = D1, Date = new DateOnly(2026, 10, 17), + BpSys = 130, BpDias = 80, WeightKg = 157.5m, HrResting = 60, Wellbeing = 5, + }); + + Assert.Equal(D1, result); + } + + [Fact] + public void Select_1Основной_1Сущность() + { + var expArgs = new ConnectionString().PrepareExecute("src.daily_log_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_date_from", DBNull.Value) + .AddParameter("p_date_to", DBNull.Value) + .BuildParams(); + var table = TestInfra.Table(TestInfra.DailyLogCols, + [D1, 1, new DateTime(2026, 10, 17), 130, 80, 157.5m, 60, DBNull.Value, DBNull.Value, 5]); + var cs = new ConnectionStringTest(expArgs, table); + + var rows = new DailyLogRepository(cs).Select(new DailyLogFilter { AthleteId = 1 }).ToList(); + + var d = Assert.Single(rows); + Assert.Equal(new DateOnly(2026, 10, 17), d.Date); + Assert.Null(d.WaistCm); + } + + [Fact] + public void Select_2Граничный_Пустая() + { + var expArgs = new ConnectionString().PrepareExecute("src.daily_log_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_date_from", DBNull.Value) + .AddParameter("p_date_to", DBNull.Value) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.DailyLogCols)); + + Assert.Empty(new DailyLogRepository(cs).Select(new DailyLogFilter { AthleteId = 1 })); + } +} + +public class WorkoutMetricsRepositoryTests +{ + private static readonly Guid W1 = Guid.Parse("aaaa0000-0000-0000-0000-000000000001"); + + [Fact] + public void Upsert_1Основной_8Параметров() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_metrics_upsert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_workout_id", W1) + .AddParameter("p_adherence", 92.5m) + .AddParameter("p_tonnage", 4200.0m) + .AddParameter("p_tonnage_by_group", "{\"push\":4200}") + .AddParameter("p_e1pm_by_exercise", "{}") + .AddParameter("p_hr_metrics", "{}") + .AddParameter("p_red_flags", "[]") + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 9); + + var result = new WorkoutMetricsRepository(cs).Upsert(1, new WorkoutMetrics + { + WorkoutId = W1, Adherence = 92.5m, Tonnage = 4200.0m, + TonnageByGroup = "{\"push\":4200}", E1pmByExercise = "{}", + HrMetrics = "{}", RedFlags = "[]", + }); + + Assert.Equal(9, result); + } + + [Fact] + public void Select_1Основной_StartedAt() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_metrics_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_workout_id", DBNull.Value) + .AddParameter("p_date_from", DBNull.Value) + .AddParameter("p_date_to", DBNull.Value) + .BuildParams(); + var table = TestInfra.Table(TestInfra.MetricsCols, + [9, W1, 1, 92.5m, 4200.0m, "{}", "{}", "{}", "[]", + new DateTime(2026, 10, 17, 20, 0, 0), new DateTime(2026, 10, 17, 18, 0, 0)]); + var cs = new ConnectionStringTest(expArgs, table); + + var rows = new WorkoutMetricsRepository(cs).Select(new WorkoutMetricsFilter { AthleteId = 1 }).ToList(); + + var m = Assert.Single(rows); + Assert.Equal(92.5m, m.Adherence); + } + + [Fact] + public void Select_2Граничный_Пустая() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_metrics_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_workout_id", DBNull.Value) + .AddParameter("p_date_from", DBNull.Value) + .AddParameter("p_date_to", DBNull.Value) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.MetricsCols)); + + Assert.Empty(new WorkoutMetricsRepository(cs).Select(new WorkoutMetricsFilter { AthleteId = 1 })); + } +} + +public class BreakRepositoryTests +{ + private static readonly Guid B1 = Guid.Parse("eeee0000-0000-0000-0000-000000000001"); + private static readonly Guid B2 = Guid.Parse("eeee0000-0000-0000-0000-000000000002"); + + [Fact] + public void Insert_1Основной_KindNull_КакDbNull() + { + var expArgs = new ConnectionString().PrepareExecute("src.break_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", B2) + .AddParameter("p_kind", DBNull.Value) + .AddParameter("p_source", "auto") + .AddParameter("p_started_on", new DateOnly(2026, 10, 1)) + .AddParameter("p_planned_return", DBNull.Value) + .AddParameter("p_note", "") + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, B2); + + var result = new BreakRepository(cs).Insert(1, new Break + { + Id = B2, Kind = null, Source = "auto", + StartedOn = new DateOnly(2026, 10, 1), PlannedReturn = null, Note = "", + }); + + Assert.Equal(B2, result); + } + + [Fact] + public void Update_1Основной_6Параметров() + { + var expArgs = new ConnectionString().PrepareExecute("src.break_update") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", B1) + .AddParameter("p_kind", "vacation") + .AddParameter("p_note", "TEST-отпуск") + .AddParameter("p_planned_return", new DateOnly(2026, 10, 27)) + .AddParameter("p_ended_on", DBNull.Value) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 1); + + var result = new BreakRepository(cs).Update(1, new Break + { + Id = B1, Kind = "vacation", Note = "TEST-отпуск", + PlannedReturn = new DateOnly(2026, 10, 27), EndedOn = null, + }); + + Assert.Equal(1, result); + } + + [Fact] + public void Select_1Основной_2Сущности_KindNull() + { + var expArgs = new ConnectionString().PrepareExecute("src.break_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_kind", DBNull.Value) + .AddParameter("p_date_from", DBNull.Value) + .AddParameter("p_date_to", DBNull.Value) + .BuildParams(); + var table = TestInfra.Table(TestInfra.BreakCols, + [B1, 1, "vacation", "manual", new DateTime(2026, 10, 20), + new DateTime(2026, 10, 27), DBNull.Value, "TEST-отпуск"], + [B2, 1, DBNull.Value, "auto", new DateTime(2026, 10, 1), + DBNull.Value, DBNull.Value, ""]); + var cs = new ConnectionStringTest(expArgs, table); + + var rows = new BreakRepository(cs).Select(new BreakFilter { AthleteId = 1 }).ToList(); + + Assert.Equal(2, rows.Count); + Assert.Null(rows[1].Kind); + Assert.Equal(DateOnly.FromDateTime(new DateTime(2026, 10, 20)), rows[0].StartedOn); + } + + [Fact] + public void Select_2Граничный_Пустая() + { + var expArgs = new ConnectionString().PrepareExecute("src.break_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_kind", DBNull.Value) + .AddParameter("p_date_from", DBNull.Value) + .AddParameter("p_date_to", DBNull.Value) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.BreakCols)); + + Assert.Empty(new BreakRepository(cs).Select(new BreakFilter { AthleteId = 1 })); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core.Tests/ExerciseRepositoryTests.cs b/3-app/2-cli/Server.WatchBody.Core.Tests/ExerciseRepositoryTests.cs new file mode 100644 index 0000000..da34a79 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core.Tests/ExerciseRepositoryTests.cs @@ -0,0 +1,364 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Xunit; + +namespace Server.WatchBody.Core.Tests; + +// Тесты ExerciseRepository / ExerciseReplacementRepository / ProgramRepository / ProgramVersionRepository +// по repository_tests\ExerciseRepository.md и ProgramRepository.md. + +public class ExerciseRepositoryTests +{ + private static readonly Guid E1 = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid E2 = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + [Fact] + public void Insert_1Основной_ScalarGuid() + { + var expArgs = new ConnectionString().PrepareExecute("src.exercise_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", E1) + .AddParameter("p_name", "TEST-жим") + .AddParameter("p_group_id", 1) + .AddParameter("p_is_base", true) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, E1); + + var result = new ExerciseRepository(cs).Insert( + new Exercise { Id = E1, AthleteId = 1, Name = "TEST-жим", GroupId = 1, IsBase = true }); + + Assert.Equal(E1, result); + } + + [Fact] + public void Update_1Основной_5Параметров() + { + var expArgs = new ConnectionString().PrepareExecute("src.exercise_update") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", E1) + .AddParameter("p_name", "TEST-жим 45°") + .AddParameter("p_group_id", 1) + .AddParameter("p_is_base", false) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 1); + + var result = new ExerciseRepository(cs).Update( + new Exercise { Id = E1, AthleteId = 1, Name = "TEST-жим 45°", GroupId = 1, IsBase = false }); + + Assert.Equal(1, result); + } + + [Fact] + public void Delete_1Основной() + { + var expArgs = new ConnectionString().PrepareExecute("src.exercise_delete") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", E1) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 1); + + Assert.Equal(1, new ExerciseRepository(cs).Delete(1, E1)); + } + + [Fact] + public void Select_1Основной_2Сущности_ЗаменыУe2() + { + var expArgs = new ConnectionString().PrepareExecute("src.exercise_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_name", DBNull.Value) + .AddParameter("p_group_id", DBNull.Value) + .AddParameter("p_is_base", DBNull.Value) + .AddParameter("p_is_active", true) + .BuildParams(); + var table = TestInfra.Table(TestInfra.ExerciseCols, + [E1, 1, "TEST-aжим", 1, true, true, "[]"], + [E2, 1, "TEST-bжим", 2, true, true, $"[\"{E1}\"]"]); + var cs = new ConnectionStringTest(expArgs, table); + + var rows = new ExerciseRepository(cs).Select( + new ExerciseFilter { AthleteId = 1, IsActive = true }).ToList(); + + Assert.Equal(2, rows.Count); + var e2 = rows.Single(x => x.Id == E2); + Assert.Equal([E1], e2.ReplacementIds); + } + + [Fact] + public void Select_2Граничный_Пустая() + { + var expArgs = new ConnectionString().PrepareExecute("src.exercise_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_name", DBNull.Value) + .AddParameter("p_group_id", DBNull.Value) + .AddParameter("p_is_base", DBNull.Value) + .AddParameter("p_is_active", DBNull.Value) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.ExerciseCols)); + + Assert.Empty(new ExerciseRepository(cs).Select(new ExerciseFilter { AthleteId = 1 })); + } + + [Fact] + public void Get_1Основной_Сущность_Замены() + { + var expArgs = new ConnectionString().PrepareExecute("src.exercise_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", E2) + .BuildParams(); + var table = TestInfra.Table(TestInfra.ExerciseCols, + [E2, 1, "TEST-bжим", 2, true, true, $"[\"{E1}\"]"]); + var cs = new ConnectionStringTest(expArgs, table); + + var e = new ExerciseRepository(cs).Get(1, E2); + + Assert.NotNull(e); + Assert.Equal([E1], e.ReplacementIds); + } + + [Fact] + public void Get_2Граничный_Null() + { + var expArgs = new ConnectionString().PrepareExecute("src.exercise_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", E2) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.ExerciseCols)); + + Assert.Null(new ExerciseRepository(cs).Get(1, E2)); + } + + [Fact] + public void Replacement_Insert_1Основной() + { + var expArgs = new ConnectionString().PrepareExecute("src.exercise_replacement_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_exercise_id", E1) + .AddParameter("p_replacement_id", E2) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 1); + + Assert.Equal(1, new ExerciseReplacementRepository(cs).Insert(1, E1, E2)); + } + + [Fact] + public void Replacement_Delete_1Основной() + { + var expArgs = new ConnectionString().PrepareExecute("src.exercise_replacement_delete") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_exercise_id", E1) + .AddParameter("p_replacement_id", E2) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 1); + + Assert.Equal(1, new ExerciseReplacementRepository(cs).Delete(1, E1, E2)); + } +} + +public class ProgramRepositoryTests +{ + [Fact] + public void Insert_1Основной() + { + var expArgs = new ConnectionString().PrepareExecute("src.program_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_title", "TEST-Программа") + .AddParameterNullable("p_external_id", null) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 5); + + Assert.Equal(5, new ProgramRepository(cs).Insert( + new Program { AthleteId = 1, Title = "TEST-Программа" })); + } + + [Fact] + public void Insert_2СExternalId() + { + var expArgs = new ConnectionString().PrepareExecute("src.program_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_title", "TEST-Пакет") + .AddParameterNullable("p_external_id", "pkg_1") + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 7); + + Assert.Equal(7, new ProgramRepository(cs).Insert( + new Program { AthleteId = 1, Title = "TEST-Пакет", ExternalId = "pkg_1" })); + } + + [Fact] + public void Select_1Основной_АктивнаяВерсияДенормализация() + { + var activeId = Guid.Parse("99999999-9999-9999-9999-999999999999"); + var expArgs = new ConnectionString().PrepareExecute("src.program_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .BuildParams(); + var table = TestInfra.Table(TestInfra.ProgramCols, + [1, 1, "TEST-Программа", new DateTime(2026, 10, 17), DBNull.Value, DBNull.Value], + [2, 1, "TEST-Программа-2", new DateTime(2026, 10, 17), activeId, 2]); + var cs = new ConnectionStringTest(expArgs, table); + + var rows = new ProgramRepository(cs).Select(1).ToList(); + + Assert.Equal(2, rows.Count); + Assert.Null(rows[0].ActiveVersionNumber); + Assert.Equal(2, rows[1].ActiveVersionNumber); + Assert.Equal(activeId, rows[1].ActiveVersionId); + } + + [Fact] + public void Get_1Основной_Сущность() + { + var activeId = Guid.Parse("99999999-9999-9999-9999-999999999999"); + var expArgs = new ConnectionString().PrepareExecute("src.program_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_program_id", 2) + .BuildParams(); + var table = TestInfra.Table(TestInfra.ProgramCols, + [2, 1, "TEST-Программа-2", new DateTime(2026, 10, 17), activeId, 2]); + var cs = new ConnectionStringTest(expArgs, table); + + var p = new ProgramRepository(cs).Get(1, 2); + + Assert.NotNull(p); + Assert.Equal(activeId, p.ActiveVersionId); + } + + [Fact] + public void Get_2Граничный_Null() + { + var expArgs = new ConnectionString().PrepareExecute("src.program_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_program_id", 999) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.ProgramCols)); + + Assert.Null(new ProgramRepository(cs).Get(1, 999)); + } +} + +public class ProgramVersionRepositoryTests +{ + private static readonly Guid V1 = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid V2 = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + [Fact] + public void Insert_1Основной_7Параметров() + { + var expArgs = new ConnectionString().PrepareExecute("src.program_version_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", V1) + .AddParameter("p_program_id", 1) + .AddParameter("p_based_on_version", DBNull.Value) + .AddParameter("p_author", "user") + .AddParameter("p_body", "{}") + .AddParameter("p_rationale", "TEST-первая") + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, V1); + + var result = new ProgramVersionRepository(cs).Insert(1, new ProgramVersion + { + Id = V1, ProgramId = 1, BasedOnVersion = null, + Author = "user", Body = "{}", Rationale = "TEST-первая", + }); + + Assert.Equal(V1, result); + } + + [Fact] + public void UpdateStatus_1Основной() + { + var expArgs = new ConnectionString().PrepareExecute("src.program_version_update") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", V1) + .AddParameter("p_status", "active") + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 1); + + Assert.Equal(1, new ProgramVersionRepository(cs).UpdateStatus(1, V1, "active")); + } + + [Fact] + public void Select_1Основной_БезBody() + { + var expArgs = new ConnectionString().PrepareExecute("src.program_version_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_program_id", DBNull.Value) + .AddParameter("p_status", DBNull.Value) + .AddParameter("p_author", DBNull.Value) + .BuildParams(); + var table = TestInfra.Table(TestInfra.VersionCols, + [V1, 1, 1, DBNull.Value, "draft", "user", "TEST", new DateTime(2026, 10, 17)], + [V2, 1, 2, 1, "active", "user", "", new DateTime(2026, 10, 17)]); + var cs = new ConnectionStringTest(expArgs, table); + + var rows = new ProgramVersionRepository(cs).Select(new ProgramVersionFilter { AthleteId = 1 }).ToList(); + + Assert.Equal(2, rows.Count); + Assert.Equal(1, rows[0].Version); + Assert.Null(rows[0].BasedOnVersion); + } + + [Fact] + public void Select_2Граничный_Пустая() + { + var expArgs = new ConnectionString().PrepareExecute("src.program_version_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_program_id", DBNull.Value) + .AddParameter("p_status", DBNull.Value) + .AddParameter("p_author", DBNull.Value) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.VersionCols)); + + Assert.Empty(new ProgramVersionRepository(cs).Select(new ProgramVersionFilter { AthleteId = 1 })); + } + + [Fact] + public void Get_1Основной_СBody() + { + var expArgs = new ConnectionString().PrepareExecute("src.program_version_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", V2) + .BuildParams(); + var table = TestInfra.Table( + TestInfra.VersionCols.Concat([("body", typeof(string))]).ToArray(), + [V2, 1, 2, 1, "active", "user", "", new DateTime(2026, 10, 17), "{}"]); + var cs = new ConnectionStringTest(expArgs, table); + + var v = new ProgramVersionRepository(cs).Get(1, V2); + + Assert.NotNull(v); + Assert.Equal("{}", v.Body); + } + + [Fact] + public void Get_2Граничный_Null() + { + var expArgs = new ConnectionString().PrepareExecute("src.program_version_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", V2) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.VersionCols)); + + Assert.Null(new ProgramVersionRepository(cs).Get(1, V2)); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core.Tests/Server.WatchBody.Core.Tests.csproj b/3-app/2-cli/Server.WatchBody.Core.Tests/Server.WatchBody.Core.Tests.csproj new file mode 100644 index 0000000..84fbfa6 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core.Tests/Server.WatchBody.Core.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/3-app/2-cli/Server.WatchBody.Core.Tests/TestInfra.cs b/3-app/2-cli/Server.WatchBody.Core.Tests/TestInfra.cs new file mode 100644 index 0000000..68a336a --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core.Tests/TestInfra.cs @@ -0,0 +1,85 @@ +using System.Data; + +namespace Server.WatchBody.Core.Tests; + +/// Заготовки DataTable для фейк-транспорта (d-tst-4: один DataTable = один конвертер). +internal static class TestInfra +{ + public static DataTable Table((string Name, Type Type)[] columns, params object?[][] rows) + { + var table = new DataTable(); + foreach (var (name, type) in columns) + { + table.Columns.Add(name, type); + } + foreach (var row in rows) + { + table.Rows.Add(row); + } + return table; + } + + public static readonly (string, Type)[] AthleteCols = + [("id", typeof(int)), ("name", typeof(string)), ("api_key_hash", typeof(string)), + ("is_blocked", typeof(bool)), ("created_at", typeof(DateTime))]; + + public static readonly (string, Type)[] ProfileCols = + [("id", typeof(int)), ("athlete_id", typeof(int)), ("sex", typeof(string)), + ("birth_year", typeof(int)), ("weight_kg", typeof(decimal)), ("hr_max", typeof(int)), + ("updated_at", typeof(DateTime))]; + + public static readonly (string, Type)[] ExerciseCols = + [("id", typeof(Guid)), ("athlete_id", typeof(int)), ("name", typeof(string)), + ("group_id", typeof(int)), ("is_base", typeof(bool)), ("is_active", typeof(bool)), + ("replacement_ids", typeof(string))]; + + public static readonly (string, Type)[] ProgramCols = + [("id", typeof(int)), ("athlete_id", typeof(int)), ("title", typeof(string)), + ("created_at", typeof(DateTime)), ("active_version_id", typeof(Guid)), + ("active_version", typeof(int))]; + + public static readonly (string, Type)[] VersionCols = + [("id", typeof(Guid)), ("program_id", typeof(int)), ("version", typeof(int)), + ("based_on_version", typeof(int)), ("status", typeof(string)), ("author", typeof(string)), + ("rationale", typeof(string)), ("created_at", typeof(DateTime))]; + + public static readonly (string, Type)[] WorkoutCols = + [("id", typeof(Guid)), ("athlete_id", typeof(int)), ("program_version_id", typeof(Guid)), + ("started_at", typeof(DateTime)), ("ended_at", typeof(DateTime)), ("status", typeof(string)), + ("session_kind", typeof(string)), ("avg_hr", typeof(int)), ("facts_json", typeof(string)), + ("fit_file_id", typeof(string)), ("hr_recovery_s", typeof(int)), ("sets", typeof(string)), + ("commands", typeof(string))]; + + public static readonly (string, Type)[] SetCols = + [("id", typeof(Guid)), ("workout_id", typeof(Guid)), ("athlete_id", typeof(int)), + ("exercise_id", typeof(Guid)), ("seq", typeof(int)), ("weight_kg", typeof(decimal)), + ("reps", typeof(int)), ("done_at", typeof(DateTime)), ("source", typeof(string)), + ("in_plan", typeof(bool)), ("rpe", typeof(int)), ("knee_pain", typeof(int))]; + + public static readonly (string, Type)[] CommandCols = + [("id", typeof(Guid)), ("workout_id", typeof(Guid)), ("t_offset_s", typeof(int)), + ("recognized_text", typeof(string)), ("semantics", typeof(string)), ("accepted", typeof(bool))]; + + public static readonly (string, Type)[] DailyLogCols = + [("id", typeof(Guid)), ("athlete_id", typeof(int)), ("date", typeof(DateTime)), + ("bp_sys", typeof(int)), ("bp_dias", typeof(int)), ("weight_kg", typeof(decimal)), + ("hr_resting", typeof(int)), ("waist_cm", typeof(decimal)), ("sleep_h", typeof(decimal)), + ("wellbeing", typeof(int))]; + + public static readonly (string, Type)[] MetricsCols = + [("id", typeof(int)), ("workout_id", typeof(Guid)), ("athlete_id", typeof(int)), + ("adherence", typeof(decimal)), ("tonnage", typeof(decimal)), ("tonnage_by_group", typeof(string)), + ("e1pm_by_exercise", typeof(string)), ("hr_metrics", typeof(string)), ("red_flags", typeof(string)), + ("computed_at", typeof(DateTime)), ("started_at", typeof(DateTime))]; + + public static readonly (string, Type)[] BreakCols = + [("id", typeof(Guid)), ("athlete_id", typeof(int)), ("kind", typeof(string)), + ("source", typeof(string)), ("started_on", typeof(DateTime)), ("planned_return", typeof(DateTime)), + ("ended_on", typeof(DateTime)), ("note", typeof(string))]; + + public static readonly (string, Type)[] MigrationCols = + [("id", typeof(int)), ("filename", typeof(string)), ("applied_at", typeof(DateTime))]; + + public static readonly (string, Type)[] GroupCols = + [("id", typeof(int)), ("code", typeof(string)), ("title", typeof(string))]; +} diff --git a/3-app/2-cli/Server.WatchBody.Core.Tests/WorkoutRepositoryTests.cs b/3-app/2-cli/Server.WatchBody.Core.Tests/WorkoutRepositoryTests.cs new file mode 100644 index 0000000..6a3ae41 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core.Tests/WorkoutRepositoryTests.cs @@ -0,0 +1,275 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Xunit; + +namespace Server.WatchBody.Core.Tests; + +// Тесты WorkoutRepository / WorkoutSetRepository / CommandLogRepository +// по repository_tests\WorkoutRepository.md и WorkoutSetRepository.md. + +public class WorkoutRepositoryTests +{ + private static readonly Guid W1 = Guid.Parse("aaaa0000-0000-0000-0000-000000000001"); + + [Fact] + public void Insert_1Основной_5Параметров_NullВерсия() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", W1) + .AddParameter("p_program_version_id", DBNull.Value) + .AddParameter("p_started_at", new DateTime(2026, 10, 17, 18, 0, 0)) + .AddParameter("p_session_kind", "normal") + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, W1); + + var result = new WorkoutRepository(cs).Insert(1, new Workout + { + Id = W1, + StartedAt = new DateTime(2026, 10, 17, 18, 0, 0), + SessionKind = "normal", + }); + + Assert.Equal(W1, result); + } + + [Fact] + public void Update_1Основной_9Параметров() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_update") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", W1) + .AddParameter("p_ended_at", new DateTime(2026, 10, 17, 19, 10, 0)) + .AddParameter("p_status", "finished") + .AddParameter("p_session_kind", "normal") + .AddParameter("p_avg_hr", 118) + .AddParameter("p_facts_json", "{\"schema\":\"workout-facts/1\"}") + .AddParameter("p_fit_file_id", "TEST-fit-1") + .AddParameter("p_hr_recovery_s", 240) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, 1); + + var result = new WorkoutRepository(cs).Update(1, new Workout + { + Id = W1, + EndedAt = new DateTime(2026, 10, 17, 19, 10, 0), + Status = "finished", + SessionKind = "normal", + AvgHr = 118, + FactsJson = "{\"schema\":\"workout-facts/1\"}", + FitFileId = "TEST-fit-1", + HrRecoveryS = 240, + }); + + Assert.Equal(1, result); + } + + [Fact] + public void Select_1Основной_Sets2ПоПорядку() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_status", DBNull.Value) + .AddParameter("p_date_from", DBNull.Value) + .AddParameter("p_date_to", DBNull.Value) + .BuildParams(); + var sets = """[{"id":"bbbb0000-0000-0000-0000-000000000001","workout_id":"aaaa0000-0000-0000-0000-000000000001","athleteId":1,"exercise_id":null,"seq":1,"weight_kg":50.0,"reps":15,"done_at":"2026-10-17T18:05:00","source":"voice","in_plan":true,"rpe":null,"knee_pain":null},{"id":"bbbb0000-0000-0000-0000-000000000002","workout_id":"aaaa0000-0000-0000-0000-000000000001","athleteId":1,"exercise_id":null,"seq":2,"weight_kg":52.5,"reps":15,"done_at":"2026-10-17T18:10:00","source":"voice","in_plan":true,"rpe":null,"knee_pain":null}]"""; + var table = TestInfra.Table(TestInfra.WorkoutCols, + [W1, 1, DBNull.Value, new DateTime(2026, 10, 17, 18, 0, 0), DBNull.Value, + "active", "normal", DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, sets, DBNull.Value]); + var cs = new ConnectionStringTest(expArgs, table); + + var rows = new WorkoutRepository(cs).Select(new WorkoutFilter { AthleteId = 1 }).ToList(); + + var w = Assert.Single(rows); + Assert.Equal(2, w.Sets.Count()); + Assert.Equal(1, w.Sets.First().Seq); + } + + [Fact] + public void Select_2Граничный_Пустая() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_status", DBNull.Value) + .AddParameter("p_date_from", DBNull.Value) + .AddParameter("p_date_to", DBNull.Value) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.WorkoutCols)); + + Assert.Empty(new WorkoutRepository(cs).Select(new WorkoutFilter { AthleteId = 1 })); + } + + [Fact] + public void Get_1Основной_SetsИCommands() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", W1) + .BuildParams(); + var sets = """[{"id":"bbbb0000-0000-0000-0000-000000000001","workout_id":"aaaa0000-0000-0000-0000-000000000001","athleteId":1,"exercise_id":null,"seq":1,"weight_kg":50.0,"reps":15,"done_at":"2026-10-17T18:05:00","source":"voice","in_plan":true,"rpe":null,"knee_pain":null}]"""; + var commands = """[{"id":"cccc0000-0000-0000-0000-000000000001","workout_id":"aaaa0000-0000-0000-0000-000000000001","t_offset_s":65,"recognized_text":"TEST","semantics":{"cmd":"weight"},"accepted":true}]"""; + var table = TestInfra.Table(TestInfra.WorkoutCols, + [W1, 1, DBNull.Value, new DateTime(2026, 10, 17, 18, 0, 0), DBNull.Value, + "active", "normal", DBNull.Value, DBNull.Value, DBNull.Value, DBNull.Value, sets, commands]); + var cs = new ConnectionStringTest(expArgs, table); + + var w = new WorkoutRepository(cs).Get(1, W1); + + Assert.NotNull(w); + Assert.Single(w.Sets); + Assert.Single(w.Commands); + Assert.Equal(65, w.Commands.First().TOffsetS); + } + + [Fact] + public void Get_2Граничный_Null() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", W1) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.WorkoutCols)); + + Assert.Null(new WorkoutRepository(cs).Get(1, W1)); + } +} + +public class WorkoutSetRepositoryTests +{ + private static readonly Guid S1 = Guid.Parse("bbbb0000-0000-0000-0000-000000000001"); + private static readonly Guid W1 = Guid.Parse("aaaa0000-0000-0000-0000-000000000001"); + + [Fact] + public void Insert_1Основной_12Параметров() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_set_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", S1) + .AddParameter("p_workout_id", W1) + .AddParameter("p_exercise_id", DBNull.Value) + .AddParameter("p_seq", 1) + .AddParameter("p_weight_kg", 50.0m) + .AddParameter("p_reps", 15) + .AddParameter("p_done_at", new DateTime(2026, 10, 17, 18, 5, 0)) + .AddParameter("p_source", "voice") + .AddParameter("p_in_plan", true) + .AddParameter("p_rpe", DBNull.Value) + .AddParameter("p_knee_pain", DBNull.Value) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, S1); + + var result = new WorkoutSetRepository(cs).Insert(new WorkoutSet + { + Id = S1, WorkoutId = W1, AthleteId = 1, ExerciseId = null, Seq = 1, + WeightKg = 50.0m, Reps = 15, DoneAt = new DateTime(2026, 10, 17, 18, 5, 0), + Source = "voice", InPlan = true, Rpe = null, KneePain = null, + }); + + Assert.Equal(S1, result); + } + + [Fact] + public void Get_1Основной_Сущность() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_set_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", S1) + .BuildParams(); + var table = TestInfra.Table(TestInfra.SetCols, + [S1, W1, 1, DBNull.Value, 1, 50.0m, 15, new DateTime(2026, 10, 17, 18, 5, 0), + "voice", true, DBNull.Value, DBNull.Value]); + var cs = new ConnectionStringTest(expArgs, table); + + var s = new WorkoutSetRepository(cs).Get(1, S1); + + Assert.NotNull(s); + Assert.Null(s.Rpe); + Assert.Equal(50.0m, s.WeightKg); + } + + [Fact] + public void Get_2Граничный_Null() + { + var expArgs = new ConnectionString().PrepareExecute("src.workout_set_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", S1) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.SetCols)); + + Assert.Null(new WorkoutSetRepository(cs).Get(1, S1)); + } +} + +public class CommandLogRepositoryTests +{ + private static readonly Guid C1 = Guid.Parse("cccc0000-0000-0000-0000-000000000001"); + private static readonly Guid W1 = Guid.Parse("aaaa0000-0000-0000-0000-000000000001"); + + [Fact] + public void Insert_1Основной_7Параметров() + { + var expArgs = new ConnectionString().PrepareExecute("src.command_log_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_id", C1) + .AddParameter("p_workout_id", W1) + .AddParameter("p_t_offset_s", 65) + .AddParameter("p_recognized_text", "TEST-вес пятьдесят") + .AddParameter("p_semantics", "{\"cmd\":\"weight\",\"weight\":50}") + .AddParameter("p_accepted", true) + .BuildParams(); + var cs = ConnectionStringTest.Scalar(expArgs, C1); + + var result = new CommandLogRepository(cs).Insert(1, new CommandLog + { + Id = C1, WorkoutId = W1, TOffsetS = 65, + RecognizedText = "TEST-вес пятьдесят", + Semantics = "{\"cmd\":\"weight\",\"weight\":50}", + Accepted = true, + }); + + Assert.Equal(C1, result); + } + + [Fact] + public void Select_1Основной_1Сущность() + { + var expArgs = new ConnectionString().PrepareExecute("src.command_log_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_workout_id", W1) + .BuildParams(); + var table = TestInfra.Table(TestInfra.CommandCols, + [C1, W1, 65, "TEST-вес пятьдесят", "{\"cmd\":\"weight\"}", true]); + var cs = new ConnectionStringTest(expArgs, table); + + var rows = new CommandLogRepository(cs).Select(1, W1).ToList(); + + var c = Assert.Single(rows); + Assert.True(c.Accepted); + } + + [Fact] + public void Select_2Граничный_Пустая() + { + var expArgs = new ConnectionString().PrepareExecute("src.command_log_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", 1) + .AddParameter("p_workout_id", W1) + .BuildParams(); + var cs = new ConnectionStringTest(expArgs, TestInfra.Table(TestInfra.CommandCols)); + + Assert.Empty(new CommandLogRepository(cs).Select(1, W1)); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Ref/Domain/ExerciseGroup.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Ref/Domain/ExerciseGroup.cs new file mode 100644 index 0000000..667ab82 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Ref/Domain/ExerciseGroup.cs @@ -0,0 +1,18 @@ +namespace Server.WatchBody.Core.Dal.Ref.Domain; + +/// Двигательная группа упражнений (ref.exercise_group, системный справочник). +public class ExerciseGroup +{ + public int Id { get; set; } + public string Code { get; set; } + public string Title { get; set; } +} + +public static class ExerciseGroups +{ + public const int Push = 1; + public const int Pull = 2; + public const int Legs = 3; + public const int Core = 4; + public const int Cardio = 5; +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Ref/Repository/ExerciseGroupRepository.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Ref/Repository/ExerciseGroupRepository.cs new file mode 100644 index 0000000..34da6fb --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Ref/Repository/ExerciseGroupRepository.cs @@ -0,0 +1,40 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Ref.Domain; + +namespace Server.WatchBody.Core.Dal.Ref.Repository; + +public interface IExerciseGroupRepository +{ + IEnumerable Select(); +} + +/// Репозиторий ref.exercise_group (системный справочник — только чтение, c-dal-2). +public class ExerciseGroupRepository : IExerciseGroupRepository +{ + private readonly IConnectionString _connectionString; + + public ExerciseGroupRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Все двигательные группы — ref.exercise_group_select. + public IEnumerable Select() + { + var result = new List(); + _connectionString + .PrepareExecute("ref.exercise_group_select") + .AsStoredProcedure() + .AddConverter((record, list) => + { + result.Add(new ExerciseGroup + { + Id = record.Convert(record.Src["id"]), + Code = record.Src["code"] as string ?? string.Empty, + Title = record.Src["title"] as string ?? string.Empty, + }); + }) + .ExecuteSelectMany(); + return result; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Athlete.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Athlete.cs new file mode 100644 index 0000000..fa7f77d --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Athlete.cs @@ -0,0 +1,11 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Атлет (src.athlete). +public class Athlete +{ + public int Id { get; set; } + public string Name { get; set; } + public string ApiKeyHash { get; set; } + public bool IsBlocked { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/AthleteProfile.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/AthleteProfile.cs new file mode 100644 index 0000000..cef3ced --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/AthleteProfile.cs @@ -0,0 +1,15 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Профиль атлета (src.athlete_profile, 1:1 к Athlete). +public class AthleteProfile +{ + public int Id { get; set; } + public int AthleteId { get; set; } + /// m / f; NULL — не указан. + public string? Sex { get; set; } + public int? BirthYear { get; set; } + public decimal? WeightKg { get; set; } + /// Ручной ввод; NULL — расчёт по Танаке. + public int? HrMax { get; set; } + public DateTime UpdatedAt { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Break.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Break.cs new file mode 100644 index 0000000..01ad1e1 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Break.cs @@ -0,0 +1,32 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Перерыв (src.breaks); закрытие — update (ended_on); delete нет. +public class Break +{ + public Guid Id { get; set; } + public int AthleteId { get; set; } + /// NULL — атлет ещё не ответил (правила 12–14 ждут ответа). + public string? Kind { get; set; } + public string Source { get; set; } + public DateOnly StartedOn { get; set; } + public DateOnly? PlannedReturn { get; set; } + public DateOnly? EndedOn { get; set; } + public string Note { get; set; } +} + +public static class BreakSources +{ + /// Сервер по пропускам ≥ 4 дней; от телефона не принимается. + public const string Auto = "auto"; + /// Запланированный простой, объявлен атлетом с телефона. + public const string Manual = "manual"; +} + +public static class BreakKinds +{ + public const string Illness = "illness"; + public const string Vacation = "vacation"; + public const string Workload = "workload"; + public const string Injury = "injury"; + public const string Other = "other"; +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/CommandLog.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/CommandLog.cs new file mode 100644 index 0000000..e88fe57 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/CommandLog.cs @@ -0,0 +1,14 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Журнал голосовых команд (src.command_log); не редактируется. +public class CommandLog +{ + public Guid Id { get; set; } + public Guid WorkoutId { get; set; } + /// Секунды от начала тренировки (сопоставление с .fit). + public int TOffsetS { get; set; } + public string RecognizedText { get; set; } + /// {"cmd","weight","reps","rest_sec","rpe"} (jsonb). + public string Semantics { get; set; } + public bool Accepted { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/DailyLog.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/DailyLog.cs new file mode 100644 index 0000000..2460f05 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/DailyLog.cs @@ -0,0 +1,18 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Дневник самоконтроля (src.daily_log); день уникален для атлета. +public class DailyLog +{ + public Guid Id { get; set; } + public int AthleteId { get; set; } + public DateOnly Date { get; set; } + public int? BpSys { get; set; } + public int? BpDias { get; set; } + public decimal? WeightKg { get; set; } + public int? HrResting { get; set; } + /// v2 (колонка есть, в v1 не заполняется). + public decimal? WaistCm { get; set; } + /// v2 (колонка есть, в v1 не заполняется). + public decimal? SleepH { get; set; } + public int? Wellbeing { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Exercise.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Exercise.cs new file mode 100644 index 0000000..8878020 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Exercise.cs @@ -0,0 +1,16 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Упражнение библиотеки атлета (src.exercise). +public class Exercise +{ + /// UUID генерирует телефон; неизменяем (Ф-4.3). + public Guid Id { get; set; } + public int AthleteId { get; set; } + public string Name { get; set; } + public int GroupId { get; set; } + public bool IsBase { get; set; } + public bool IsActive { get; set; } + + /// Пары замен (src.exercise_replacement); заполняет репозиторий. + public IEnumerable ReplacementIds { get; set; } = []; +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Migration.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Migration.cs new file mode 100644 index 0000000..6ba2e51 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Migration.cs @@ -0,0 +1,9 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Журнал применённых миграций (src.migration); пишет --mode migrate. +public class Migration +{ + public int Id { get; set; } + public string Filename { get; set; } + public DateTime AppliedAt { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Program.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Program.cs new file mode 100644 index 0000000..5b1772f --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Program.cs @@ -0,0 +1,16 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Программа атлета (src.program); жизненный цикл ведут версии. +public class Program +{ + public int Id { get; set; } + public int AthleteId { get; set; } + public string Title { get; set; } + /// Идентификатор пакета program-import.json (З-31/З-35); NULL — не из пакета. + public string? ExternalId { get; set; } + public DateTime CreatedAt { get; set; } + + /// Денормализация program_select: активная версия (null — версии нет). + public Guid? ActiveVersionId { get; set; } + public int? ActiveVersionNumber { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/ProgramVersion.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/ProgramVersion.cs new file mode 100644 index 0000000..6f07d3c --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/ProgramVersion.cs @@ -0,0 +1,31 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Неизменяемая версия программы (src.program_version); update — только статус. +public class ProgramVersion +{ + public Guid Id { get; set; } + public int ProgramId { get; set; } + public int Version { get; set; } + public int? BasedOnVersion { get; set; } + public string Status { get; set; } + public string Author { get; set; } + /// Недели → тренировки → упражнения → подходы-план (jsonb). + public string Body { get; set; } + public string Rationale { get; set; } + public DateTime CreatedAt { get; set; } +} + +public static class ProgramVersionStatuses +{ + public const string Draft = "draft"; + public const string Active = "active"; + public const string Archived = "archived"; +} + +public static class ProgramVersionAuthors +{ + public const string User = "user"; + public const string Admin = "admin"; + public const string Rules = "rules"; + public const string Llm = "llm"; +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Workout.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Workout.cs new file mode 100644 index 0000000..f3c63d7 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/Workout.cs @@ -0,0 +1,38 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Тренировка (src.workout); PK uuid — идемпотентность по client_guid. +public class Workout +{ + public Guid Id { get; set; } + public int AthleteId { get; set; } + public Guid? ProgramVersionId { get; set; } + public DateTime StartedAt { get; set; } + public DateTime? EndedAt { get; set; } + public string Status { get; set; } + public string SessionKind { get; set; } + public int? AvgHr { get; set; } + /// Канонический факт .fit (workout-facts/1); заполняет FitParser. + public string? FactsJson { get; set; } + public string? FitFileId { get; set; } + public int? HrRecoveryS { get; set; } + + /// Подходы (src.workout_set); заполняет репозиторий (workout_select/get). + public IEnumerable Sets { get; set; } = []; + /// Журнал голосовых команд; заполняет репозиторий (workout_get). + public IEnumerable Commands { get; set; } = []; +} + +public static class WorkoutStatuses +{ + public const string Active = "active"; + public const string Finished = "finished"; + public const string Cancelled = "cancelled"; +} + +public static class SessionKinds +{ + public const string Normal = "normal"; + public const string Return = "return"; + public const string Deload = "deload"; + public const string Test = "test"; +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/WorkoutMetrics.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/WorkoutMetrics.cs new file mode 100644 index 0000000..f51a923 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/WorkoutMetrics.cs @@ -0,0 +1,20 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Метрики тренировки (src.workout_metrics, 1:1 к Workout); считает MetricsEngine. +public class WorkoutMetrics +{ + public int Id { get; set; } + public Guid WorkoutId { get; set; } + public int AthleteId { get; set; } + public decimal Adherence { get; set; } + public decimal Tonnage { get; set; } + /// {"push": 4200, …} (jsonb). + public string TonnageByGroup { get; set; } + /// {"<exercise_id>": 82.5} — Эпли (jsonb). + public string E1pmByExercise { get; set; } + /// Средний/пиковый пульс подхода, отдых (jsonb). + public string HrMetrics { get; set; } + /// Сработавшие красные флаги — АД/пульс/колено (jsonb). + public string RedFlags { get; set; } + public DateTime ComputedAt { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/WorkoutSet.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/WorkoutSet.cs new file mode 100644 index 0000000..0075ee2 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Domain/WorkoutSet.cs @@ -0,0 +1,26 @@ +namespace Server.WatchBody.Core.Dal.Src.Domain; + +/// Подход (src.workout_set); без update/delete — правка = новая запись source=edit. +public class WorkoutSet +{ + public Guid Id { get; set; } + public Guid WorkoutId { get; set; } + /// Денормализация для изоляции выборок. + public int AthleteId { get; set; } + public Guid? ExerciseId { get; set; } + public int Seq { get; set; } + public decimal WeightKg { get; set; } + public int Reps { get; set; } + public DateTime DoneAt { get; set; } + public string Source { get; set; } + public bool InPlan { get; set; } + public int? Rpe { get; set; } + public int? KneePain { get; set; } +} + +public static class WorkoutSetSources +{ + public const string Voice = "voice"; + public const string Watch = "watch"; + public const string Edit = "edit"; +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/AthleteProfileRepository.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/AthleteProfileRepository.cs new file mode 100644 index 0000000..2af34ff --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/AthleteProfileRepository.cs @@ -0,0 +1,60 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; + +namespace Server.WatchBody.Core.Dal.Src.Repository; + +public interface IAthleteProfileRepository +{ + int Upsert(AthleteProfile profile); + AthleteProfile? Get(int athleteId); +} + +/// Репозиторий src.athlete_profile (1:1 к атлету). +public class AthleteProfileRepository : IAthleteProfileRepository +{ + private readonly IConnectionString _connectionString; + + public AthleteProfileRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Сохранение профиля (ON CONFLICT) — src.athlete_profile_upsert. + public int Upsert(AthleteProfile profile) + { + return _connectionString + .PrepareExecute("src.athlete_profile_upsert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", profile.AthleteId) + .AddParameterNullable("p_sex", profile.Sex) + .AddParameterNullable("p_birth_year", profile.BirthYear) + .AddParameterNullable("p_weight_kg", profile.WeightKg) + .AddParameterNullable("p_hr_max", profile.HrMax) + .ExecuteScalar(); + } + + /// Профиль атлета — src.athlete_profile_get. + public AthleteProfile? Get(int athleteId) + { + AthleteProfile? result = null; + _connectionString + .PrepareExecute("src.athlete_profile_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddConverter((record, list) => + { + result = new AthleteProfile + { + Id = record.Convert(record.Src["id"]), + AthleteId = record.Convert(record.Src["athlete_id"]), + Sex = record.IsDbNull("sex") ? null : record.Src["sex"] as string, + BirthYear = record.IsDbNull("birth_year") ? null : record.Convert(record.Src["birth_year"]), + WeightKg = record.IsDbNull("weight_kg") ? null : record.Convert(record.Src["weight_kg"]), + HrMax = record.IsDbNull("hr_max") ? null : record.Convert(record.Src["hr_max"]), + UpdatedAt = record.Convert(record.Src["updated_at"]), + }; + }) + .ExecuteSelectMany(); + return result; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/AthleteRepository.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/AthleteRepository.cs new file mode 100644 index 0000000..043c981 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/AthleteRepository.cs @@ -0,0 +1,95 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; + +namespace Server.WatchBody.Core.Dal.Src.Repository; + +public interface IAthleteRepository +{ + int Insert(Athlete athlete); + int Update(Athlete athlete); + IEnumerable Select(AthleteFilter? filter); + Athlete? Get(int id); +} + +/// Репозиторий src.athlete (каждый метод — одна хранимая функция, c-dal-1). +public class AthleteRepository : IAthleteRepository +{ + private readonly IConnectionString _connectionString; + + public AthleteRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Регистрация атлета — src.athlete_insert. + public int Insert(Athlete athlete) + { + return _connectionString + .PrepareExecute("src.athlete_insert") + .AsStoredProcedure() + .AddParameter("p_name", athlete.Name) + .AddParameter("p_api_key_hash", athlete.ApiKeyHash) + .ExecuteScalar(); + } + + /// Правка атлета (все поля, db-m-8) — src.athlete_update. + public int Update(Athlete athlete) + { + return _connectionString + .PrepareExecute("src.athlete_update") + .AsStoredProcedure() + .AddParameter("p_id", athlete.Id) + .AddParameter("p_name", athlete.Name) + .AddParameter("p_api_key_hash", athlete.ApiKeyHash) + .AddParameter("p_is_blocked", athlete.IsBlocked) + .ExecuteScalar(); + } + + /// Выбор атлетов с фильтрами — src.athlete_select. + public IEnumerable Select(AthleteFilter? filter) + { + filter ??= new AthleteFilter(); + var result = new List(); + _connectionString + .PrepareExecute("src.athlete_select") + .AsStoredProcedure() + .AddParameterNullable("p_api_key_hash", filter.ApiKeyHash) + .AddParameterNullable("p_is_blocked", filter.IsBlocked) + .AddConverter((record, list) => + { + result.Add(new Athlete + { + Id = record.Convert(record.Src["id"]), + Name = record.Src["name"] as string ?? string.Empty, + ApiKeyHash = record.Src["api_key_hash"] as string ?? string.Empty, + IsBlocked = record.Convert(record.Src["is_blocked"]), + CreatedAt = record.Convert(record.Src["created_at"]), + }); + }) + .ExecuteSelectMany(); + return result; + } + + /// Атлет по id — src.athlete_get. + public Athlete? Get(int id) + { + Athlete? result = null; + _connectionString + .PrepareExecute("src.athlete_get") + .AsStoredProcedure() + .AddParameter("p_id", id) + .AddConverter((record, list) => + { + result = new Athlete + { + Id = record.Convert(record.Src["id"]), + Name = record.Src["name"] as string ?? string.Empty, + ApiKeyHash = record.Src["api_key_hash"] as string ?? string.Empty, + IsBlocked = record.Convert(record.Src["is_blocked"]), + CreatedAt = record.Convert(record.Src["created_at"]), + }; + }) + .ExecuteSelectMany(); + return result; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/DailyLogRepository.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/DailyLogRepository.cs new file mode 100644 index 0000000..f2c7c30 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/DailyLogRepository.cs @@ -0,0 +1,224 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; + +namespace Server.WatchBody.Core.Dal.Src.Repository; + +public interface IDailyLogRepository +{ + Guid Upsert(int athleteId, DailyLog log); + IEnumerable Select(DailyLogFilter? filter); +} + +/// Репозиторий src.daily_log (день уникален для атлета). +public class DailyLogRepository : IDailyLogRepository +{ + /// errata m04: Npgsql возвращает date как DateOnly (не DateTime). + internal static DateOnly AsDate(object value) => value switch + { + DateOnly d => d, + DateTime dt => DateOnly.FromDateTime(dt), + _ => DateOnly.FromDateTime(Convert.ToDateTime(value)), + }; + + private readonly IConnectionString _connectionString; + + public DailyLogRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Сохранение дня — src.daily_log_upsert. + public Guid Upsert(int athleteId, DailyLog log) + { + return _connectionString + .PrepareExecute("src.daily_log_upsert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", log.Id) + .AddParameter("p_date", log.Date) + .AddParameterNullable("p_bp_sys", log.BpSys) + .AddParameterNullable("p_bp_dias", log.BpDias) + .AddParameterNullable("p_weight_kg", log.WeightKg) + .AddParameterNullable("p_hr_resting", log.HrResting) + .AddParameterNullable("p_wellbeing", log.Wellbeing) + .ExecuteScalar(); + } + + /// Дневник за период — src.daily_log_select. + public IEnumerable Select(DailyLogFilter? filter) + { + filter ??= new DailyLogFilter(); + var result = new List(); + _connectionString + .PrepareExecute("src.daily_log_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", filter.AthleteId) + .AddParameterNullable("p_date_from", filter.DateFrom) + .AddParameterNullable("p_date_to", filter.DateTo) + .AddConverter((record, list) => + { + result.Add(new DailyLog + { + Id = record.Convert(record.Src["id"]), + AthleteId = record.Convert(record.Src["athlete_id"]), + Date = AsDate(record.Src["date"]), + BpSys = record.IsDbNull("bp_sys") ? null : record.Convert(record.Src["bp_sys"]), + BpDias = record.IsDbNull("bp_dias") ? null : record.Convert(record.Src["bp_dias"]), + WeightKg = record.IsDbNull("weight_kg") ? null : record.Convert(record.Src["weight_kg"]), + HrResting = record.IsDbNull("hr_resting") ? null : record.Convert(record.Src["hr_resting"]), + WaistCm = record.IsDbNull("waist_cm") ? null : record.Convert(record.Src["waist_cm"]), + SleepH = record.IsDbNull("sleep_h") ? null : record.Convert(record.Src["sleep_h"]), + Wellbeing = record.IsDbNull("wellbeing") ? null : record.Convert(record.Src["wellbeing"]), + }); + }) + .ExecuteSelectMany(); + return result; + } +} + +public interface IWorkoutMetricsRepository +{ + int Upsert(int athleteId, WorkoutMetrics metrics); + IEnumerable Select(WorkoutMetricsFilter? filter); +} + +/// Репозиторий src.workout_metrics (1:1 к тренировке; пишет MetricsEngine). +public class WorkoutMetricsRepository : IWorkoutMetricsRepository +{ + private readonly IConnectionString _connectionString; + + public WorkoutMetricsRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Сохранение/пересчёт метрик — src.workout_metrics_upsert. + public int Upsert(int athleteId, WorkoutMetrics metrics) + { + return _connectionString + .PrepareExecute("src.workout_metrics_upsert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_workout_id", metrics.WorkoutId) + .AddParameter("p_adherence", metrics.Adherence) + .AddParameter("p_tonnage", metrics.Tonnage) + .AddParameter("p_tonnage_by_group", metrics.TonnageByGroup) + .AddParameter("p_e1pm_by_exercise", metrics.E1pmByExercise) + .AddParameter("p_hr_metrics", metrics.HrMetrics) + .AddParameter("p_red_flags", metrics.RedFlags) + .ExecuteScalar(); + } + + /// Метрики за период (графики) — src.workout_metrics_select. + public IEnumerable Select(WorkoutMetricsFilter? filter) + { + filter ??= new WorkoutMetricsFilter(); + var result = new List(); + _connectionString + .PrepareExecute("src.workout_metrics_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", filter.AthleteId) + .AddParameterNullable("p_workout_id", filter.WorkoutId) + .AddParameterNullable("p_date_from", filter.DateFrom) + .AddParameterNullable("p_date_to", filter.DateTo) + .AddConverter((record, list) => + { + result.Add(new WorkoutMetrics + { + Id = record.Convert(record.Src["id"]), + WorkoutId = record.Convert(record.Src["workout_id"]), + AthleteId = record.Convert(record.Src["athlete_id"]), + Adherence = record.Convert(record.Src["adherence"]), + Tonnage = record.Convert(record.Src["tonnage"]), + TonnageByGroup = record.Src["tonnage_by_group"].ToString() ?? string.Empty, + E1pmByExercise = record.Src["e1pm_by_exercise"].ToString() ?? string.Empty, + HrMetrics = record.Src["hr_metrics"].ToString() ?? string.Empty, + RedFlags = record.Src["red_flags"].ToString() ?? string.Empty, + ComputedAt = record.Convert(record.Src["computed_at"]), + }); + }) + .ExecuteSelectMany(); + return result; + } +} + +public interface IBreakRepository +{ + Guid Insert(int athleteId, Break breakItem); + int Update(int athleteId, Break breakItem); + IEnumerable Select(BreakFilter? filter); +} + +/// Репозиторий src.breaks (журнал перерывов; delete нет, 🛡). +public class BreakRepository : IBreakRepository +{ + private readonly IConnectionString _connectionString; + + public BreakRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Открытие перерыва — src.break_insert. + public Guid Insert(int athleteId, Break breakItem) + { + return _connectionString + .PrepareExecute("src.break_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", breakItem.Id) + .AddParameterNullable("p_kind", breakItem.Kind) + .AddParameter("p_source", breakItem.Source) + .AddParameter("p_started_on", breakItem.StartedOn) + .AddParameterNullable("p_planned_return", breakItem.PlannedReturn) + .AddParameter("p_note", breakItem.Note) + .ExecuteScalar(); + } + + /// Ответ атлета/закрытие (все поля, db-m-8) — src.break_update. + public int Update(int athleteId, Break breakItem) + { + return _connectionString + .PrepareExecute("src.break_update") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", breakItem.Id) + .AddParameterNullable("p_kind", breakItem.Kind) + .AddParameter("p_note", breakItem.Note) + .AddParameterNullable("p_planned_return", breakItem.PlannedReturn) + .AddParameterNullable("p_ended_on", breakItem.EndedOn) + .ExecuteScalar(); + } + + /// Журнал перерывов — src.break_select. + public IEnumerable Select(BreakFilter? filter) + { + filter ??= new BreakFilter(); + var result = new List(); + _connectionString + .PrepareExecute("src.break_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", filter.AthleteId) + .AddParameterNullable("p_kind", filter.Kind) + .AddParameterNullable("p_date_from", filter.DateFrom) + .AddParameterNullable("p_date_to", filter.DateTo) + .AddConverter((record, list) => + { + result.Add(new Break + { + Id = record.Convert(record.Src["id"]), + AthleteId = record.Convert(record.Src["athlete_id"]), + Kind = record.IsDbNull("kind") ? null : record.Src["kind"] as string, + Source = record.Src["source"] as string ?? string.Empty, + StartedOn = DailyLogRepository.AsDate(record.Src["started_on"]), + PlannedReturn = record.IsDbNull("planned_return") + ? null : DailyLogRepository.AsDate(record.Src["planned_return"]), + EndedOn = record.IsDbNull("ended_on") + ? null : DailyLogRepository.AsDate(record.Src["ended_on"]), + Note = record.Src["note"] as string ?? string.Empty, + }); + }) + .ExecuteSelectMany(); + return result; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/ExerciseRepository.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/ExerciseRepository.cs new file mode 100644 index 0000000..7ce425d --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/ExerciseRepository.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; + +namespace Server.WatchBody.Core.Dal.Src.Repository; + +public interface IExerciseRepository +{ + Guid Insert(Exercise exercise); + int Update(Exercise exercise); + int Delete(int athleteId, Guid exerciseId); + IEnumerable Select(ExerciseFilter? filter); + Exercise? Get(int athleteId, Guid exerciseId); +} + +/// Репозиторий src.exercise (+ денормализация пар замен). +public class ExerciseRepository : IExerciseRepository +{ + private readonly IConnectionString _connectionString; + + public ExerciseRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Добавление упражнения — src.exercise_insert. + public Guid Insert(Exercise exercise) + { + return _connectionString + .PrepareExecute("src.exercise_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", exercise.AthleteId) + .AddParameter("p_id", exercise.Id) + .AddParameter("p_name", exercise.Name) + .AddParameter("p_group_id", exercise.GroupId) + .AddParameter("p_is_base", exercise.IsBase) + .ExecuteScalar(); + } + + /// Правка упражнения (id не меняется, 🛡) — src.exercise_update. + public int Update(Exercise exercise) + { + return _connectionString + .PrepareExecute("src.exercise_update") + .AsStoredProcedure() + .AddParameter("p_athlete_id", exercise.AthleteId) + .AddParameter("p_id", exercise.Id) + .AddParameter("p_name", exercise.Name) + .AddParameter("p_group_id", exercise.GroupId) + .AddParameter("p_is_base", exercise.IsBase) + .ExecuteScalar(); + } + + /// Логическое удаление — src.exercise_delete. + public int Delete(int athleteId, Guid exerciseId) + { + return _connectionString + .PrepareExecute("src.exercise_delete") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", exerciseId) + .ExecuteScalar(); + } + + /// Выбор упражнений с парами замен — src.exercise_select. + public IEnumerable Select(ExerciseFilter? filter) + { + filter ??= new ExerciseFilter(); + var result = new List(); + _connectionString + .PrepareExecute("src.exercise_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", filter.AthleteId) + .AddParameterNullable("p_name", filter.Name) + .AddParameterNullable("p_group_id", filter.GroupId) + .AddParameterNullable("p_is_base", filter.IsBase) + .AddParameterNullable("p_is_active", filter.IsActive) + .AddConverter((record, list) => + result.Add(ConvertExercise(record))) + .ExecuteSelectMany(); + return result; + } + + /// Упражнение с заменами — src.exercise_get. + public Exercise? Get(int athleteId, Guid exerciseId) + { + Exercise? result = null; + _connectionString + .PrepareExecute("src.exercise_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", exerciseId) + .AddConverter((record, list) => + result = ConvertExercise(record)) + .ExecuteSelectMany(); + return result; + } + + private static Exercise ConvertExercise(IDataRecordSpecified record) + { + return new Exercise + { + Id = record.Convert(record.Src["id"]), + AthleteId = record.Convert(record.Src["athlete_id"]), + Name = record.Src["name"] as string ?? string.Empty, + GroupId = record.Convert(record.Src["group_id"]), + IsBase = record.Convert(record.Src["is_base"]), + IsActive = record.Convert(record.Src["is_active"]), + ReplacementIds = record.IsDbNull("replacement_ids") + ? [] + : JsonSerializer.Deserialize>(record.Src["replacement_ids"].ToString()!) ?? [], + }; + } +} + +public interface IExerciseReplacementRepository +{ + int Insert(int athleteId, Guid exerciseId, Guid replacementId); + int Delete(int athleteId, Guid exerciseId, Guid replacementId); +} + +/// Репозиторий src.exercise_replacement (связь ММ). +public class ExerciseReplacementRepository : IExerciseReplacementRepository +{ + private readonly IConnectionString _connectionString; + + public ExerciseReplacementRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Добавление пары-замены — src.exercise_replacement_insert. + public int Insert(int athleteId, Guid exerciseId, Guid replacementId) + { + return _connectionString + .PrepareExecute("src.exercise_replacement_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_exercise_id", exerciseId) + .AddParameter("p_replacement_id", replacementId) + .ExecuteScalar(); + } + + /// Удаление пары-замены — src.exercise_replacement_delete. + public int Delete(int athleteId, Guid exerciseId, Guid replacementId) + { + return _connectionString + .PrepareExecute("src.exercise_replacement_delete") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_exercise_id", exerciseId) + .AddParameter("p_replacement_id", replacementId) + .ExecuteScalar(); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/AthleteFilter.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/AthleteFilter.cs new file mode 100644 index 0000000..28b4b1c --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/AthleteFilter.cs @@ -0,0 +1,8 @@ +namespace Server.WatchBody.Core.Dal.Src.Repository; + +/// Фильтры src.athlete_select; NULL — фильтр не применяется. +public class AthleteFilter +{ + public string? ApiKeyHash { get; set; } + public bool? IsBlocked { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/ExerciseFilter.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/ExerciseFilter.cs new file mode 100644 index 0000000..33939e8 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/ExerciseFilter.cs @@ -0,0 +1,11 @@ +namespace Server.WatchBody.Core.Dal.Src.Repository; + +/// Фильтры src.exercise_select; NULL — фильтр не применяется. +public class ExerciseFilter +{ + public int AthleteId { get; set; } + public string? Name { get; set; } + public int? GroupId { get; set; } + public bool? IsBase { get; set; } + public bool? IsActive { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/ProgramVersionFilter.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/ProgramVersionFilter.cs new file mode 100644 index 0000000..331ef0d --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/ProgramVersionFilter.cs @@ -0,0 +1,10 @@ +namespace Server.WatchBody.Core.Dal.Src.Repository; + +/// Фильтры src.program_version_select; NULL — фильтр не применяется. +public class ProgramVersionFilter +{ + public int AthleteId { get; set; } + public int? ProgramId { get; set; } + public string? Status { get; set; } + public string? Author { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/WorkoutFilters.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/WorkoutFilters.cs new file mode 100644 index 0000000..d6fe46c --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/Filter/WorkoutFilters.cs @@ -0,0 +1,36 @@ +namespace Server.WatchBody.Core.Dal.Src.Repository; + +/// Фильтры src.workout_select; NULL — фильтр не применяется. +public class WorkoutFilter +{ + public int AthleteId { get; set; } + public string? Status { get; set; } + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } +} + +/// Фильтры src.daily_log_select; NULL — фильтр не применяется. +public class DailyLogFilter +{ + public int AthleteId { get; set; } + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } +} + +/// Фильтры src.workout_metrics_select; NULL — фильтр не применяется. +public class WorkoutMetricsFilter +{ + public int AthleteId { get; set; } + public Guid? WorkoutId { get; set; } + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } +} + +/// Фильтры src.break_select; NULL — фильтр не применяется. +public class BreakFilter +{ + public int AthleteId { get; set; } + public string? Kind { get; set; } + public DateOnly? DateFrom { get; set; } + public DateOnly? DateTo { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/MigrationRepository.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/MigrationRepository.cs new file mode 100644 index 0000000..c59606b --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/MigrationRepository.cs @@ -0,0 +1,52 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; + +namespace Server.WatchBody.Core.Dal.Src.Repository; + +public interface IMigrationRepository +{ + int Insert(string filename); + Migration? Select(string filename); +} + +/// Репозиторий src.migration (журнал миграций режима --mode migrate). +public class MigrationRepository : IMigrationRepository +{ + private readonly IConnectionString _connectionString; + + public MigrationRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Фиксация применённой миграции — src.migration_insert. + public int Insert(string filename) + { + return _connectionString + .PrepareExecute("src.migration_insert") + .AsStoredProcedure() + .AddParameter("p_filename", filename) + .ExecuteScalar(); + } + + /// Проверка «применена ли» — src.migration_select. + public Migration? Select(string filename) + { + Migration? result = null; + _connectionString + .PrepareExecute("src.migration_select") + .AsStoredProcedure() + .AddParameter("p_filename", filename) + .AddConverter((record, list) => + { + result = new Migration + { + Id = record.Convert(record.Src["id"]), + Filename = record.Src["filename"] as string ?? string.Empty, + AppliedAt = record.Convert(record.Src["applied_at"]), + }; + }) + .ExecuteSelectMany(); + return result; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/ProgramRepository.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/ProgramRepository.cs new file mode 100644 index 0000000..8388a74 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/ProgramRepository.cs @@ -0,0 +1,198 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; + +namespace Server.WatchBody.Core.Dal.Src.Repository; + +public interface IProgramRepository +{ + int Insert(Program program); + IEnumerable Select(int athleteId); + Program? Get(int athleteId, int programId); +} + +/// Репозиторий src.program (+ денормализация активной версии). +public class ProgramRepository : IProgramRepository +{ + private readonly IConnectionString _connectionString; + + public ProgramRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Создание программы — src.program_insert. + public int Insert(Program program) + { + return _connectionString + .PrepareExecute("src.program_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", program.AthleteId) + .AddParameter("p_title", program.Title) + .AddParameterNullable("p_external_id", program.ExternalId) + .ExecuteScalar(); + } + + /// Программы атлета — src.program_select. + public IEnumerable Select(int athleteId) + { + var result = new List(); + _connectionString + .PrepareExecute("src.program_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddConverter((record, list) => + { + result.Add(new Program + { + Id = record.Convert(record.Src["id"]), + AthleteId = record.Convert(record.Src["athlete_id"]), + Title = record.Src["title"] as string ?? string.Empty, + CreatedAt = record.Convert(record.Src["created_at"]), + ActiveVersionId = record.IsDbNull("active_version_id") + ? null : record.Convert(record.Src["active_version_id"]), + ActiveVersionNumber = record.IsDbNull("active_version") + ? null : record.Convert(record.Src["active_version"]), + }); + }) + .ExecuteSelectMany(); + return result; + } + + /// Программа атлета — src.program_get. + public Program? Get(int athleteId, int programId) + { + Program? result = null; + _connectionString + .PrepareExecute("src.program_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_program_id", programId) + .AddConverter((record, list) => + { + result = new Program + { + Id = record.Convert(record.Src["id"]), + AthleteId = record.Convert(record.Src["athlete_id"]), + Title = record.Src["title"] as string ?? string.Empty, + CreatedAt = record.Convert(record.Src["created_at"]), + ActiveVersionId = record.IsDbNull("active_version_id") + ? null : record.Convert(record.Src["active_version_id"]), + ActiveVersionNumber = record.IsDbNull("active_version") + ? null : record.Convert(record.Src["active_version"]), + }; + }) + .ExecuteSelectMany(); + return result; + } +} + +public interface IProgramVersionRepository +{ + Guid Insert(int athleteId, ProgramVersion version); + int UpdateStatus(int athleteId, Guid versionId, string status); + IEnumerable Select(ProgramVersionFilter? filter); + ProgramVersion? Get(int athleteId, Guid versionId); +} + +/// Репозиторий src.program_version (🛡 тело неизменяемо — только Insert/UpdateStatus). +public class ProgramVersionRepository : IProgramVersionRepository +{ + private readonly IConnectionString _connectionString; + + public ProgramVersionRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Новая версия (всегда draft) — src.program_version_insert. + public Guid Insert(int athleteId, ProgramVersion version) + { + if (version.Id == Guid.Empty) + { + throw new InvalidOperationException("ProgramVersion.Id обязателен (uuid телефона)"); + } + return _connectionString + .PrepareExecute("src.program_version_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", version.Id) + .AddParameter("p_program_id", version.ProgramId) + .AddParameterNullable("p_based_on_version", version.BasedOnVersion) + .AddParameter("p_author", version.Author) + .AddParameter("p_body", version.Body) + .AddParameter("p_rationale", version.Rationale) + .ExecuteScalar(); + } + + /// Смена статуса (единственное изменение, 🛡) — src.program_version_update. + public int UpdateStatus(int athleteId, Guid versionId, string status) + { + return _connectionString + .PrepareExecute("src.program_version_update") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", versionId) + .AddParameter("p_status", status) + .ExecuteScalar(); + } + + /// Список версий (без body) — src.program_version_select. + public IEnumerable Select(ProgramVersionFilter? filter) + { + filter ??= new ProgramVersionFilter(); + var result = new List(); + _connectionString + .PrepareExecute("src.program_version_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", filter.AthleteId) + .AddParameterNullable("p_program_id", filter.ProgramId) + .AddParameterNullable("p_status", filter.Status) + .AddParameterNullable("p_author", filter.Author) + .AddConverter((record, list) => + { + result.Add(new ProgramVersion + { + Id = record.Convert(record.Src["id"]), + ProgramId = record.Convert(record.Src["program_id"]), + Version = record.Convert(record.Src["version"]), + BasedOnVersion = record.IsDbNull("based_on_version") + ? null : record.Convert(record.Src["based_on_version"]), + Status = record.Src["status"] as string ?? string.Empty, + Author = record.Src["author"] as string ?? string.Empty, + Rationale = record.Src["rationale"] as string ?? string.Empty, + CreatedAt = record.Convert(record.Src["created_at"]), + }); + }) + .ExecuteSelectMany(); + return result; + } + + /// Версия с телом — src.program_version_get. + public ProgramVersion? Get(int athleteId, Guid versionId) + { + ProgramVersion? result = null; + _connectionString + .PrepareExecute("src.program_version_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", versionId) + .AddConverter((record, list) => + { + result = new ProgramVersion + { + Id = record.Convert(record.Src["id"]), + ProgramId = record.Convert(record.Src["program_id"]), + Version = record.Convert(record.Src["version"]), + BasedOnVersion = record.IsDbNull("based_on_version") + ? null : record.Convert(record.Src["based_on_version"]), + Status = record.Src["status"] as string ?? string.Empty, + Author = record.Src["author"] as string ?? string.Empty, + Body = record.Src["body"] as string ?? string.Empty, + Rationale = record.Src["rationale"] as string ?? string.Empty, + CreatedAt = record.Convert(record.Src["created_at"]), + }; + }) + .ExecuteSelectMany(); + return result; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/WorkoutRepository.cs b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/WorkoutRepository.cs new file mode 100644 index 0000000..9c1bf0b --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Dal/Src/Repository/WorkoutRepository.cs @@ -0,0 +1,276 @@ +using System.Text.Json; +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; + +namespace Server.WatchBody.Core.Dal.Src.Repository; + +public interface IWorkoutRepository +{ + Guid Insert(int athleteId, Workout workout); + int Update(int athleteId, Workout workout); + IEnumerable Select(WorkoutFilter? filter); + Workout? Get(int athleteId, Guid workoutId); +} + +/// Репозиторий src.workout (карточки с подходами и командами — jsonb). +public class WorkoutRepository : IWorkoutRepository +{ + private readonly IConnectionString _connectionString; + + public WorkoutRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Создание тренировки (идемпотентно по uuid) — src.workout_insert. + public Guid Insert(int athleteId, Workout workout) + { + return _connectionString + .PrepareExecute("src.workout_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", workout.Id) + .AddParameterNullable("p_program_version_id", workout.ProgramVersionId) + .AddParameter("p_started_at", workout.StartedAt) + .AddParameter("p_session_kind", workout.SessionKind) + .ExecuteScalar(); + } + + /// Закрытие/факты .fit (все поля, db-m-8) — src.workout_update. + public int Update(int athleteId, Workout workout) + { + return _connectionString + .PrepareExecute("src.workout_update") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", workout.Id) + .AddParameterNullable("p_ended_at", workout.EndedAt) + .AddParameter("p_status", workout.Status) + .AddParameter("p_session_kind", workout.SessionKind) + .AddParameterNullable("p_avg_hr", workout.AvgHr) + .AddParameterNullable("p_facts_json", workout.FactsJson) + .AddParameterNullable("p_fit_file_id", workout.FitFileId) + .AddParameterNullable("p_hr_recovery_s", workout.HrRecoveryS) + .ExecuteScalar(); + } + + /// Карточки тренировок с подходами — src.workout_select. + public IEnumerable Select(WorkoutFilter? filter) + { + filter ??= new WorkoutFilter(); + var result = new List(); + _connectionString + .PrepareExecute("src.workout_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", filter.AthleteId) + .AddParameterNullable("p_status", filter.Status) + .AddParameterNullable("p_date_from", filter.DateFrom) + .AddParameterNullable("p_date_to", filter.DateTo) + .AddConverter((record, list) => + result.Add(ConvertWorkout(record, withCommands: false))) + .ExecuteSelectMany(); + return result; + } + + /// Полная карточка (подходы + команды) — src.workout_get. + public Workout? Get(int athleteId, Guid workoutId) + { + Workout? result = null; + _connectionString + .PrepareExecute("src.workout_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", workoutId) + .AddConverter((record, list) => + result = ConvertWorkout(record, withCommands: true)) + .ExecuteSelectMany(); + return result; + } + + private static Workout ConvertWorkout(IDataRecordSpecified record, bool withCommands) + { + return new Workout + { + Id = record.Convert(record.Src["id"]), + AthleteId = record.Convert(record.Src["athlete_id"]), + ProgramVersionId = record.IsDbNull("program_version_id") + ? null : record.Convert(record.Src["program_version_id"]), + StartedAt = record.Convert(record.Src["started_at"]), + EndedAt = record.IsDbNull("ended_at") ? null : record.Convert(record.Src["ended_at"]), + Status = record.Src["status"] as string ?? string.Empty, + SessionKind = record.Src["session_kind"] as string ?? string.Empty, + AvgHr = record.IsDbNull("avg_hr") ? null : record.Convert(record.Src["avg_hr"]), + FactsJson = record.IsDbNull("facts_json") ? null : record.Src["facts_json"].ToString(), + FitFileId = record.IsDbNull("fit_file_id") ? null : record.Src["fit_file_id"] as string, + HrRecoveryS = record.IsDbNull("hr_recovery_s") ? null : record.Convert(record.Src["hr_recovery_s"]), + Sets = ParseSets(record.Src["sets"].ToString()), + Commands = withCommands ? ParseCommands(record.Src["commands"].ToString()) : [], + }; + } + + internal static IEnumerable ParseSets(string? json) + { + if (string.IsNullOrEmpty(json)) + { + return []; + } + return JsonSerializer.Deserialize>(json, + new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + // errata m04: ключи агрегата — snake_case ('in_plan', 'weight_kg') + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }) ?? []; + } + + internal static IEnumerable ParseCommands(string? json) + { + if (string.IsNullOrEmpty(json)) + { + return []; + } + var result = new List(); + using var document = JsonDocument.Parse(json); + foreach (var element in document.RootElement.EnumerateArray()) + { + result.Add(new CommandLog + { + Id = element.GetProperty("id").GetGuid(), + WorkoutId = element.GetProperty("workout_id").GetGuid(), + TOffsetS = element.GetProperty("t_offset_s").GetInt32(), + RecognizedText = element.GetProperty("recognized_text").GetString() ?? string.Empty, + Semantics = element.GetProperty("semantics").GetRawText(), + Accepted = element.GetProperty("accepted").GetBoolean(), + }); + } + return result; + } +} + +public interface IWorkoutSetRepository +{ + Guid Insert(WorkoutSet set); + WorkoutSet? Get(int athleteId, Guid setId); +} + +/// Репозиторий src.workout_set (без update/delete — правка = новая запись source=edit, 🛡). +public class WorkoutSetRepository : IWorkoutSetRepository +{ + private readonly IConnectionString _connectionString; + + public WorkoutSetRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Фиксация подхода (идемпотентно по uuid) — src.workout_set_insert. + public Guid Insert(WorkoutSet set) + { + return _connectionString + .PrepareExecute("src.workout_set_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", set.AthleteId) + .AddParameter("p_id", set.Id) + .AddParameter("p_workout_id", set.WorkoutId) + .AddParameterNullable("p_exercise_id", set.ExerciseId) + .AddParameter("p_seq", set.Seq) + .AddParameter("p_weight_kg", set.WeightKg) + .AddParameter("p_reps", set.Reps) + .AddParameter("p_done_at", set.DoneAt) + .AddParameter("p_source", set.Source) + .AddParameter("p_in_plan", set.InPlan) + .AddParameterNullable("p_rpe", set.Rpe) + .AddParameterNullable("p_knee_pain", set.KneePain) + .ExecuteScalar(); + } + + /// Подход по id — src.workout_set_get. + public WorkoutSet? Get(int athleteId, Guid setId) + { + WorkoutSet? result = null; + _connectionString + .PrepareExecute("src.workout_set_get") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", setId) + .AddConverter((record, list) => + { + result = new WorkoutSet + { + Id = record.Convert(record.Src["id"]), + WorkoutId = record.Convert(record.Src["workout_id"]), + AthleteId = record.Convert(record.Src["athlete_id"]), + ExerciseId = record.IsDbNull("exercise_id") + ? null : record.Convert(record.Src["exercise_id"]), + Seq = record.Convert(record.Src["seq"]), + WeightKg = record.Convert(record.Src["weight_kg"]), + Reps = record.Convert(record.Src["reps"]), + DoneAt = record.Convert(record.Src["done_at"]), + Source = record.Src["source"] as string ?? string.Empty, + InPlan = record.Convert(record.Src["in_plan"]), + Rpe = record.IsDbNull("rpe") ? null : record.Convert(record.Src["rpe"]), + KneePain = record.IsDbNull("knee_pain") ? null : record.Convert(record.Src["knee_pain"]), + }; + }) + .ExecuteSelectMany(); + return result; + } +} + +public interface ICommandLogRepository +{ + Guid Insert(int athleteId, CommandLog command); + IEnumerable Select(int athleteId, Guid workoutId); +} + +/// Репозиторий src.command_log (журнал не редактируется, 🛡). +public class CommandLogRepository : ICommandLogRepository +{ + private readonly IConnectionString _connectionString; + + public CommandLogRepository(IConnectionString connectionString) + { + _connectionString = connectionString; + } + + /// Запись команды — src.command_log_insert. + public Guid Insert(int athleteId, CommandLog command) + { + return _connectionString + .PrepareExecute("src.command_log_insert") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_id", command.Id) + .AddParameter("p_workout_id", command.WorkoutId) + .AddParameter("p_t_offset_s", command.TOffsetS) + .AddParameter("p_recognized_text", command.RecognizedText) + .AddParameter("p_semantics", command.Semantics) + .AddParameter("p_accepted", command.Accepted) + .ExecuteScalar(); + } + + /// Журнал команд тренировки — src.command_log_select. + public IEnumerable Select(int athleteId, Guid workoutId) + { + var result = new List(); + _connectionString + .PrepareExecute("src.command_log_select") + .AsStoredProcedure() + .AddParameter("p_athlete_id", athleteId) + .AddParameter("p_workout_id", workoutId) + .AddConverter((record, list) => + { + result.Add(new CommandLog + { + Id = record.Convert(record.Src["id"]), + WorkoutId = record.Convert(record.Src["workout_id"]), + TOffsetS = record.Convert(record.Src["t_offset_s"]), + RecognizedText = record.Src["recognized_text"] as string ?? string.Empty, + Semantics = record.Src["semantics"].ToString() ?? string.Empty, + Accepted = record.Convert(record.Src["accepted"]), + }); + }) + .ExecuteSelectMany(); + return result; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Metrics/MetricsConfig.cs b/3-app/2-cli/Server.WatchBody.Core/Metrics/MetricsConfig.cs new file mode 100644 index 0000000..d41cfe2 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Metrics/MetricsConfig.cs @@ -0,0 +1,58 @@ +using System.Text.Json; + +namespace Server.WatchBody.Core.Metrics; + +/// +/// Пороги красных флагов (секция Safety, Р3-А); умолчания — документ +/// метрик (п. 2.1–2.3, правила 1 и 6). 🛡 В расчётном коде пороги не +/// хардкодятся — только эти значения. +/// +public class MetricsConfig +{ + public int BpCancelSys { get; set; } = 150; + public int BpCancelDias { get; set; } = 90; + public int BpElevatedSys { get; set; } = 140; + public int BpElevatedDias { get; set; } = 95; + public int HrRestingRise { get; set; } = 8; + public int HrRestingDays { get; set; } = 2; + public int WellbeingLow { get; set; } = 2; + public int WellbeingDays { get; set; } = 2; + public int LightDayCount { get; set; } = 3; + public int KneePainMin { get; set; } = 1; + public int KneeRepeatSessions { get; set; } = 2; + public int HrBaselineDays { get; set; } = 14; + public int HrBaselineMin { get; set; } = 3; + + /// Чтение секции Safety (отсутствует/частичная — умолчания). + public static MetricsConfig Load(JsonElement root) + { + var config = new MetricsConfig(); + if (!root.TryGetProperty("Safety", out var safety) || safety.ValueKind != JsonValueKind.Object) + return config; + foreach (var property in typeof(MetricsConfig).GetProperties()) + { + var name = char.ToLowerInvariant(property.Name[0]) + property.Name[1..]; + if (!safety.TryGetProperty(name, out var value)) continue; + if (value.ValueKind != JsonValueKind.Number || !value.TryGetInt32(out var number)) + throw new InvalidOperationException($"Safety.{name}: ожидалось целое число"); + property.SetValue(config, number); + } + return config; + } + + /// Ошибки конфигурации (фатальны при старте). + public List Validate() + { + var errors = new List(); + // по документу метрик коридоры АД перекрываются + // (отмена: >150/90; «только кардио»: 140–150/90–95), поэтому + // bpElevatedDias (95) может быть больше bpCancelDias (90) + if (BpElevatedSys > BpCancelSys) errors.Add("Safety: bpElevatedSys > bpCancelSys"); + foreach (var property in typeof(MetricsConfig).GetProperties()) + { + var name = char.ToLowerInvariant(property.Name[0]) + property.Name[1..]; + if ((int)property.GetValue(this)! <= 0) errors.Add($"Safety: {name} <= 0"); + } + return errors; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Metrics/MetricsEngine.cs b/3-app/2-cli/Server.WatchBody.Core/Metrics/MetricsEngine.cs new file mode 100644 index 0000000..29893d3 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Metrics/MetricsEngine.cs @@ -0,0 +1,281 @@ +using System.Text.Json; +using Server.WatchBody.Core.Dal.Ref.Domain; +using Server.WatchBody.Core.Dal.Ref.Repository; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Core.Programs; + +namespace Server.WatchBody.Core.Metrics; + +/// +/// Детерминированный расчёт метрик одной тренировки (Ф-12.1, документ +/// метрик п. 2.5, правила 1 и 6) → src.workout_metrics (upsert). +/// Все выборки по athleteId (🛡 изоляция); в план ничего не пишет — +/// это m05 (🛡3); сбой расчёта не должен ломать sync (🛡4, см. m02). +/// +public class MetricsEngine( + IWorkoutRepository workouts, + IExerciseRepository exercises, + IExerciseGroupRepository groups, + IDailyLogRepository dailyLogs, + IWorkoutMetricsRepository metrics, + ProgramStore programs, + MetricsConfig safety) +{ + private static readonly string[] DayNames = + ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; + + public WorkoutMetrics ComputeForWorkout(int athleteId, Guid workoutId) + { + var workout = workouts.Get(athleteId, workoutId) + ?? throw new InvalidOperationException( + $"тренировка {workoutId} не найдена (athlete {athleteId})"); + var sets = workout.Sets.ToList(); + var exById = exercises.Select(new ExerciseFilter { AthleteId = athleteId }) + .ToDictionary(e => e.Id); + var groupCodeById = groups.Select().ToDictionary(g => g.Id, g => g.Code); + + var date = DateOnly.FromDateTime(workout.StartedAt); + var logs = dailyLogs.Select(new DailyLogFilter + { + AthleteId = athleteId, + DateFrom = date.AddDays(-safety.HrBaselineDays - safety.LightDayCount), + DateTo = date, + }) + .GroupBy(l => l.Date).ToDictionary(g => g.Key, g => g.First()); + + var result = new WorkoutMetrics + { + WorkoutId = workoutId, + AthleteId = athleteId, + Adherence = Adherence(workout, sets, date), + Tonnage = Tonnage(sets), + TonnageByGroup = JsonSerializer.Serialize(TonnageByGroup(sets, exById, groupCodeById)), + E1pmByExercise = JsonSerializer.Serialize(E1pmByExercise(sets)), + HrMetrics = HrMetrics(workout), + RedFlags = JsonSerializer.Serialize(RedFlags(workout, sets, date, logs, exById)), + }; + metrics.Upsert(athleteId, result); + return result; + } + + /// % подходов «по плану» от планового числа подходов сессии дня; + /// план не сопоставлен → доля in_plan среди фактических подходов (spec 3.1). + private decimal Adherence(Workout workout, List sets, DateOnly date) + { + var inPlan = sets.Count(s => s.InPlan); + var planned = PlannedSets(workout, date); + if (planned != null) + return planned.Value == 0 ? 0 : Percent(inPlan, planned.Value); + return sets.Count == 0 ? 0 : Percent(inPlan, sets.Count); + } + + /// Плановое число подходов сессии дня недели из активной версии; + /// null — плана нет (нет версии / сессии дня / body не парсится). + private int? PlannedSets(Workout workout, DateOnly date) + { + var active = programs.GetActive(workout.AthleteId); + if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null; + try + { + using var doc = JsonDocument.Parse(active.Body); + var day = DayNames[((int)date.DayOfWeek + 6) % 7]; + foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray()) + foreach (var session in week.GetProperty("sessions").EnumerateArray()) + { + if (!session.TryGetProperty("day", out var d) || + !string.Equals(d.GetString(), day, StringComparison.OrdinalIgnoreCase)) + continue; + var count = 0; + foreach (var exercise in session.GetProperty("exercises").EnumerateArray()) + count += exercise.GetProperty("sets").GetArrayLength(); + return count; + } + return null; + } + catch (Exception) + { + return null; // body не парсится — метрики считаем без плана + } + } + + private static decimal Tonnage(List sets) => + sets.Where(s => s.WeightKg > 0 && s.Reps > 0).Sum(s => s.WeightKg * s.Reps); + + private static Dictionary TonnageByGroup( + List sets, Dictionary exById, Dictionary groupCodeById) + { + var result = new Dictionary(); + foreach (var set in sets) + { + if (set.WeightKg <= 0 || set.Reps <= 0) continue; + if (set.ExerciseId == null || !exById.TryGetValue(set.ExerciseId.Value, out var ex)) continue; + var code = groupCodeById.GetValueOrDefault(ex.GroupId, $"group_{ex.GroupId}"); + result[code] = result.GetValueOrDefault(code) + set.WeightKg * set.Reps; + } + return result; + } + + /// Эпли (ОВ-7): e1pm = w × (1 + reps/30) по лучшему подходу + /// упражнения (max вес, при равном — max reps), spec 3.4. + private static Dictionary E1pmByExercise(List sets) + { + var best = new Dictionary(); + foreach (var set in sets) + { + if (set.ExerciseId == null || set.WeightKg <= 0 || set.Reps <= 0) continue; + if (!best.TryGetValue(set.ExerciseId.Value, out var b) || + set.WeightKg > b.WeightKg || + (set.WeightKg == b.WeightKg && set.Reps > b.Reps)) + best[set.ExerciseId.Value] = set; + } + return best.ToDictionary( + p => p.Key.ToString(), + p => Math.Round(p.Value.WeightKg * (1m + p.Value.Reps / 30m), 1, MidpointRounding.AwayFromZero)); + } + + /// Из фактов .fit (m06), что есть; фактов нет — телефонные + /// avg_hr/hr_recovery_s (spec 3.5). + private static string HrMetrics(Workout workout) + { + var result = new Dictionary(); + if (!string.IsNullOrWhiteSpace(workout.FactsJson)) + { + try + { + using var doc = JsonDocument.Parse(workout.FactsJson); + foreach (var key in new[] { "avg_hr", "peak_hr", "rest_between_s" }) + if (doc.RootElement.TryGetProperty(key, out var v) && v.TryGetDecimal(out var d)) + result[key] = d; + } + catch (JsonException) + { + // битые факты — метрики по телефонным полям + } + } + if (result.Count == 0) + { + if (workout.AvgHr != null) result["avg_hr"] = workout.AvgHr.Value; + if (workout.HrRecoveryS != null) result["hr_recovery_s"] = workout.HrRecoveryS.Value; + } + return JsonSerializer.Serialize(result); + } + + private List RedFlags( + Workout workout, List sets, DateOnly date, + Dictionary logs, Dictionary exById) + { + var flags = new List(); + + var day = logs.GetValueOrDefault(date); + if (day != null) + { + if ((day.BpSys != null && day.BpSys > safety.BpCancelSys) || + (day.BpDias != null && day.BpDias > safety.BpCancelDias)) + flags.Add(Flag("bp_over_150_90", date)); + else if ((day.BpSys != null && day.BpSys >= safety.BpElevatedSys) || + (day.BpDias != null && day.BpDias >= safety.BpElevatedDias)) + flags.Add(Flag("bp_elevated", date)); + } + + var hrRun = HrRestingRun(date, logs); + if (hrRun.Count >= safety.HrRestingDays) + flags.Add(new Dictionary { ["flag"] = "hr_resting_up", ["dates"] = hrRun }); + + var wbRun = ConditionRun(date, logs, + l => l.Wellbeing != null, l => l.Wellbeing <= safety.WellbeingLow, null); + if (wbRun.Count >= safety.WellbeingDays) + flags.Add(new Dictionary { ["flag"] = "wellbeing_low", ["dates"] = wbRun }); + + if (hrRun.Count >= safety.LightDayCount || wbRun.Count >= safety.LightDayCount) + flags.Add(Flag("cancel_recommendation", date)); + + if (KneePainRepeat(workout, sets, exById)) + flags.Add(Flag("knee_pain_repeat", date)); + + return flags; + } + + private static Dictionary Flag(string name, DateOnly date) => + new() { ["flag"] = name, ["date"] = date }; + + /// Серия «пульс покоя ≥ базовый + HrRestingRise» назад от дня + /// тренировки, включая его (spec 03 §2 #3). Начало серии — среди + /// последних LightDayCount записей; базовый = среднее за окно до начала + /// серии, значений меньше HrBaselineMin → серия не считается. + private List HrRestingRun(DateOnly date, Dictionary logs) + { + List best = []; + for (var back = 0; back < safety.LightDayCount; back++) + { + var start = date.AddDays(-back); + var baseline = HrBaseline(start, logs); + if (baseline == null) continue; + var run = ConditionRun(date, logs, + l => l.HrResting != null, + l => l.HrResting >= baseline + safety.HrRestingRise, + start); + if (run.Count > best.Count) best = run; + } + return best; + } + + /// Базовый пульс покоя: среднее hr_resting за окно до дня start; + /// значений меньше HrBaselineMin → null. + private int? HrBaseline(DateOnly start, Dictionary logs) + { + var window = new List(); + for (var day = start.AddDays(-safety.HrBaselineDays); day < start; day = day.AddDays(1)) + if (logs.TryGetValue(day, out var log) && log.HrResting != null) + window.Add(log.HrResting.Value); + return window.Count < safety.HrBaselineMin + ? null + : (int)Math.Round(window.Average(), MidpointRounding.AwayFromZero); + } + + /// Даты серии условия назад от date до start (start null — без + /// нижней границы): запись с условием продлевает серию, запись без + /// условия рвёт, день без записи не рвёт и не удлиняет (spec 03). + private List ConditionRun( + DateOnly date, Dictionary logs, + Func hasValue, Func matches, DateOnly? start) + { + var run = new List(); + for (var day = date; ; day = day.AddDays(-1)) + { + if (start != null && day < start) break; + if (day < date.AddDays(-safety.HrBaselineDays - safety.LightDayCount)) break; + if (!logs.TryGetValue(day, out var log) || !hasValue(log)) continue; + if (!matches(log)) break; + run.Add(day); + if (run.Count >= safety.LightDayCount) break; + } + return run; + } + + /// Боль в колене ≥ KneePainMin в KneeRepeatSessions занятиях + /// подряд (текущая + предыдущие по started_at), группа «ноги» (spec 03 §2 #6). + private bool KneePainRepeat(Workout workout, List sets, Dictionary exById) + { + if (!HasLegKneePain(sets, exById)) return false; + var need = safety.KneeRepeatSessions - 1; + if (need <= 0) return true; + var previous = workouts.Select(new WorkoutFilter + { + AthleteId = workout.AthleteId, + DateTo = DateOnly.FromDateTime(workout.StartedAt).AddDays(-1), + }) + .OrderByDescending(w => w.StartedAt) + .Take(need) + .ToList(); + return previous.Count == need && previous.All(w => HasLegKneePain(w.Sets.ToList(), exById)); + } + + private bool HasLegKneePain(List sets, Dictionary exById) => + sets.Any(s => s.KneePain != null && s.KneePain >= safety.KneePainMin + && s.ExerciseId != null && exById.TryGetValue(s.ExerciseId.Value, out var ex) + && ex.GroupId == ExerciseGroups.Legs); + + private static decimal Percent(int numerator, int denominator) => + Math.Round(numerator * 100m / denominator, 1, MidpointRounding.AwayFromZero); +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Programs/BreakEngine.cs b/3-app/2-cli/Server.WatchBody.Core/Programs/BreakEngine.cs new file mode 100644 index 0000000..ca1143b --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Programs/BreakEngine.cs @@ -0,0 +1,171 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; + +namespace Server.WatchBody.Core.Programs; + +/// +/// Перерывы (Ф-14): авто-детект по пропускам (source=auto), закрытие +/// первой сессией, формула возврата (документ перерывов разд. 3.2) → +/// draft-версия (author=rules) с session_kind=return|test в week 1. +/// 🛡 Активная версия не меняется; запись разрешена только в breaks +/// и через ProgramStore.CreateDraft. +/// +public class BreakEngine( + IWorkoutRepository workouts, + IBreakRepository breaks, + ProgramStore programs, + ProgressionConfig config) +{ + private static readonly string[] DayNames = + ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; + + /// Авто-детект (spec 04 §2): все плановые дни окна + /// MissDaysForBreak без единой тренировки → break_insert(auto). + public bool DetectBreak(int athleteId, DateOnly today) + { + if (OpenBreak(athleteId) != null) return false; + var active = programs.GetActive(athleteId); + if (active == null || string.IsNullOrWhiteSpace(active.Body)) return false; + + var plannedDays = PlannedDays(active.Body); + var windowStart = today.AddDays(-(config.MissDaysForBreak - 1)); + var workoutDays = workouts + .Select(new WorkoutFilter { AthleteId = athleteId, DateFrom = windowStart, DateTo = today }) + .Select(w => DateOnly.FromDateTime(w.StartedAt)) + .ToHashSet(); + if (workoutDays.Count > 0) return false; // в окне есть тренировка — не перерыв + + var missed = new List(); + for (var day = windowStart; day <= today; day = day.AddDays(1)) + { + if (!plannedDays.Contains(DayName(day))) continue; + missed.Add(day); + } + if (missed.Count == 0) return false; + + breaks.Insert(athleteId, new Break + { + Id = Guid.NewGuid(), + AthleteId = athleteId, + Source = BreakSources.Auto, + StartedOn = missed[0], + Note = $"авто: пропущены все сессии за {config.MissDaysForBreak} дней", + }); + return true; + } + + /// Закрытие первой сессией после break (ended_on) и + /// draft-версия возврата с коэффициентом по таблице (spec 04 §3). + public ProgramVersion? CloseOnReturn(int athleteId, Guid workoutId, DateOnly today) + { + var open = OpenBreak(athleteId); + if (open == null) return null; + var active = programs.GetActive(athleteId); + if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null; + var workout = workouts.Get(athleteId, workoutId); + if (workout == null) return null; + + var endedOn = DateOnly.FromDateTime(workout.StartedAt); + open.EndedOn = endedOn; + breaks.Update(athleteId, open); // closed_by_first_session (db-m-8) + + if (HasRulesDraft(athleteId, active.Version)) return null; + + var days = endedOn.DayNumber - open.StartedOn.DayNumber; + var factor = open.Kind == BreakKinds.Illness + ? config.ReturnMinFactor // болезнь — перезапуск (правило 12) + : days switch + { + <= 6 => 1m, // 4–6 дней: веса без снижения + <= 13 => 0.9m, // 7–13: −10 % + <= 27 => 0.85m, // 14–27: −15 % + <= 59 => 0.75m, // 28–59: −25 % + _ => config.ReturnMinFactor, // 60+: перезапуск + }; + if (factor < 1m) + factor = Math.Clamp(factor, config.ReturnMinFactor, config.ReturnMaxFactor); + + var kind = days >= 14 && open.Kind != BreakKinds.Illness + ? SessionKinds.Test // правило 17: тест-сессия RPE ≤ 6 + : SessionKinds.Return; + + var body = JsonNode.Parse(active.Body) as JsonObject + ?? throw new InvalidOperationException("body активной версии не является объектом"); + var firstWeek = body["weeks"] is JsonArray weeks && weeks.Count > 0 + ? weeks[0] as JsonObject + : null; + if (firstWeek != null) + foreach (var session in firstWeek["sessions"] is JsonArray sessions + ? sessions.OfType().ToList() + : []) + { + session["session_kind"] = kind; + foreach (var exerciseNode in session["exercises"] is JsonArray exerciseNodes + ? exerciseNodes.OfType().ToList() + : []) + foreach (var set in exerciseNode["sets"] is JsonArray sets + ? sets.OfType().ToList() + : []) + { + if (set["weight_kg"] is JsonValue value && value.TryGetValue(out decimal weight)) + set["weight_kg"] = Halves(weight * factor); + // 4–6 дней: первая сессия мягче (−1 подход, RPE 5–6) + if (days <= 6 && set["target_rpe"] != null) + set["target_rpe"] = 5; + } + if (days <= 6) TrimSets(session); + } + + var kindNote = open.Kind == BreakKinds.Illness + ? "болезнь" + : $"простой {days} дн."; + return programs.CreateDraft(athleteId, body.ToJsonString(), + ProgramVersionAuthors.Rules, + $"Возврат после break ({kindNote}): веса ×{factor}, session_kind={kind}"); + } + + private Break? OpenBreak(int athleteId) => + breaks.Select(new BreakFilter { AthleteId = athleteId }) + .Where(b => b.EndedOn == null) + .OrderByDescending(b => b.StartedOn) + .FirstOrDefault(); + + /// Плановые дни недели активной версии (v1 — все недели + /// одинаковы, ротация — v2). + private static HashSet PlannedDays(string body) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + using var doc = JsonDocument.Parse(body); + foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray()) + foreach (var session in week.GetProperty("sessions").EnumerateArray()) + if (session.TryGetProperty("day", out var day) && day.ValueKind == JsonValueKind.String) + result.Add(day.GetString()!); + return result; + } + + private static string DayName(DateOnly date) => DayNames[((int)date.DayOfWeek + 6) % 7]; + + /// Первая сессия мягче: подходы −1 (минимум 1). + private static void TrimSets(JsonObject session) + { + if (session["exercises"] is not JsonArray exerciseNodes) return; + foreach (var exerciseNode in exerciseNodes.OfType()) + { + if (exerciseNode["sets"] is not JsonArray sets) continue; + if (sets.Count > 1) sets.RemoveAt(sets.Count - 1); + } + } + + private static decimal Halves(decimal value) => + Math.Round(value * 2m, MidpointRounding.AwayFromZero) / 2m; + + /// Rules-draft на активную версию или новее — предложений + /// не создаём (spec 04 §3.3). + private bool HasRulesDraft(int athleteId, int activeVersion) => + programs.ListVersions(athleteId).Any(v => + v.Author == ProgramVersionAuthors.Rules && + v.Status == ProgramVersionStatuses.Draft && + (v.BasedOnVersion ?? 0) >= activeVersion); +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Programs/ProgramStore.cs b/3-app/2-cli/Server.WatchBody.Core/Programs/ProgramStore.cs new file mode 100644 index 0000000..b950a23 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Programs/ProgramStore.cs @@ -0,0 +1,129 @@ +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; + +namespace Server.WatchBody.Core.Programs; + +/// Нарушение правил жизненного цикла программы (спецификации m03). +public class ProgramStoreException(string message) : Exception(message); + +/// +/// Единая точка работы с программами атлета (m03): версионный JSON, +/// жизненный цикл draft → active → archived, «active — ровно одна». +/// 🛡 Версия после создания неизменяема (меняется только статус); +/// переходы только вперёд; автоматической смены активной версии нет. +/// +public class ProgramStore(IProgramRepository programs, IProgramVersionRepository versions) +{ + public static readonly IReadOnlyList AllowedAuthors = ["user", "admin", "rules"]; + + /// Программа атлета (создаёт при отсутствии; одна на атлета). + public Program EnsureProgram(int athleteId, string title) + { + var existing = programs.Select(athleteId).FirstOrDefault(); + if (existing != null) return existing; + var id = programs.Insert(new Program { AthleteId = athleteId, Title = title }); + return new Program { Id = id, AthleteId = athleteId, Title = title }; + } + + /// + /// Новая draft-версия: based_on_version = актуальная максимальная + /// (или явная, равная ей); version считает БД (max+1). + /// + public ProgramVersion CreateDraft( + int athleteId, string body, string author, string rationale, int? basedOnVersion = null) + { + if (string.IsNullOrWhiteSpace(body)) + throw new ProgramStoreException("body версии обязателен"); + if (!AllowedAuthors.Contains(author)) + throw new ProgramStoreException($"author '{author}' не допускается в v1 ({string.Join("/", AllowedAuthors)}; llm — v2)"); + + var program = EnsureProgram(athleteId, "Программа"); + var latest = LatestVersion(athleteId, program.Id); + if (basedOnVersion.HasValue && basedOnVersion != latest) + throw new ProgramStoreException( + $"based_on_version {basedOnVersion} не равна актуальной версии {latest}"); + var effectiveBasedOn = basedOnVersion ?? (latest == 0 ? null : latest); + + var id = Guid.NewGuid(); + versions.Insert(athleteId, new ProgramVersion + { + Id = id, + ProgramId = program.Id, + Version = 0, // номер считает БД (max+1) + BasedOnVersion = effectiveBasedOn, + Status = "draft", // выставит БД + Author = author, + Body = body, + Rationale = rationale ?? string.Empty, + }); + return versions.Get(athleteId, id)!; + } + + /// draft → active; прежняя активная → archived (гарантия БД). + public void Activate(int athleteId, Guid versionId) + { + var version = GetOrThrow(athleteId, versionId); + if (version.Status != "draft") + throw new ProgramStoreException( + $"активировать можно только draft-версию (текущий статус '{version.Status}')"); + versions.UpdateStatus(athleteId, versionId, "active"); + } + + /// active → archived. + public void Archive(int athleteId, Guid versionId) + { + var version = GetOrThrow(athleteId, versionId); + if (version.Status != "active") + throw new ProgramStoreException( + $"архивировать можно только active-версию (текущий статус '{version.Status}')"); + versions.UpdateStatus(athleteId, versionId, "archived"); + } + + /// Активная версия с body (или null). + public ProgramVersion? GetActive(int athleteId) + { + var program = programs.Select(athleteId).FirstOrDefault(); + if (program == null) return null; + var meta = versions.Select(new ProgramVersionFilter + { + AthleteId = athleteId, + ProgramId = program.Id, + Status = "active", + }).FirstOrDefault(); + return meta == null ? null : versions.Get(athleteId, meta.Id); + } + + /// Версии программы атлета (без body), по возрастанию номера. + public IReadOnlyList ListVersions(int athleteId) + { + var program = programs.Select(athleteId).FirstOrDefault(); + if (program == null) return []; + var list = versions.Select(new ProgramVersionFilter + { + AthleteId = athleteId, + ProgramId = program.Id, + }).OrderBy(v => v.Version).ToList(); + foreach (var v in list) v.Body = string.Empty; // body select не возвращает + return list; + } + + /// Версия с body; чужая/несуществующая → null. + public ProgramVersion? GetVersion(int athleteId, Guid versionId) => + versions.Get(athleteId, versionId); + + private int LatestVersion(int athleteId, int programId) => + versions.Select(new ProgramVersionFilter + { + AthleteId = athleteId, + ProgramId = programId, + }).Select(v => v.Version).DefaultIfEmpty(0).Max(); + + private ProgramVersion GetOrThrow(int athleteId, Guid versionId) + { + var version = versions.Get(athleteId, versionId); + if (version == null) + throw new ProgramStoreException( + $"версия {versionId} не найдена у атлета {athleteId} (изоляция 🛡)"); + return version; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Programs/ProgressionConfig.cs b/3-app/2-cli/Server.WatchBody.Core/Programs/ProgressionConfig.cs new file mode 100644 index 0000000..dafc6e4 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Programs/ProgressionConfig.cs @@ -0,0 +1,52 @@ +using System.Text.Json; + +namespace Server.WatchBody.Core.Programs; + +/// +/// Пороги правил прогрессии/делоада/перерывов (секция Progression); +/// умолчания — requirements разд. 3 и Ф-14.1. 🛡 В расчётном коде +/// пороги не хардкодятся — только эти значения (Р3-А). +/// +public class ProgressionConfig +{ + public decimal BaseStepKg { get; set; } = 2.5m; + public decimal IsoStepKg { get; set; } = 1.0m; + public int BackoffPercent { get; set; } = 5; + public int DeloadMaxWeeks { get; set; } = 6; + public int MissDaysForBreak { get; set; } = 4; + public decimal ReturnMinFactor { get; set; } = 0.6m; + public decimal ReturnMaxFactor { get; set; } = 0.9m; + + /// Чтение секции Progression (отсутствует/частичная — умолчания). + public static ProgressionConfig Load(JsonElement root) + { + var config = new ProgressionConfig(); + if (!root.TryGetProperty("Progression", out var section) || section.ValueKind != JsonValueKind.Object) + return config; + foreach (var property in typeof(ProgressionConfig).GetProperties()) + { + var name = char.ToLowerInvariant(property.Name[0]) + property.Name[1..]; + if (!section.TryGetProperty(name, out var value)) continue; + if (value.ValueKind != JsonValueKind.Number || !value.TryGetDecimal(out var number)) + throw new InvalidOperationException($"Progression.{name}: ожидалось число"); + property.SetValue(config, Convert.ChangeType(number, property.PropertyType)); + } + return config; + } + + /// Ошибки конфигурации (фатальны при старте). + public List Validate() + { + var errors = new List(); + // IsoStepKg — коридор метрик «+0,5–1 кг» (О-3-А): 0.25–5 + if (IsoStepKg < 0.25m || IsoStepKg > 5m) errors.Add("Progression: isoStepKg вне [0.25; 5]"); + if (BaseStepKg < 0.5m || BaseStepKg > 10m) errors.Add("Progression: baseStepKg вне [0.5; 10]"); + if (BackoffPercent < 1 || BackoffPercent > 20) errors.Add("Progression: backoffPercent вне [1; 20]"); + if (DeloadMaxWeeks < 3 || DeloadMaxWeeks > 12) errors.Add("Progression: deloadMaxWeeks вне [3; 12]"); + if (MissDaysForBreak < 2) errors.Add("Progression: missDaysForBreak < 2"); + if (ReturnMinFactor < 0.3m || ReturnMinFactor > 0.9m) errors.Add("Progression: returnMinFactor вне [0.3; 0.9]"); + if (ReturnMaxFactor <= 0.5m || ReturnMaxFactor > 1m) errors.Add("Progression: returnMaxFactor вне (0.5; 1]"); + if (ReturnMinFactor > ReturnMaxFactor) errors.Add("Progression: returnMinFactor > returnMaxFactor"); + return errors; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Programs/ProgressionRules.cs b/3-app/2-cli/Server.WatchBody.Core/Programs/ProgressionRules.cs new file mode 100644 index 0000000..08f34c4 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Programs/ProgressionRules.cs @@ -0,0 +1,245 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; + +namespace Server.WatchBody.Core.Programs; + +/// +/// Детерминированные правила прогрессии (Ф-12.2) и делоада (Ф-12.4): +/// результат — предложение draft-версии (author=rules) через +/// ProgramStore; активация — только человеком (🛡 vision 2.4). +/// Ограничения атлета — через персональную библиотеку: is_active=false +/// не попадает в draft, пара-замена подставляется при наличии +/// (module_reqs разд. 2.4). 🛡 Активная версия не меняется; в факт +/// ничего не пишет. +/// +public class ProgressionRules( + IWorkoutRepository workouts, + IExerciseRepository exercises, + ProgramStore programs, + ProgressionConfig config) +{ + private static readonly string[] DayNames = + ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; + + /// Микроправка весов после тренировки (spec 02): правила 1–5 + /// по порядку, первое сработавшее; draft одна на активную версию. + public ProgramVersion? EvaluateAfterWorkout(int athleteId, Guid workoutId) + { + var active = programs.GetActive(athleteId); + if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null; + if (HasRulesDraft(athleteId, active.Version)) return null; + var workout = workouts.Get(athleteId, workoutId); + if (workout == null) return null; + + var byName = NameIndex(athleteId); + var history = workouts + .Select(new WorkoutFilter { AthleteId = athleteId }) + .OrderByDescending(w => w.StartedAt) + .ToList(); + + var body = JsonNode.Parse(active.Body) as JsonObject + ?? throw new InvalidOperationException("body активной версии не является объектом"); + var rationale = new List(); + + foreach (var week in Weeks(body)) + foreach (var session in Sessions(week)) + { + if (session["exercises"] is not JsonArray exerciseNodes) continue; + foreach (var exerciseNode in exerciseNodes.OfType().ToList()) + { + var planId = exerciseNode["exercise_id"]?.GetValue(); + if (planId == null || !byName.TryGetValue(planId, out var ex)) + { + rationale.Add($"{planId}: нет в библиотеке атлета — решение не генерируется"); + continue; + } + if (!ex.IsActive) + { + ApplyExclusion(exerciseNodes, exerciseNode, ex, byName, rationale); + continue; + } + var decision = Decide(workout, ex, history, Sets(exerciseNode)); + if (decision == null) continue; + ApplyDecision(exerciseNode, decision.Value, rationale); + } + // сессия только из исключённых упражнений → из плана целиком + if (session["exercises"] is JsonArray remaining && remaining.Count == 0 && + session.Parent is JsonArray sessionList) + sessionList.Remove(session); + } + + if (rationale.Count == 0) return null; + var draft = programs.CreateDraft(athleteId, + body.ToJsonString(), ProgramVersionAuthors.Rules, + "Микроправка после тренировки: " + string.Join("; ", rationale)); + return draft; + } + + /// Делоад (spec 03): DeloadMaxWeeks без делаода → draft + /// (веса ×0.6, подходы −1, session_kind=deload в week 1). + public ProgramVersion? CheckDeload(int athleteId, DateOnly today) + { + var active = programs.GetActive(athleteId); + if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null; + if (HasRulesDraft(athleteId, active.Version)) return null; + + var all = workouts.Select(new WorkoutFilter { AthleteId = athleteId }) + .OrderByDescending(w => w.StartedAt).ToList(); + if (all.Count == 0) return null; + // атлет давно не тренируется — это домен break, не делоад + if (today > DateOnly.FromDateTime(all[0].StartedAt).AddDays(config.DeloadMaxWeeks * 7)) + return null; + + var lastDeload = all.FirstOrDefault(w => w.SessionKind == SessionKinds.Deload); + var anchor = lastDeload != null + ? DateOnly.FromDateTime(lastDeload.StartedAt) + : DateOnly.FromDateTime(active.CreatedAt); + if (today < anchor.AddDays(config.DeloadMaxWeeks * 7)) return null; + + var body = JsonNode.Parse(active.Body) as JsonObject + ?? throw new InvalidOperationException("body активной версии не является объектом"); + var firstWeek = Weeks(body).FirstOrDefault(); + if (firstWeek == null) return null; + foreach (var session in Sessions(firstWeek)) + { + session["session_kind"] = SessionKinds.Deload; + if (session["exercises"] is not JsonArray exerciseNodes) continue; + foreach (var exerciseNode in exerciseNodes.OfType()) + foreach (var set in Sets(exerciseNode)) + if (WeightOf(set) is decimal weight) + set["weight_kg"] = Halves(weight * 0.6m); + TrimSets(session, remove: 1); + } + + return programs.CreateDraft(athleteId, body.ToJsonString(), + ProgramVersionAuthors.Rules, + $"Делоад: {config.DeloadMaxWeeks} недель без лёгкой недели — объём −40–50 %"); + } + + // ---------- микроправка: решение по одному упражнению ---------- + + private (decimal? newWeight, int? newReps, string why)? Decide( + Workout workout, Exercise ex, List history, List planSets) + { + if (planSets.Count == 0) return null; + var planWeight = WeightOf(planSets[0]); + var planReps = planSets[0]["reps"]?.GetValue(); + if (planWeight == null || planReps == null) return null; // калибровка + + var setsOf = (Workout w) => w.Sets + .Where(s => s.ExerciseId == ex.Id && s.Source != WorkoutSetSources.Edit) + .ToList(); + var current = setsOf(workout); + if (current.Count == 0) return null; + + var step = ex.IsBase ? config.BaseStepKg : config.IsoStepKg; + + // правило 1: 2 тренировки подряд все подходы «по плану» при RPE ≤ 7 + var withExercise = history.Where(w => setsOf(w).Count > 0).Take(2).ToList(); + if (withExercise.Count == 2 && withExercise.All(ClosedEasy)) + return (Halves(planWeight.Value + step), null, + $"+{step} (2 тренировки подряд закрыты при RPE≤7)"); + + // правило 2: RPE ≥ 9 в 2+ сетах + if (current.Count(s => s.Rpe >= 9) >= 2) + return (Halves(planWeight.Value * (1 - config.BackoffPercent / 100m)), null, + $"−{config.BackoffPercent}% (RPE≥9 в {current.Count(s => s.Rpe >= 9)} сетах)"); + + // правило 3: верхняя граница диапазона повторений (15–20) + if (current.All(s => s.InPlan) && planReps >= 15) + return (Halves(planWeight.Value + step), 12, + $"+{step}, повторения к 12 (верх диапазона достигнут)"); + + // правило 4: ≥ 2 подходов не дотянуты по повторениям + var shortfalls = current.Count(s => s.Reps < planReps); + if (shortfalls >= 2) + return (Halves(planWeight.Value * (1 - config.BackoffPercent / 100m)), null, + $"−{config.BackoffPercent}% ({shortfalls} подходов не дотянуты)"); + + return null; // правило 5: без изменения + } + + private static bool ClosedEasy(Workout w) => + w.Sets.Where(s => s.Source != WorkoutSetSources.Edit).All( + s => s.InPlan && s.Rpe != null && s.Rpe <= 7); + + private static void ApplyDecision( + JsonObject exerciseNode, (decimal? newWeight, int? newReps, string why) decision, + List rationale) + { + var name = exerciseNode["exercise_id"]?.GetValue(); + var from = WeightOf(Sets(exerciseNode).FirstOrDefault()); + foreach (var set in Sets(exerciseNode)) + { + if (decision.newWeight != null) + set["weight_kg"] = decision.newWeight; + if (decision.newReps != null) + set["reps"] = decision.newReps; + } + rationale.Add($"{name}: {from} → {decision.newWeight ?? from} ({decision.why})"); + } + + /// Ограничения (spec 02 §4): неактивное упражнение → замена + /// из пары (активная, та же группа) или удаление из сессии. + private void ApplyExclusion( + JsonArray siblings, JsonObject exerciseNode, Exercise ex, + Dictionary byName, List rationale) + { + var replacement = ex.ReplacementIds + .Select(id => byName.Values.FirstOrDefault(e => e.Id == id)) + .FirstOrDefault(r => r != null && r.IsActive); + if (replacement != null) + { + exerciseNode["exercise_id"] = replacement.Name; + rationale.Add($"{ex.Name}: исключено (is_active=false) → замена {replacement.Name}"); + return; + } + siblings.Remove(exerciseNode); + rationale.Add($"{ex.Name}: исключено (is_active=false), замены нет"); + } + + // ---------- делоад/общие помощники ---------- + + private static void TrimSets(JsonObject session, int remove) + { + if (session["exercises"] is not JsonArray exerciseNodes) return; + foreach (var exerciseNode in exerciseNodes.OfType()) + { + if (exerciseNode["sets"] is not JsonArray sets) continue; + while (sets.Count > 1 && remove > 0) { sets.RemoveAt(sets.Count - 1); remove--; } + } + } + + // материализуем: ApplyExclusion удаляет узлы во время обхода + private static List Weeks(JsonObject body) => + body["weeks"] is JsonArray weeks ? weeks.OfType().ToList() : []; + + private static List Sessions(JsonObject week) => + week?["sessions"] is JsonArray sessions ? sessions.OfType().ToList() : []; + + private static List Sets(JsonObject exerciseNode) => + exerciseNode["sets"] is JsonArray sets ? sets.OfType().ToList() : []; + + private static decimal? WeightOf(JsonObject? set) + { + if (set?["weight_kg"] is not JsonValue value) return null; + return value.TryGetValue(out decimal weight) ? weight : null; + } + + private static decimal Halves(decimal value) => Math.Round(value * 2m, MidpointRounding.AwayFromZero) / 2m; + + private Dictionary NameIndex(int athleteId) => + exercises.Select(new ExerciseFilter { AthleteId = athleteId }) + .GroupBy(e => e.Name, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + /// Rules-draft, основанный на активной версии или новее + /// (spec 02 §5.3) — пока он есть, новых предложений не создаём. + private bool HasRulesDraft(int athleteId, int activeVersion) => + programs.ListVersions(athleteId).Any(v => + v.Author == ProgramVersionAuthors.Rules && + v.Status == ProgramVersionStatuses.Draft && + (v.BasedOnVersion ?? 0) >= activeVersion); +} diff --git a/3-app/2-cli/Server.WatchBody.Core/Server.WatchBody.Core.csproj b/3-app/2-cli/Server.WatchBody.Core/Server.WatchBody.Core.csproj new file mode 100644 index 0000000..28d15af --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Core/Server.WatchBody.Core.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/IntegrationTests.cs b/3-app/2-cli/Server.WatchBody.Web.Tests/IntegrationTests.cs new file mode 100644 index 0000000..b84e2cd --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/IntegrationTests.cs @@ -0,0 +1,305 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace Server.WatchBody.Web.Tests; + +/// +/// Интеграционные тесты эндпоинтов (specs 02–05) против docker +/// watchbody-pg. Данные — фиксированные uuid (идемпотентность повторов). +/// +[Collection("web")] +public class IntegrationTests(TestApp app) +{ + private static Guid U(int n) => Guid.Parse($"a0000000-0000-4000-8000-{n:D12}"); + private const string Ivan = TestApp.IvanKey; + private const string Petr = TestApp.PetrKey; + private const string Anna = TestApp.AnnaKey; + + private static string Item(Guid guid, string entity, string data) => + $$"""{"client_guid":"{{guid}}","entity":"{{entity}}","op":"upsert","data":{{data}}}"""; + + private async Task Sync(string key, params string[] items) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") + { + Content = new StringContent("[" + string.Join(",", items) + "]", Encoding.UTF8, "application/json"), + }; + request.Headers.Add("X-Api-Key", key); + return await app.Client.SendAsync(request); + } + + // --- middleware (spec 02) --- + + [Fact] + public async Task Health_NoKey_Ok() + { + var response = await app.Client.GetAsync("/health"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Workouts_WithoutKey_401() + { + var response = await app.Client.GetAsync("/api/v1/workouts"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Workouts_UnknownKey_401() + { + var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/workouts"); + request.Headers.Add("X-Api-Key", "nobody-key"); + Assert.Equal(HttpStatusCode.Unauthorized, (await app.Client.SendAsync(request)).StatusCode); + } + + // --- sync (spec 03) --- + + [Fact] + public async Task Sync_AllEntities_Accepted() + { + var workout = U(1); + var response = await Sync(Ivan, + Item(workout, "workout", + $$"""{"workout_id":"{{workout}}","started_at":"2026-10-17T10:00:00Z","status":"finished","ended_at":"2026-10-17T11:00:00Z","session_kind":"normal","avg_hr":128}"""), + Item(U(2), "workout_set", + $$"""{"workout_id":"{{workout}}","exercise_id":null,"seq":1,"weight_kg":60,"reps":10,"done_at":"2026-10-17T10:05:00Z","source":"voice","in_plan":true,"rpe":7}"""), + Item(U(3), "exercise", + """{"exercise_id":"a0000000-0000-4000-8000-000000000003","name":"Test Жим лёжа","group":"push","is_base":true,"is_active":true}"""), + Item(U(4), "program_version", + """{"version":1,"based_on_version":null,"author":"user","rationale":"тест","weeks":[]}"""), + Item(U(5), "profile", """{"sex":"m","birth_year":1980,"weight_kg":85.5,"hr_max":null}"""), + Item(U(6), "daily_log", """{"date":"2026-10-17","bp_sys":128,"bp_dias":82,"weight_kg":85.5,"hr_resting":58,"wellbeing":4}"""), + Item(U(7), "break", """{"kind":"illness","source":"manual","started_on":"2026-10-01","planned_return":"2026-10-10","note":"тест"}""")); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var accepted = (await response.Content.ReadFromJsonAsync()).GetProperty("accepted"); + Assert.Equal(7, accepted.GetArrayLength()); + } + + [Fact] + public async Task Sync_RepeatPackage_SameAccepted_NoDuplicates() + { + var workout = U(10); + var package = new[] + { + Item(workout, "workout", $$"""{"workout_id":"{{workout}}","started_at":"2026-10-16T10:00:00Z"}"""), + Item(U(11), "daily_log", """{"date":"2026-10-16","wellbeing":3}"""), + }; + + var first = await Sync(Ivan, package); + var second = await Sync(Ivan, package); + + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + var firstAccepted = await first.Content.ReadFromJsonAsync(); + var secondAccepted = await second.Content.ReadFromJsonAsync(); + Assert.Equal(firstAccepted.GetProperty("accepted").ToString(), + secondAccepted.GetProperty("accepted").ToString()); + + // дублей нет: одна тренировка с этим started_at у Ивана (данные других тестов не в счёт) + var list = await ListWorkouts(Ivan); + var count = list.GetProperty("items").EnumerateArray() + .Count(w => w.GetProperty("workoutId").GetString() == workout.ToString()); + Assert.Equal(1, count); + } + + [Fact] + public async Task Sync_PackageOver50_400() + { + var items = Enumerable.Range(0, 51) + .Select(i => Item(U(100 + i), "daily_log", """{"date":"2026-10-15"}""")) + .ToArray(); + Assert.Equal(HttpStatusCode.BadRequest, (await Sync(Ivan, items)).StatusCode); + } + + [Fact] + public async Task Sync_ProgramVersionStaleBasedOn_409() + { + // первая версия Анны (based_on_version = null → создаётся v1, latest = 1) + await Sync(Anna, Item(U(120), "program_version", """{"version":1,"based_on_version":null,"author":"user","weeks":[]}""")); + // телефон «отстал»: based_on_version = 5 при latest = 1 + var response = await Sync(Anna, + Item(U(121), "program_version", """{"version":6,"based_on_version":5,"author":"user","weeks":[]}""")); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + [Fact] + public async Task Sync_SetForForeignWorkout_400_AndRolledBack() + { + // своя тренировка Ивана; пакет Петра ссылается на неё + var workout = U(140); + await Sync(Ivan, Item(workout, "workout", $$"""{"workout_id":"{{workout}}","started_at":"2026-10-13T10:00:00Z"}""")); + + var response = await Sync(Petr, + Item(U(130), "workout_set", + $$"""{"workout_id":"{{workout}}","exercise_id":null,"seq":1,"weight_kg":50,"reps":8,"done_at":"2026-10-13T10:05:00Z","source":"watch","in_plan":true}"""), + Item(U(131), "daily_log", """{"date":"2026-10-14","wellbeing":2}""")); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + // откат пакета: 401-канал не нужен — просто проверяем, что Петру + // доступ по-прежнему работает + Assert.Equal(HttpStatusCode.OK, (await GetWith(Petr, "/api/v1/workouts")).StatusCode); + } + + [Fact] + public async Task Sync_BadJson_400() + { + var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") + { + Content = new StringContent("{not json", Encoding.UTF8, "application/json"), + }; + request.Headers.Add("X-Api-Key", Ivan); + var response = await app.Client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + // --- чтение (spec 04) --- + + [Fact] + public async Task Workouts_Isolation_Pagination() + { + // 3 тренировки Анны (разные started_at — порядок предсказуем) + for (var i = 0; i < 3; i++) + { + var w = U(200 + i); + await Sync(Anna, Item(w, "workout", + $$"""{"workout_id":"{{w}}","started_at":"2026-10-0{{i + 1}}T09:00:00Z"}""")); + } + + var all = await ListWorkouts(Anna); + var annaItems = all.GetProperty("items").EnumerateArray() + .Where(w => w.GetProperty("workoutId").GetString()!.StartsWith("a0000000-0000-4000-8000-0000000002")).ToList(); + Assert.Equal(3, annaItems.Count); + + var limit = await ListWorkouts(Anna, "?limit=2&offset=1"); + Assert.Equal(2, limit.GetProperty("items").EnumerateArray() + .Count(w => w.GetProperty("workoutId").GetString()!.StartsWith("a0000000-0000-4000-8000-0000000002"))); + + // изоляция: Петр не видит тренировки Анны + var petr = await ListWorkouts(Petr); + Assert.DoesNotContain(petr.GetProperty("items").EnumerateArray(), + w => w.GetProperty("workoutId").GetString()!.StartsWith("a0000000-0000-4000-8000-0000000002")); + } + + [Fact] + public async Task Workouts_LimitOutOfRange_400() + { + Assert.Equal(HttpStatusCode.BadRequest, (await GetWith(Ivan, "/api/v1/workouts?limit=0")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await GetWith(Ivan, "/api/v1/workouts?limit=101")).StatusCode); + } + + [Fact] + public async Task WorkoutCard_Own_WithSets_Foreign_404() + { + var own = await GetWith(Ivan, $"/api/v1/workouts/{U(1)}"); + Assert.Equal(HttpStatusCode.OK, own.StatusCode); + var card = await own.Content.ReadFromJsonAsync(); + Assert.Equal(1, card.GetProperty("sets").GetArrayLength()); + + Assert.Equal(HttpStatusCode.NotFound, (await GetWith(Petr, $"/api/v1/workouts/{U(1)}")).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await GetWith(Ivan, "/api/v1/workouts/not-a-uuid")).StatusCode); + } + + // --- программы (spec 05) --- + + [Fact] + public async Task Programs_Active_Or404() + { + // Анна: новая версия через sync (создаётся draft), затем active — + // через репозиторий БД (в v1 active ставит сервер при переключении, m03). + // uuid случайный: program_version_insert не идемпотентен (version=max+1) + var versionId = Guid.NewGuid(); + await Sync(Anna, Item(versionId, "program_version", """{"version":9,"based_on_version":null,"author":"user","weeks":[]}""")); + var cs = new Kit.Helpers.SqlHelperAbstract.ConnectionString + { + Type = Kit.Helpers.SqlHelperAbstract.ConnectionStringType.Postgres, + Value = app.Config.DbConnectionString, + }; + new Server.WatchBody.Core.Dal.Src.Repository.ProgramVersionRepository(cs) + .UpdateStatus(FindAthleteId(Anna), versionId, "active"); + + var response = await GetWith(Anna, "/api/v1/programs/active"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var program = await response.Content.ReadFromJsonAsync(); + Assert.Equal("active", program.GetProperty("status").GetString()); + Assert.True(program.TryGetProperty("body", out _)); + + // у Петра программы нет → 404 + Assert.Equal(HttpStatusCode.NotFound, (await GetWith(Petr, "/api/v1/programs/active")).StatusCode); + } + + private async Task GetWith(string key, string path) + { + var request = new HttpRequestMessage(HttpMethod.Get, path); + request.Headers.Add("X-Api-Key", key); + return await app.Client.SendAsync(request); + } + + // --- З-41(а): is_active=false в sync — логическое удаление (Ф-4.2) --- + + [Fact] + public async Task Sync_ExerciseInactive_LogicalDelete() + { + var key = "del-ex-" + Guid.NewGuid().ToString("N")[..12]; + var cs = new Kit.Helpers.SqlHelperAbstract.ConnectionString + { + Type = Kit.Helpers.SqlHelperAbstract.ConnectionStringType.Postgres, + Value = app.Config.DbConnectionString, + }; + new Server.WatchBody.Core.Dal.Src.Repository.AthleteRepository(cs).Insert( + new Server.WatchBody.Core.Dal.Src.Domain.Athlete + { + Name = "Del exercise", + ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(key), + }); + var exGuid = Guid.NewGuid(); + + var first = await Sync(key, Item(exGuid, "exercise", + """{"name":"Test Присед","group":"legs","is_base":true,"is_active":true}""")); + Assert.True(first.IsSuccessStatusCode, + $"sync 1: {(int)first.StatusCode} {await first.Content.ReadAsStringAsync()}"); + var accepted1 = await first.Content.ReadFromJsonAsync(); + Assert.Equal(exGuid, accepted1.GetProperty("accepted")[0].GetGuid()); + + // повтор того же упражнения с is_active=false → удаление, GUID в accepted + var second = await Sync(key, Item(exGuid, "exercise", + """{"name":"Test Присед","group":"legs","is_base":true,"is_active":false}""")); + Assert.True(second.IsSuccessStatusCode, + $"sync 2: {(int)second.StatusCode} {await second.Content.ReadAsStringAsync()}"); + var accepted2 = await second.Content.ReadFromJsonAsync(); + Assert.Equal(exGuid, accepted2.GetProperty("accepted")[0].GetGuid()); + + var row = new Server.WatchBody.Core.Dal.Src.Repository.ExerciseRepository(cs) + .Select(new Server.WatchBody.Core.Dal.Src.Repository.ExerciseFilter + { + AthleteId = FindAthleteId(key), Name = "Test Присед", + }).Single(); + Assert.False(row.IsActive); + + // идемпотентность: повтор false — 200, состояние не меняется + var third = await Sync(key, Item(exGuid, "exercise", + """{"name":"Test Присед","group":"legs","is_base":true,"is_active":false}""")); + Assert.True(third.IsSuccessStatusCode); + } + + private async Task ListWorkouts(string key, string query = "") => + await (await GetWith(key, "/api/v1/workouts" + query)).Content.ReadFromJsonAsync(); + + private int FindAthleteId(string apiKey) + { + var cs = new Kit.Helpers.SqlHelperAbstract.ConnectionString + { + Type = Kit.Helpers.SqlHelperAbstract.ConnectionStringType.Postgres, + Value = app.Config.DbConnectionString, + }; + var athlete = new Server.WatchBody.Core.Dal.Src.Repository.AthleteRepository(cs) + .Select(new Server.WatchBody.Core.Dal.Src.Repository.AthleteFilter + { + ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(apiKey), + }).First(); + return athlete.Id; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/MetricsEngineTests.cs b/3-app/2-cli/Server.WatchBody.Web.Tests/MetricsEngineTests.cs new file mode 100644 index 0000000..812635f --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/MetricsEngineTests.cs @@ -0,0 +1,385 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Ref.Repository; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Core.Metrics; +using Server.WatchBody.Core.Programs; +using Xunit; + +namespace Server.WatchBody.Web.Tests; + +/// +/// Интеграционные тесты MetricsEngine (specs m04 01–04) против docker +/// watchbody-pg. Каждый тест — свой атлет (случайный ключ). Тренировки — +/// среда 2026-10-14 (план в активной версии привязан к «wednesday»). +/// +[Collection("web")] +public class MetricsEngineTests(TestApp app) +{ + private static readonly DateTime Wednesday = new(2026, 10, 14, 18, 0, 0, DateTimeKind.Utc); + private static readonly DateOnly Wed = new(2026, 10, 14); + + private (MetricsEngine Engine, ProgramStore Store, int AthleteId) NewEngine( + MetricsConfig? config = null) + { + var cs = NewCs(); + var athleteId = new AthleteRepository(cs).Insert(new Athlete + { + Name = "Metrics test", + ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), + }); + var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); + var engine = new MetricsEngine( + new WorkoutRepository(cs), new ExerciseRepository(cs), + new ExerciseGroupRepository(cs), new DailyLogRepository(cs), + new WorkoutMetricsRepository(cs), store, config ?? new MetricsConfig()); + return (engine, store, athleteId); + } + + private ConnectionString NewCs() => new() + { + Type = ConnectionStringType.Postgres, + Value = app.Config.DbConnectionString, + }; + + private IWorkoutMetricsRepository MetricsRepo() => new WorkoutMetricsRepository(NewCs()); + + private Guid NewExercise(int athleteId, int groupId = 1) + { + var id = Guid.NewGuid(); + new ExerciseRepository(NewCs()).Insert(new Exercise + { + Id = id, AthleteId = athleteId, Name = $"Присед {id:N}", + GroupId = groupId, IsBase = true, IsActive = true, ReplacementIds = [], + }); + return id; + } + + private Guid NewWorkout(int athleteId, DateTime? startedAt = null) => + new WorkoutRepository(NewCs()).Insert(athleteId, new Workout + { + Id = Guid.NewGuid(), + AthleteId = athleteId, + StartedAt = startedAt ?? Wednesday, + Status = "finished", + SessionKind = "normal", + Sets = [], + Commands = [], + }); + + private void NewSet(int athleteId, Guid workoutId, Guid exerciseId, + decimal weight, int reps, bool inPlan = true, int? rpe = null, + int? kneePain = null, DateTime? doneAt = null) + { + new WorkoutSetRepository(NewCs()).Insert(new WorkoutSet + { + Id = Guid.NewGuid(), WorkoutId = workoutId, AthleteId = athleteId, + ExerciseId = exerciseId, Seq = 1, WeightKg = weight, Reps = reps, + DoneAt = doneAt ?? Wednesday, Source = "voice", InPlan = inPlan, + Rpe = rpe, KneePain = kneePain, + }); + } + + private void NewLog(int athleteId, DateOnly date, + int? bpSys = null, int? bpDias = null, int? hrResting = null, int? wellbeing = null) + { + new DailyLogRepository(NewCs()).Upsert(athleteId, new DailyLog + { + Id = Guid.NewGuid(), AthleteId = athleteId, Date = date, + BpSys = bpSys, BpDias = bpDias, HrResting = hrResting, Wellbeing = wellbeing, + }); + } + + private static JsonElement Flags(WorkoutMetrics m) => + JsonDocument.Parse(m.RedFlags).RootElement.Clone(); + + private static string FlagNames(JsonElement flags) => + string.Join(",", flags.EnumerateArray().Select(f => f.GetProperty("flag").GetString())); + + // --- основные метрики (приёмка 1) --- + + [Fact] + public void Compute_BasicMetrics() + { + var (engine, store, athlete) = NewEngine(); + var ex = NewExercise(athlete, groupId: 1); // push + store.CreateDraft(athlete, """ + {"weeks":[{"week":1,"sessions":[{"day":"wednesday","focus":"push", + "exercises":[{"exercise_id":"bench_press","sets":[ + {"weight_kg":80,"reps":8},{"weight_kg":80,"reps":8},{"weight_kg":80,"reps":8}]}]}]}]} + """, "user", "тест"); + store.Activate(athlete, store.ListVersions(athlete)[0].Id); + var workout = NewWorkout(athlete); + NewSet(athlete, workout, ex, 100, 6, inPlan: true, rpe: 7); + NewSet(athlete, workout, ex, 60, 10, inPlan: false, rpe: 5); + + var m = engine.ComputeForWorkout(athlete, workout); + + Assert.Equal(33.3m, m.Adherence); // 1 из 3 плановых + Assert.Equal(1200m, m.Tonnage); // 100×6 + 60×10 + Assert.Equal(1200m, JsonDocument.Parse(m.TonnageByGroup) + .RootElement.GetProperty("push").GetDecimal()); + Assert.Equal(120.0m, JsonDocument.Parse(m.E1pmByExercise) + .RootElement.GetProperty($"{ex}").GetDecimal()); + } + + [Fact] + public void Compute_HrMetrics_FallbackToWorkoutFields() + { + var (engine, _, athlete) = NewEngine(); + var workoutId = NewWorkout(athlete); + new WorkoutRepository(NewCs()).Update(athlete, new Workout + { + Id = workoutId, AthleteId = athlete, StartedAt = Wednesday, + Status = "finished", SessionKind = "normal", AvgHr = 110, + HrRecoveryS = 45, Sets = [], Commands = [], + }); + + var m = engine.ComputeForWorkout(athlete, workoutId); + + var hr = JsonDocument.Parse(m.HrMetrics).RootElement; + Assert.Equal(110m, hr.GetProperty("avg_hr").GetDecimal()); + Assert.Equal(45m, hr.GetProperty("hr_recovery_s").GetDecimal()); + } + + [Fact] + public void Compute_NoActivePlan_FallbackAdherence() + { + var (engine, _, athlete) = NewEngine(); + var ex = NewExercise(athlete); + var workoutId = NewWorkout(athlete); + NewSet(athlete, workoutId, ex, 50, 10, inPlan: true); + NewSet(athlete, workoutId, ex, 50, 8, inPlan: false); + + var m = engine.ComputeForWorkout(athlete, workoutId); + + Assert.Equal(50.0m, m.Adherence); // 1 из 2 фактических + } + + // --- идемпотентность/пересчёт (приёмка 2) --- + + [Fact] + public void Compute_Twice_SingleRow() + { + var (engine, _, athlete) = NewEngine(); + var ex = NewExercise(athlete); + var workoutId = NewWorkout(athlete); + NewSet(athlete, workoutId, ex, 50, 10); + + engine.ComputeForWorkout(athlete, workoutId); + engine.ComputeForWorkout(athlete, workoutId); + + var rows = MetricsRepo().Select(new WorkoutMetricsFilter + { AthleteId = athlete, WorkoutId = workoutId }).ToList(); + Assert.Single(rows); + } + + // --- красные флаги (приёмка 3, spec 03) --- + + [Fact] + public void RedFlags_BpOver150_90() + { + var (engine, _, athlete) = NewEngine(); + NewLog(athlete, Wed, bpSys: 160, bpDias: 95); + var workoutId = NewWorkout(athlete); + + var flags = Flags(engine.ComputeForWorkout(athlete, workoutId)); + + Assert.Contains("bp_over_150_90", FlagNames(flags)); + Assert.Equal(Wed, DateOnly.FromDateTime( + flags[0].GetProperty("date").GetDateTime())); + } + + [Fact] + public void RedFlags_BpElevated() + { + var (engine, _, athlete) = NewEngine(); + NewLog(athlete, Wed, bpSys: 145, bpDias: 89); // в коридоре, не >150/90 + var workoutId = NewWorkout(athlete); + + Assert.Contains("bp_elevated", + FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId)))); + } + + [Fact] + public void RedFlags_HrRestingUp_TwoDays() + { + var (engine, _, athlete) = NewEngine(); + // базовый: 3 значения по 60 в окне до серии + NewLog(athlete, new DateOnly(2026, 10, 1), hrResting: 60); + NewLog(athlete, new DateOnly(2026, 10, 2), hrResting: 60); + NewLog(athlete, new DateOnly(2026, 10, 3), hrResting: 60); + // серия: 10-13 и 10-14 по 70 ≥ 60+8 (базовый для старта 10-13 — + // окно до него, значения 10-13/14 в него не входят) + NewLog(athlete, new DateOnly(2026, 10, 13), hrResting: 70); + NewLog(athlete, Wed, hrResting: 70); + var workoutId = NewWorkout(athlete); + + var flags = Flags(engine.ComputeForWorkout(athlete, workoutId)); + + var hr = flags.EnumerateArray() + .Single(f => f.GetProperty("flag").GetString() == "hr_resting_up"); + Assert.Equal(2, hr.GetProperty("dates").GetArrayLength()); + } + + [Fact] + public void RedFlags_HrResting_NoBaseline_NoFlag() + { + var (engine, _, athlete) = NewEngine(); + NewLog(athlete, new DateOnly(2026, 10, 13), hrResting: 70); // всего 1 значение + NewLog(athlete, Wed, hrResting: 70); + var workoutId = NewWorkout(athlete); + + Assert.DoesNotContain("hr_resting_up", + FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId)))); + } + + [Fact] + public void RedFlags_WellbeingLow_ThreeDays_Cancel() + { + var (engine, _, athlete) = NewEngine(); + NewLog(athlete, new DateOnly(2026, 10, 12), wellbeing: 2); + NewLog(athlete, new DateOnly(2026, 10, 13), wellbeing: 2); + NewLog(athlete, Wed, wellbeing: 2); + var workoutId = NewWorkout(athlete); + + var names = FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId))); + + Assert.Contains("wellbeing_low", names); + Assert.Contains("cancel_recommendation", names); + } + + [Fact] + public void RedFlags_KneePain_TwoSessions() + { + var (engine, _, athlete) = NewEngine(); + var legs = NewExercise(athlete, groupId: 3); + var prev = NewWorkout(athlete, startedAt: new DateTime(2026, 10, 7, 18, 0, 0, DateTimeKind.Utc)); + NewSet(athlete, prev, legs, 60, 10, kneePain: 1); + var workoutId = NewWorkout(athlete); + NewSet(athlete, workoutId, legs, 60, 10, kneePain: 2); + + Assert.Contains("knee_pain_repeat", + FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId)))); + } + + [Fact] + public void RedFlags_KneePain_FirstSession_NoFlag() + { + var (engine, _, athlete) = NewEngine(); + var legs = NewExercise(athlete, groupId: 3); + var workoutId = NewWorkout(athlete); + NewSet(athlete, workoutId, legs, 60, 10, kneePain: 2); + + Assert.DoesNotContain("knee_pain_repeat", + FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId)))); + } + + [Fact] + public void RedFlags_CleanData_Empty() + { + var (engine, _, athlete) = NewEngine(); + NewLog(athlete, Wed, bpSys: 120, bpDias: 80, hrResting: 60, wellbeing: 4); + var workoutId = NewWorkout(athlete); + + Assert.Equal("[]", + engine.ComputeForWorkout(athlete, workoutId).RedFlags.Replace(" ", "")); + } + + // --- изоляция (приёмка 4) и устойчивость (приёмка 5) --- + + [Fact] + public void Isolation_ForeignLogsInvisible() + { + var (_, _, other) = NewEngine(); + NewLog(other, Wed, bpSys: 170, bpDias: 100); // чужой атлет — высокое АД + var (engine, _, athlete) = NewEngine(); + var workoutId = NewWorkout(athlete); + + Assert.DoesNotContain("bp_over_150_90", + FlagNames(Flags(engine.ComputeForWorkout(athlete, workoutId)))); + } + + [Fact] + public void Compute_BrokenActivePlan_Fallback() + { + var (engine, store, athlete) = NewEngine(); + store.CreateDraft(athlete, "\"oops\"", "user", "не программа"); + store.Activate(athlete, store.ListVersions(athlete)[0].Id); + var ex = NewExercise(athlete); + var workoutId = NewWorkout(athlete); + NewSet(athlete, workoutId, ex, 50, 10, inPlan: true); + NewSet(athlete, workoutId, ex, 50, 8, inPlan: false); + + var m = engine.ComputeForWorkout(athlete, workoutId); + + Assert.Equal(50.0m, m.Adherence); // план не парсится → доля in_plan + } + + [Fact] + public void Compute_ForeignWorkout_Throws() + { + var (_, _, other) = NewEngine(); + var foreignWorkout = NewWorkout(other); + var (engine, _, athlete) = NewEngine(); + + Assert.Throws( + () => engine.ComputeForWorkout(athlete, foreignWorkout)); + } + + // --- wiring в sync (spec 04): тренировка из пакета получает метрики --- + + [Fact] + public async Task Sync_WorkoutPackage_ComputesMetrics() + { + var cs = NewCs(); + var key = "metrics-sync-" + Guid.NewGuid().ToString("N")[..12]; + var athleteId = new AthleteRepository(cs).Insert(new Athlete + { + Name = "Metrics sync", + ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(key), + }); + var ex = NewExercise(athleteId); + var workoutGuid = Guid.NewGuid(); + var setGuid = Guid.NewGuid(); + var logGuid = Guid.NewGuid(); + + var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") + { + Content = new StringContent($$""" + [ + {"client_guid":"{{workoutGuid}}","entity":"workout","op":"upsert", + "data":{"started_at":"2026-10-14T18:00:00Z","ended_at":"2026-10-14T19:00:00Z", + "status":"finished","avg_hr":105} }, + {"client_guid":"{{setGuid}}","entity":"workout_set","op":"upsert", + "data":{"workout_id":"{{workoutGuid}}","exercise_id":"{{ex}}","seq":1, + "weight_kg":80,"reps":8,"done_at":"2026-10-14T18:05:00Z", + "source":"voice","in_plan":true,"rpe":7} }, + {"client_guid":"{{logGuid}}","entity":"daily_log","op":"upsert", + "data":{"date":"2026-10-14","bp_sys":120,"bp_dias":80, + "hr_resting":60,"wellbeing":4} } + ] + """, Encoding.UTF8, "application/json"), + }; + request.Headers.Add("X-Api-Key", key); + var response = await app.Client.SendAsync(request); + Assert.True(response.IsSuccessStatusCode, + $"sync: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); + + var rows = MetricsRepo().Select(new WorkoutMetricsFilter + { AthleteId = athleteId, WorkoutId = workoutGuid }).ToList(); + var row = Assert.Single(rows); + Assert.Equal(100.0m, row.Adherence); // единственный подход — по плану + Assert.Equal(640.0m, row.Tonnage); + } + + [Fact] + public void MetricsConfig_Validate_RejectsBadCorridor() + { + var config = new MetricsConfig { BpElevatedSys = 160, BpCancelSys = 150 }; + Assert.Contains(config.Validate(), e => e.Contains("bpElevatedSys")); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/ProgramStoreTests.cs b/3-app/2-cli/Server.WatchBody.Web.Tests/ProgramStoreTests.cs new file mode 100644 index 0000000..6880805 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/ProgramStoreTests.cs @@ -0,0 +1,235 @@ +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Core.Programs; +using Xunit; + +namespace Server.WatchBody.Web.Tests; + +/// +/// Интеграционные тесты ProgramStore (specs m03 01-lifecycle, +/// 02-reading) против docker watchbody-pg. Каждый тест — свой атлет +/// (случайный ключ), fixture-данные не пересекаются с m02. +/// +[Collection("web")] +public class ProgramStoreTests(TestApp app) +{ + private (ProgramStore Store, int AthleteId) NewStore() + { + var cs = new ConnectionString + { + Type = ConnectionStringType.Postgres, + Value = app.Config.DbConnectionString, + }; + var athleteId = new AthleteRepository(cs).Insert(new Athlete + { + Name = "ProgramStore test", + ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), + }); + var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); + return (store, athleteId); + } + + // --- EnsureProgram (spec 01 #1–2) --- + + [Fact] + public void EnsureProgram_CreatesOnce() + { + var (store, athlete) = NewStore(); + var first = store.EnsureProgram(athlete, "Программа v1"); + var second = store.EnsureProgram(athlete, "Другое название"); + + Assert.Equal(first.Id, second.Id); + Assert.Equal("Программа v1", second.Title); // title не перезаписывается + } + + // --- CreateDraft (spec 01 #3–9) --- + + [Fact] + public void CreateDraft_FirstVersion_NoBasedOn() + { + var (store, athlete) = NewStore(); + var v1 = store.CreateDraft(athlete, """{"weeks":[]}""", "user", "первая"); + + Assert.Equal(1, v1.Version); + Assert.Equal("draft", v1.Status); + Assert.Null(v1.BasedOnVersion); + } + + [Fact] + public void CreateDraft_Second_BasedOnLatest() + { + var (store, athlete) = NewStore(); + store.CreateDraft(athlete, """{"weeks":[]}""", "user", "первая"); + var v2 = store.CreateDraft(athlete, """{"weeks":[1]}""", "user", "вторая"); + + Assert.Equal(2, v2.Version); + Assert.Equal(1, v2.BasedOnVersion); + } + + [Fact] + public void CreateDraft_AuthorLlm_Rejected() + { + var (store, athlete) = NewStore(); + Assert.Throws( + () => store.CreateDraft(athlete, "{}", "llm", "")); + } + + [Fact] + public void CreateDraft_AuthorRules_Accepted() + { + var (store, athlete) = NewStore(); + var v = store.CreateDraft(athlete, "{}", "rules", "правила"); + Assert.Equal("rules", v.Author); + } + + [Fact] + public void CreateDraft_BrokenChain_Rejected() + { + var (store, athlete) = NewStore(); + Assert.Throws( + () => store.CreateDraft(athlete, "{}", "user", "", basedOnVersion: 5)); + } + + [Fact] + public void CreateDraft_EmptyBody_Rejected() + { + var (store, athlete) = NewStore(); + Assert.Throws(() => store.CreateDraft(athlete, " ", "user", "")); + } + + [Fact] + public void CreateDraft_TwoDrafts_VersionsGrow() + { + var (store, athlete) = NewStore(); + var a = store.CreateDraft(athlete, "{}", "user", "a"); + var b = store.CreateDraft(athlete, "{}", "user", "b"); + Assert.Equal(1, a.Version); + Assert.Equal(2, b.Version); + Assert.All(store.ListVersions(athlete), v => Assert.Equal("draft", v.Status)); + } + + // --- Activate / Archive (spec 01 #10–16) --- + + [Fact] + public void Activate_ShiftsPreviousToArchived() + { + var (store, athlete) = NewStore(); + var v1 = store.CreateDraft(athlete, "{}", "user", "v1"); + var v2 = store.CreateDraft(athlete, "{}", "user", "v2"); + + store.Activate(athlete, v1.Id); + Assert.Equal(v1.Id, store.GetActive(athlete)!.Id); + + store.Activate(athlete, v2.Id); + var active = store.GetActive(athlete)!; + Assert.Equal(v2.Id, active.Id); + Assert.Equal("archived", store.GetVersion(athlete, v1.Id)!.Status); + } + + [Fact] + public void Activate_FirstVersion_SingleActive() + { + var (store, athlete) = NewStore(); + var v1 = store.CreateDraft(athlete, "{}", "user", "v1"); + store.Activate(athlete, v1.Id); + Assert.Equal("active", store.GetActive(athlete)!.Status); + } + + [Fact] + public void Activate_Archived_Rejected() + { + var (store, athlete) = NewStore(); + var v1 = store.CreateDraft(athlete, "{}", "user", "v1"); + var v2 = store.CreateDraft(athlete, "{}", "user", "v2"); + store.Activate(athlete, v1.Id); + store.Activate(athlete, v2.Id); + + Assert.Throws(() => store.Activate(athlete, v1.Id)); + } + + [Fact] + public void Activate_AlreadyActive_Rejected() + { + var (store, athlete) = NewStore(); + var v1 = store.CreateDraft(athlete, "{}", "user", "v1"); + store.Activate(athlete, v1.Id); + Assert.Throws(() => store.Activate(athlete, v1.Id)); + } + + [Fact] + public void Activate_ForeignVersion_Rejected() + { + var (store, athlete) = NewStore(); + var (otherStore, otherAthlete) = NewStore(); + var foreign = otherStore.CreateDraft(otherAthlete, "{}", "user", "чужая"); + Assert.Throws(() => store.Activate(athlete, foreign.Id)); + } + + [Fact] + public void Archive_Active_ThenNoActive() + { + var (store, athlete) = NewStore(); + var v1 = store.CreateDraft(athlete, "{}", "user", "v1"); + store.Activate(athlete, v1.Id); + store.Archive(athlete, v1.Id); + + Assert.Null(store.GetActive(athlete)); + Assert.Throws(() => store.Archive(athlete, v1.Id)); // повтор + } + + [Fact] + public void Archive_Draft_Rejected() + { + var (store, athlete) = NewStore(); + var v1 = store.CreateDraft(athlete, "{}", "user", "v1"); + Assert.Throws(() => store.Archive(athlete, v1.Id)); + } + + // --- чтение (spec 02 #1–7) --- + + [Fact] + public void GetActive_NoProgram_Null() + { + var (store, athlete) = NewStore(); + Assert.Null(store.GetActive(athlete)); + } + + [Fact] + public void GetActive_AllArchived_Null() + { + var (store, athlete) = NewStore(); + var v1 = store.CreateDraft(athlete, """{"x":1}""", "user", "v1"); + store.Activate(athlete, v1.Id); + store.Archive(athlete, v1.Id); + Assert.Null(store.GetActive(athlete)); + } + + [Fact] + public void ListVersions_Ordered_NoBody_Isolated() + { + var (store, athlete) = NewStore(); + var (otherStore, otherAthlete) = NewStore(); + for (var i = 0; i < 3; i++) + store.CreateDraft(athlete, "{\"n\":" + i + "}", "user", $"v{i + 1}"); + otherStore.CreateDraft(otherAthlete, "{}", "user", "чужая"); + + var list = store.ListVersions(athlete); + Assert.Equal([1, 2, 3], list.Select(v => v.Version)); + Assert.All(list, v => Assert.Equal(string.Empty, v.Body)); + } + + [Fact] + public void GetVersion_Own_WithBody_Foreign_Null() + { + var (store, athlete) = NewStore(); + var (otherStore, otherAthlete) = NewStore(); + var v = store.CreateDraft(athlete, """{"weeks":[2]}""", "user", "v1"); + var foreign = otherStore.CreateDraft(otherAthlete, "{}", "user", "чужая"); + + Assert.True(System.Text.Json.JsonElement.DeepEquals( + System.Text.Json.JsonDocument.Parse("""{"weeks":[2]}""").RootElement, + System.Text.Json.JsonDocument.Parse(store.GetVersion(athlete, v.Id)!.Body).RootElement)); + Assert.Null(store.GetVersion(athlete, foreign.Id)); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/ProgressionRulesTests.cs b/3-app/2-cli/Server.WatchBody.Web.Tests/ProgressionRulesTests.cs new file mode 100644 index 0000000..7879c2f --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/ProgressionRulesTests.cs @@ -0,0 +1,509 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Ref.Repository; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Core.Programs; +using Xunit; + +namespace Server.WatchBody.Web.Tests; + +/// +/// Интеграционные тесты ProgressionRules/BreakEngine (specs m05 01–05) +/// против docker watchbody-pg. Каждый тест — свой атлет. Сессия плана — +/// «monday»; упражнения библиотеки именованы как exercise_id в body. +/// +[Collection("web")] +public class ProgressionRulesTests(TestApp app) +{ + private static readonly DateOnly Monday = new(2026, 10, 12); + private static readonly DateOnly NextMonday = new(2026, 10, 19); + + private (ProgressionRules Rules, BreakEngine Breaks, ProgramStore Store, int AthleteId) NewRules( + ProgressionConfig? config = null) + { + var cs = NewCs(); + var athleteId = new AthleteRepository(cs).Insert(new Athlete + { + Name = "Rules test", + ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(Guid.NewGuid().ToString()), + }); + var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); + var cfg = config ?? new ProgressionConfig(); + var rules = new ProgressionRules( + new WorkoutRepository(cs), new ExerciseRepository(cs), store, cfg); + var breaks = new BreakEngine( + new WorkoutRepository(cs), new BreakRepository(cs), store, cfg); + return (rules, breaks, store, athleteId); + } + + private ConnectionString NewCs() => new() + { + Type = ConnectionStringType.Postgres, + Value = app.Config.DbConnectionString, + }; + + private Guid NewExercise(int athleteId, string name, int groupId = 1, + bool isBase = true, bool isActive = true) + { + var id = Guid.NewGuid(); + new ExerciseRepository(NewCs()).Insert(new Exercise + { + Id = id, AthleteId = athleteId, Name = name, + GroupId = groupId, IsBase = isBase, IsActive = isActive, ReplacementIds = [], + }); + return id; + } + + private Guid NewWorkout(int athleteId, DateTime startedAt, + string sessionKind = "normal") + { + var id = Guid.NewGuid(); + new WorkoutRepository(NewCs()).Insert(athleteId, new Workout + { + Id = id, AthleteId = athleteId, StartedAt = startedAt, + Status = "finished", SessionKind = sessionKind, Sets = [], Commands = [], + }); + return id; + } + + private void NewSet(int athleteId, Guid workoutId, Guid exerciseId, + decimal weight, int reps, bool inPlan = true, int? rpe = null, + DateTime? doneAt = null) + { + new WorkoutSetRepository(NewCs()).Insert(new WorkoutSet + { + Id = Guid.NewGuid(), WorkoutId = workoutId, AthleteId = athleteId, + ExerciseId = exerciseId, Seq = 1, WeightKg = weight, Reps = reps, + DoneAt = doneAt ?? DateTime.UtcNow, Source = "voice", InPlan = inPlan, + Rpe = rpe, KneePain = null, + }); + } + + /// Активная версия: понедельник, одно упражнение bench_press, + /// 3 подхода planWeight×planReps. + private Guid ActivatePlan(int athleteId, ProgramStore store, + decimal planWeight = 100, int planReps = 8, string exerciseId = "bench_press") + { + var weight = planWeight.ToString(System.Globalization.CultureInfo.InvariantCulture); + store.CreateDraft(athleteId, $$""" + {"weeks":[{"week":1,"sessions":[{"day":"monday","focus":"push", + "exercises":[{"exercise_id":"{{exerciseId}}","sets":[ + {"weight_kg":{{weight}},"reps":{{planReps}},"target_rpe":7}, + {"weight_kg":{{weight}},"reps":{{planReps}},"target_rpe":7}, + {"weight_kg":{{weight}},"reps":{{planReps}},"target_rpe":7}]}]}]}]} + """, "user", "тест"); + var version = store.ListVersions(athleteId)[0]; + store.Activate(athleteId, version.Id); + return version.Id; + } + + private static JsonNode DraftBody(ProgramStore store, int athleteId, ProgramVersion draft) + { + Assert.Equal("rules", draft.Author); + Assert.Equal("draft", draft.Status); + return JsonNode.Parse(store.GetVersion(athleteId, draft.Id)!.Body)!; + } + + // --- микроправка: правила 1–5 (приёмка 1, spec 02 §3) --- + + [Fact] + public void Progress_Rule1_TwoClosedEasyWorkouts_StepUp() + { + var (rules, _, store, athlete) = NewRules(); + var ex = NewExercise(athlete, "bench_press"); + ActivatePlan(athlete, store); + var w1 = NewWorkout(athlete, Monday.AddMonths(-1).ToDateTimeAt(18)); + var w2 = NewWorkout(athlete, NextMonday.AddMonths(-1).ToDateTimeAt(18)); + foreach (var w in new[] { w1, w2 }) + for (var i = 0; i < 3; i++) + NewSet(athlete, w, ex, 100, 8, inPlan: true, rpe: 7); + + var draft = rules.EvaluateAfterWorkout(athlete, w2); + + Assert.NotNull(draft); + var body = DraftBody(store, athlete, draft!); + Assert.Equal(102.5m, body["weeks"]![0]!["sessions"]![0]!["exercises"]![0]! + ["sets"]![0]!["weight_kg"]!.GetValue()); + Assert.Contains("2 тренировки подряд", draft!.Rationale); + } + + [Fact] + public void Progress_Rule1_Isolation_IsoStep() + { + var (rules, _, store, athlete) = NewRules(config: new ProgressionConfig + { BaseStepKg = 2.5m, IsoStepKg = 1.0m }); + var ex = NewExercise(athlete, "bench_press", isBase: false); + ActivatePlan(athlete, store); + var w1 = NewWorkout(athlete, Monday.AddMonths(-1).ToDateTimeAt(18)); + var w2 = NewWorkout(athlete, NextMonday.AddMonths(-1).ToDateTimeAt(18)); + foreach (var w in new[] { w1, w2 }) + for (var i = 0; i < 3; i++) + NewSet(athlete, w, ex, 100, 8, rpe: 6); + + var draft = rules.EvaluateAfterWorkout(athlete, w2); + + var body = DraftBody(store, athlete, draft!); + Assert.Equal(101.0m, body["weeks"]![0]!["sessions"]![0]!["exercises"]![0]! + ["sets"]![0]!["weight_kg"]!.GetValue()); + } + + [Fact] + public void Progress_Rule2_HighRpe_Backoff() + { + var (rules, _, store, athlete) = NewRules(); + var ex = NewExercise(athlete, "bench_press"); + ActivatePlan(athlete, store); + var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + for (var i = 0; i < 3; i++) + NewSet(athlete, w, ex, 100, 8, rpe: i < 2 ? 9 : 6); + + var draft = rules.EvaluateAfterWorkout(athlete, w); + + var body = DraftBody(store, athlete, draft!); + Assert.Equal(95.0m, body["weeks"]![0]!["sessions"]![0]!["exercises"]![0]! + ["sets"]![0]!["weight_kg"]!.GetValue()); + } + + [Fact] + public void Progress_Rule3_RepsTop_WeightUpReps12() + { + var (rules, _, store, athlete) = NewRules(); + var ex = NewExercise(athlete, "bench_press"); + ActivatePlan(athlete, store, planWeight: 60, planReps: 15); + var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + for (var i = 0; i < 3; i++) + NewSet(athlete, w, ex, 60, 15, rpe: 7); + + var draft = rules.EvaluateAfterWorkout(athlete, w); + + var exercise = DraftBody(store, athlete, draft!)["weeks"]![0]!["sessions"]![0]! + ["exercises"]![0]!; + Assert.Equal(62.5m, exercise["sets"]![0]!["weight_kg"]!.GetValue()); + Assert.Equal(12, exercise["sets"]![0]!["reps"]!.GetValue()); + } + + [Fact] + public void Progress_Rule4_Shortfall_Backoff() + { + var (rules, _, store, athlete) = NewRules(); + var ex = NewExercise(athlete, "bench_press"); + ActivatePlan(athlete, store, planWeight: 100, planReps: 8); + var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + NewSet(athlete, w, ex, 100, 6, rpe: 8); + NewSet(athlete, w, ex, 100, 6, rpe: 8); + NewSet(athlete, w, ex, 100, 8, rpe: 8); + + var draft = rules.EvaluateAfterWorkout(athlete, w); + + var body = DraftBody(store, athlete, draft!); + Assert.Equal(95.0m, body["weeks"]![0]!["sessions"]![0]!["exercises"]![0]! + ["sets"]![0]!["weight_kg"]!.GetValue()); + } + + [Fact] + public void Progress_Rule5_Partial_NoDraft() + { + var (rules, _, store, athlete) = NewRules(); + var ex = NewExercise(athlete, "bench_press"); + ActivatePlan(athlete, store, planWeight: 100, planReps: 8); + var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + NewSet(athlete, w, ex, 100, 6, rpe: 8); // один недобор (<2) + NewSet(athlete, w, ex, 100, 8, rpe: 8); + + var draft = rules.EvaluateAfterWorkout(athlete, w); + + Assert.Null(draft); + Assert.DoesNotContain(store.ListVersions(athlete), + v => v.Author == "rules"); + } + + // --- дедупликация и изоляция (приёмка 2, 5) --- + + [Fact] + public void Progress_Dedup_SecondCall_NoNewDraft() + { + var (rules, _, store, athlete) = NewRules(); + var ex = NewExercise(athlete, "bench_press"); + ActivatePlan(athlete, store); + var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + for (var i = 0; i < 3; i++) + NewSet(athlete, w, ex, 100, 8, rpe: 9); + + var first = rules.EvaluateAfterWorkout(athlete, w); + var second = rules.EvaluateAfterWorkout(athlete, w); + + Assert.NotNull(first); + Assert.Null(second); + Assert.Single(store.ListVersions(athlete).Where(v => v.Author == "rules")); + } + + [Fact] + public void Progress_Isolation_ForeignWorkoutIgnored() + { + var (_, _, _, other) = NewRules(); + var otherEx = NewExercise(other, "bench_press"); + ActivatePlan(other, new ProgramStore(new ProgramRepository(NewCs()), + new ProgramVersionRepository(NewCs()))); + var otherWorkout = NewWorkout(other, NextMonday.ToDateTimeAt(18)); + for (var i = 0; i < 3; i++) + NewSet(other, otherWorkout, otherEx, 100, 8, rpe: 6); + + var (rules, _, store, athlete) = NewRules(); + var ex = NewExercise(athlete, "bench_press"); + ActivatePlan(athlete, store); + var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + // у атлета подходы с RPE 9 → правило 2; чужие подходы не мешают + for (var i = 0; i < 3; i++) + NewSet(athlete, w, ex, 100, 8, rpe: 9); + + var draft = rules.EvaluateAfterWorkout(athlete, w); + + var body = DraftBody(store, athlete, draft!); + Assert.Equal(95.0m, body["weeks"]![0]!["sessions"]![0]!["exercises"]![0]! + ["sets"]![0]!["weight_kg"]!.GetValue()); + } + + // --- ограничения атлета (module_reqs 2.4) --- + + [Fact] + public void Limits_InactiveExercise_ReplacedInDraft() + { + var (rules, _, store, athlete) = NewRules(); + var squat = NewExercise(athlete, "squat", groupId: 3); + var legPress = NewExercise(athlete, "leg_press", groupId: 3); + // логическое удаление — единственный путь is_active=false (З-34) + new ExerciseRepository(NewCs()).Delete(athlete, squat); + new ExerciseReplacementRepository(NewCs()).Insert(athlete, squat, legPress); + ActivatePlan(athlete, store, exerciseId: "squat"); + var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + NewSet(athlete, w, legPress, 120, 8); + + var draft = rules.EvaluateAfterWorkout(athlete, w); + + Assert.NotNull(draft); + var exercise = DraftBody(store, athlete, draft!)["weeks"]![0]!["sessions"]![0]! + ["exercises"]![0]!; + Assert.Equal("leg_press", exercise["exercise_id"]!.GetValue()); + Assert.Contains("leg_press", draft!.Rationale); + } + + [Fact] + public void Limits_InactiveNoReplacement_SessionDropped() + { + var (rules, _, store, athlete) = NewRules(); + var squat = NewExercise(athlete, "squat", groupId: 3); + new ExerciseRepository(NewCs()).Delete(athlete, squat); + ActivatePlan(athlete, store, exerciseId: "squat"); + var w = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + NewSet(athlete, w, NewExercise(athlete, "other"), 50, 10); + + var draft = rules.EvaluateAfterWorkout(athlete, w); + + Assert.NotNull(draft); + var sessions = DraftBody(store, athlete, draft!)["weeks"]![0]!["sessions"]!.AsArray(); + Assert.Empty(sessions); + } + + // --- делоад (приёмка 3, spec 03) --- + + [Fact] + public void Deload_AfterMaxWeeks_Proposed() + { + var (rules, _, store, athlete) = NewRules(); + var ex = NewExercise(athlete, "bench_press"); + ActivatePlan(athlete, store); + NewWorkout(athlete, Monday.AddMonths(-2).ToDateTimeAt(18), sessionKind: "deload"); + var recent = NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + for (var i = 0; i < 3; i++) + NewSet(athlete, recent, ex, 100, 8, rpe: 7); + + var draft = rules.CheckDeload(athlete, new DateOnly(2026, 10, 18)); + + Assert.NotNull(draft); + var week = DraftBody(store, athlete, draft!)["weeks"]![0]!; + Assert.Equal("deload", week["sessions"]![0]!["session_kind"]!.GetValue()); + Assert.Equal(60.0m, week["sessions"]![0]!["exercises"]![0]! + ["sets"]![0]!["weight_kg"]!.GetValue()); + Assert.Equal(2, week["sessions"]![0]!["exercises"]![0]!["sets"]!.AsArray().Count); + } + + [Fact] + public void Deload_NotDue_NoDraft() + { + var (rules, _, store, athlete) = NewRules(); + ActivatePlan(athlete, store); + NewWorkout(athlete, new DateOnly(2026, 10, 8).ToDateTimeAt(18), sessionKind: "deload"); + NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + + Assert.Null(rules.CheckDeload(athlete, new DateOnly(2026, 10, 18))); + } + + // --- перерывы (приёмка 4, spec 04) --- + + [Fact] + public void Break_Detected_AfterMissedWindow() + { + var (rules, breaks, store, athlete) = NewRules(); + ActivatePlan(athlete, store, exerciseId: "bench_press"); // день monday + var before = new BreakRepository(NewCs()) + .Select(new BreakFilter { AthleteId = athlete }).ToList(); + + var detected = breaks.DetectBreak(athlete, new DateOnly(2026, 10, 20)); // вт + + Assert.True(detected); + var open = new BreakRepository(NewCs()) + .Select(new BreakFilter { AthleteId = athlete }) + .Single(b => b.EndedOn == null && !before.Any(x => x.Id == b.Id)); + Assert.Equal("auto", open.Source); + Assert.Equal(new DateOnly(2026, 10, 19), open.StartedOn); // понедельник + } + + [Fact] + public void Break_NotDetected_RecentWorkout() + { + var (_, breaks, store, athlete) = NewRules(); + ActivatePlan(athlete, store); + NewWorkout(athlete, NextMonday.ToDateTimeAt(18)); + + Assert.False(breaks.DetectBreak(athlete, new DateOnly(2026, 10, 20))); + } + + [Fact] + public void Break_CloseOnReturn_TestSession_After14Days() + { + var (_, breaks, store, athlete) = NewRules(); + ActivatePlan(athlete, store); + new BreakRepository(NewCs()).Insert(athlete, new Break + { + Id = Guid.NewGuid(), AthleteId = athlete, + Source = "auto", StartedOn = new DateOnly(2026, 10, 1), Note = "", + }); + var workout = NewWorkout(athlete, new DateOnly(2026, 10, 15).ToDateTimeAt(18)); + + var draft = breaks.CloseOnReturn(athlete, workout, new DateOnly(2026, 10, 15)); + + Assert.NotNull(draft); + var closed = new BreakRepository(NewCs()) + .Select(new BreakFilter { AthleteId = athlete }).Single(); + Assert.Equal(new DateOnly(2026, 10, 15), closed.EndedOn); + + var week = DraftBody(store, athlete, draft!)["weeks"]![0]!; + Assert.Equal("test", week["sessions"]![0]!["session_kind"]!.GetValue()); + Assert.Equal(85.0m, week["sessions"]![0]!["exercises"]![0]! + ["sets"]![0]!["weight_kg"]!.GetValue()); // 14–27 дней → ×0.85 + Assert.Contains("14", draft!.Rationale); + } + + [Fact] + public void Break_Illness_ReturnFactor_Min() + { + var (_, breaks, store, athlete) = NewRules(); + ActivatePlan(athlete, store); + new BreakRepository(NewCs()).Insert(athlete, new Break + { + Id = Guid.NewGuid(), AthleteId = athlete, Kind = "illness", + Source = "auto", StartedOn = new DateOnly(2026, 10, 10), Note = "", + }); + var workout = NewWorkout(athlete, new DateOnly(2026, 10, 15).ToDateTimeAt(18)); + + var draft = breaks.CloseOnReturn(athlete, workout, new DateOnly(2026, 10, 15)); + + var week = DraftBody(store, athlete, draft!)["weeks"]![0]!; + Assert.Equal("return", week["sessions"]![0]!["session_kind"]!.GetValue()); + Assert.Equal(60.0m, week["sessions"]![0]!["exercises"]![0]! + ["sets"]![0]!["weight_kg"]!.GetValue()); // болезнь → ×0.6 + } + + [Fact] + public void Break_ShortReturn_FullWeights_SofterSession() + { + var (_, breaks, store, athlete) = NewRules(); + ActivatePlan(athlete, store); + new BreakRepository(NewCs()).Insert(athlete, new Break + { + Id = Guid.NewGuid(), AthleteId = athlete, + Source = "auto", StartedOn = new DateOnly(2026, 10, 14), Note = "", + }); + var workout = NewWorkout(athlete, new DateOnly(2026, 10, 19).ToDateTimeAt(18)); + + var draft = breaks.CloseOnReturn(athlete, workout, new DateOnly(2026, 10, 19)); + + var session = DraftBody(store, athlete, draft!)["weeks"]![0]!["sessions"]![0]!; + Assert.Equal("return", session["session_kind"]!.GetValue()); + Assert.Equal(100.0m, session["exercises"]![0]!["sets"]![0]! + ["weight_kg"]!.GetValue()); // 4–6 дней — веса без снижения + Assert.Equal(2, session["exercises"]![0]!["sets"]!.AsArray().Count); // −1 подход + Assert.Equal(5, session["exercises"]![0]!["sets"]![0]!["target_rpe"]!.GetValue()); + } + + // --- wiring в sync (spec 05): правила вызываются после пакета --- + + [Fact] + public async Task Sync_Package_CreatesRulesDraft() + { + var cs = NewCs(); + var key = "rules-sync-" + Guid.NewGuid().ToString("N")[..12]; + var athleteId = new AthleteRepository(cs).Insert(new Athlete + { + Name = "Rules sync", + ApiKeyHash = Server.WatchBody.Web.Services.ApiKeyHash.Of(key), + }); + var ex = NewExercise(athleteId, "bench_press"); + var store = new ProgramStore(new ProgramRepository(cs), new ProgramVersionRepository(cs)); + ActivatePlan(athleteId, store); + var w1 = Guid.NewGuid(); + var w2 = Guid.NewGuid(); + + var items = new List<(Guid Workout, DateTime Started)> + { + (w1, Monday.AddMonths(-1).ToDateTimeAt(18)), + (w2, NextMonday.AddMonths(-1).ToDateTimeAt(18)), + }; + var payload = new StringBuilder("["); + foreach (var (workout, started) in items) + { + payload.Append($$""" + {"client_guid":"{{workout}}","entity":"workout","op":"upsert", + "data":{"started_at":"{{started:yyyy-MM-dd}}T18:00:00Z","status":"finished"} }, + """); + for (var i = 0; i < 3; i++) + payload.Append($$""" + {"client_guid":"{{Guid.NewGuid()}}","entity":"workout_set","op":"upsert", + "data":{"workout_id":"{{workout}}","exercise_id":"{{ex}}","seq":{{i + 1}}, + "weight_kg":100,"reps":8,"done_at":"{{started:yyyy-MM-dd}}T18:0{{i + 1}}:00Z", + "source":"voice","in_plan":true,"rpe":6} }, + """); + } + payload.Length -= 1; + payload.Append(']'); + + var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/sync") + { + Content = new StringContent(payload.ToString(), Encoding.UTF8, "application/json"), + }; + request.Headers.Add("X-Api-Key", key); + var response = await app.Client.SendAsync(request); + Assert.True(response.IsSuccessStatusCode, + $"sync: {(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}"); + + var drafts = store.ListVersions(athleteId).Where(v => v.Author == "rules").ToList(); + Assert.Single(drafts); // правило 1: 2 закрытые тренировки → +2.5 + Assert.Contains("2 тренировки подряд", store.GetVersion(athleteId, drafts[0].Id)!.Rationale); + } + + [Fact] + public void ProgressionConfig_Validate_RejectsIsoStep() + { + var config = new ProgressionConfig { IsoStepKg = 10m }; + Assert.Contains(config.Validate(), e => e.Contains("isoStepKg")); + } +} + +internal static class DateOnlyExtensions +{ + public static DateTime ToDateTimeAt(this DateOnly date, int hour) => + new(date.Year, date.Month, date.Day, hour, 0, 0, DateTimeKind.Utc); +} diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/Server.WatchBody.Web.Tests.csproj b/3-app/2-cli/Server.WatchBody.Web.Tests/Server.WatchBody.Web.Tests.csproj new file mode 100644 index 0000000..26b9157 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/Server.WatchBody.Web.Tests.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/TestApp.cs b/3-app/2-cli/Server.WatchBody.Web.Tests/TestApp.cs new file mode 100644 index 0000000..8bb1498 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/TestApp.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Builder; +using Server.WatchBody.Web; +using Server.WatchBody.Web.Config; +using Server.WatchBody.Web.Db; +using Xunit; + +namespace Server.WatchBody.Web.Tests; + +/// +/// Общий экземпляр приложения для всех интеграционных тестов: тестовая +/// БД — docker watchbody-pg (порт 5433), данные идемпотентны по +/// фиксированным uuid/ключам. +/// +public class TestApp : IDisposable +{ + public const string IvanKey = "test-key-ivan-0001"; + public const string PetrKey = "test-key-petr-0002"; + public const string AnnaKey = "test-key-anna-0003"; + + private readonly WebApplication _app; + + public HttpClient Client { get; } + public ServerConfig Config { get; } + + public TestApp() + { + var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.test.json"); + Config = ServerConfig.Load(configPath); + var cs = new PostgresConnectionString(Config.DbConnectionString); + WebAppStartup.Initialize(cs, Config); + + var builder = WebApplication.CreateBuilder([]); + builder.WebHost.UseSetting("urls", "http://127.0.0.1:0"); + _app = WebAppStartup.Build(builder, Config, cs); + _app.StartAsync().Wait(); + + var url = _app.Urls.First(u => u.StartsWith("http://")); + Client = new HttpClient { BaseAddress = new Uri(url) }; + } + + public void Dispose() + { + Client.Dispose(); + _app.StopAsync().Wait(); + _app.DisposeAsync().AsTask().Wait(); + } +} + +[CollectionDefinition("web")] +public class WebTestCollection : ICollectionFixture; diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/UnitTests.cs b/3-app/2-cli/Server.WatchBody.Web.Tests/UnitTests.cs new file mode 100644 index 0000000..00e0152 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/UnitTests.cs @@ -0,0 +1,110 @@ +using System.Text; +using Server.WatchBody.Web.Config; +using Server.WatchBody.Web.Dto; +using Xunit; + +namespace Server.WatchBody.Web.Tests; + +/// Юнит-тесты: валидация конфига и парсер пакета sync (без БД). +public class UnitTests +{ + private static ServerConfig Valid() => new() + { + ApiKey = "owner", + DbConnectionString = "Host=x", + Athletes = [new ConfigAthlete { Name = "A", ApiKey = "k1" }], + }; + + [Fact] + public void Config_EmptyApiKey_IsError() + { + var config = Valid(); + config.ApiKey = ""; + Assert.Contains(config.Validate(), e => e.Contains("Api.Key")); + } + + [Fact] + public void Config_DuplicateAthleteKeys_IsError() + { + var config = Valid(); + config.Athletes.Add(new ConfigAthlete { Name = "B", ApiKey = "k1" }); + Assert.Contains(config.Validate(), e => e.Contains("дубликат")); + } + + [Fact] + public void Config_AthleteKeyEqualsOwnerKey_IsError() + { + var config = Valid(); + config.Athletes[0].ApiKey = "owner"; + Assert.NotEmpty(config.Validate()); + } + + [Fact] + public void Config_Valid_NoErrors() => Assert.Empty(Valid().Validate()); + + [Fact] + public void Parser_NotArray_Rejected() + { + Assert.Throws(() => SyncItemParser.Parse("""{"client_guid":"..."}""")); + } + + [Fact] + public void Parser_UnknownEntity_Rejected() + { + Assert.Throws(() => SyncItemParser.Parse( + """[{"client_guid":"0b8f6e2a-1111-4222-8333-444455556666","entity":"nonsense","op":"upsert","data":{}}]""")); + } + + [Fact] + public void Parser_BadOp_Rejected() + { + Assert.Throws(() => SyncItemParser.Parse( + """[{"client_guid":"0b8f6e2a-1111-4222-8333-444455556666","entity":"daily_log","op":"delete","data":{}}]""")); + } + + [Fact] + public void Parser_PackageOver50_Rejected() + { + var sb = new StringBuilder("["); + for (var i = 0; i < 51; i++) + { + if (i > 0) sb.Append(','); + sb.Append($$"""{"client_guid":"0b8f6e2a-0000-4000-8000-{{i:D12}}","entity":"daily_log","op":"upsert","data":{"date":"2026-10-17" } }"""); + } + sb.Append(']'); + Assert.Throws(() => SyncItemParser.Parse(sb.ToString())); + } + + [Fact] + public void Parser_BreakSourceAuto_Rejected() + { + Assert.Throws(() => SyncItemParser.Parse( + """[{"client_guid":"0b8f6e2a-1111-4222-8333-444455556666","entity":"break","op":"upsert","data":{"started_on":"2026-10-01","source":"auto"}}]""")); + } + + [Fact] + public void Parser_RpeOutOfRange_Rejected() + { + Assert.Throws(() => SyncItemParser.Parse( + """[{"client_guid":"0b8f6e2a-1111-4222-8333-444455556666","entity":"workout_set","op":"upsert","data":{"workout_id":"0b8f6e2a-1111-4222-8333-444455557777","seq":1,"weight_kg":50,"reps":10,"done_at":"2026-10-17T10:05:00Z","source":"voice","in_plan":true,"rpe":11}}]""")); + } + + [Fact] + public void Parser_MissingRequiredField_Rejected() + { + Assert.Throws(() => SyncItemParser.Parse( + """[{"client_guid":"0b8f6e2a-1111-4222-8333-444455556666","entity":"workout","op":"upsert","data":{"status":"active"}}]""")); + } + + [Fact] + public void Parser_ValidPackage_Parses() + { + var items = SyncItemParser.Parse( + """[{"client_guid":"0b8f6e2a-1111-4222-8333-444455556666","entity":"profile","op":"upsert","data":{"sex":"m","birth_year":1980,"weight_kg":85.5,"hr_max":185}}]"""); + var item = Assert.Single(items); + Assert.Equal("profile", item.Entity); + Assert.Equal("m", SyncItemParser.StringOrNull(item.Data, "sex")); + Assert.Equal(1980, SyncItemParser.IntOrNull(item.Data, "birth_year")); + Assert.Equal(85.5m, SyncItemParser.DecimalOrNull(item.Data, "weight_kg")); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web.Tests/appsettings.test.json b/3-app/2-cli/Server.WatchBody.Web.Tests/appsettings.test.json new file mode 100644 index 0000000..bde4a63 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web.Tests/appsettings.test.json @@ -0,0 +1,21 @@ +{ + "Api": { + "Key": "test-owner-key", + "Athletes": [ + { "name": "Test Иван", "apiKey": "test-key-ivan-0001" }, + { "name": "Test Пётр", "apiKey": "test-key-petr-0002" }, + { "name": "Test Анна", "apiKey": "test-key-anna-0003" } + ] + }, + "Db": { + "ConnectionString": "Host=127.0.0.1;Port=5433;Database=watchbody;Username=postgres;Password=watchbody" + }, + "Safety": { + "hrBaselineDays": 14, + "hrBaselineMin": 3 + }, + "Progression": { + "deloadMaxWeeks": 6, + "missDaysForBreak": 4 + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Config/RouteConfig.cs b/3-app/2-cli/Server.WatchBody.Web/Config/RouteConfig.cs new file mode 100644 index 0000000..d108b1b --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Config/RouteConfig.cs @@ -0,0 +1,32 @@ +using Kit.Helpers.Routes; +using Server.WatchBody.Web.Controllers; + +namespace Server.WatchBody.Web.Config; + +/// +/// Таблица маршрутов REST API v1 (fluent Kit.Helpers.Routes, +/// пример — Kit.Task.WebAp\Task.WebApp\Config\RouteConfig.cs). +/// +public class RouteConfig +{ + public static void Configure(IRouteBuilder routeBuilder) + { + routeBuilder.AddController() + .ContentAction("health", x => x.Info) + .NeedHttpGet(); + + routeBuilder.AddController() + .ActionAction("api/v1/sync", x => x.Sync) + .NeedHttpPost_All(); + + routeBuilder.AddController() + .ActionAction("api/v1/workouts", x => x.List) + .NeedHttpGet() + .ActionAction("api/v1/workouts/{id}", x => x.Get) + .NeedHttpGet(); + + routeBuilder.AddController() + .ActionAction("api/v1/programs/active", x => x.Active) + .NeedHttpGet(); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs b/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs new file mode 100644 index 0000000..51f8206 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Config/ServerConfig.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using Server.WatchBody.Core.Metrics; +using Server.WatchBody.Core.Programs; + +namespace Server.WatchBody.Web.Config; + +/// Атлет из конфига (Api.Athletes). +public class ConfigAthlete +{ + public string Name { get; set; } = string.Empty; + public string ApiKey { get; set; } = string.Empty; +} + +/// +/// Конфигурация сервера (appsettings.json; tech-requirements 4.2). +/// Ошибка валидации — фатальна: лог + stderr, код возврата 1. +/// +public class ServerConfig +{ + public string ApiKey { get; set; } = string.Empty; + public List Athletes { get; set; } = []; + public string DbConnectionString { get; set; } = string.Empty; + + /// Каталог миграций (--mode migrate, З-36); по умолчанию — + /// migrations рядом с исполняемым файлом. + public string MigrationsDir { get; set; } = + Path.Combine(AppContext.BaseDirectory, "migrations"); + + /// Пороги красных флагов (Safety.*, Р3-А; MetricsEngine). + public MetricsConfig Safety { get; set; } = new(); + + /// Пороги правил прогрессии/делоада/перерывов (Progression.*, m05). + public ProgressionConfig Progression { get; set; } = new(); + + /// Загрузка из json-файла (путь — из WATCHBODY_APPSETTINGS либо appsettings.json рядом). + public static ServerConfig Load(string path) + { + var json = File.ReadAllText(path); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var result = new ServerConfig(); + if (root.TryGetProperty("Api", out var api)) + { + if (api.TryGetProperty("Key", out var key)) result.ApiKey = key.GetString() ?? string.Empty; + if (api.TryGetProperty("Athletes", out var athletes) && athletes.ValueKind == JsonValueKind.Array) + { + foreach (var a in athletes.EnumerateArray()) + { + result.Athletes.Add(new ConfigAthlete + { + Name = a.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty, + ApiKey = a.TryGetProperty("apiKey", out var k) ? k.GetString() ?? string.Empty : string.Empty, + }); + } + } + } + if (root.TryGetProperty("Db", out var db)) + { + if (db.TryGetProperty("ConnectionString", out var cs)) + result.DbConnectionString = cs.GetString() ?? string.Empty; + if (db.TryGetProperty("MigrationsDir", out var md) && md.ValueKind == JsonValueKind.String) + result.MigrationsDir = md.GetString() ?? result.MigrationsDir; + } + result.Safety = MetricsConfig.Load(root); + result.Progression = ProgressionConfig.Load(root); + return result; + } + + /// Список ошибок валидации (spec 01-config-bootstrap). + public List Validate() + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(ApiKey)) + errors.Add("Api.Key пуст"); + var seen = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < Athletes.Count; i++) + { + var a = Athletes[i]; + if (string.IsNullOrWhiteSpace(a.Name)) errors.Add($"Api.Athletes[{i}].name пуст"); + if (string.IsNullOrWhiteSpace(a.ApiKey)) errors.Add($"Api.Athletes[{i}].apiKey пуст"); + else if (a.ApiKey == ApiKey) errors.Add($"Api.Athletes[{i}].apiKey совпадает с Api.Key"); + else if (!seen.Add(a.ApiKey)) errors.Add($"дубликат apiKey у Api.Athletes[{i}]"); + } + if (string.IsNullOrWhiteSpace(DbConnectionString)) + errors.Add("Db.ConnectionString пуст"); + errors.AddRange(Safety.Validate()); + errors.AddRange(Progression.Validate()); + return errors; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Controllers/HealthController.cs b/3-app/2-cli/Server.WatchBody.Web/Controllers/HealthController.cs new file mode 100644 index 0000000..221176c --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Controllers/HealthController.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Server.WatchBody.Web.Controllers; + +/// GET /health — без ключа (middleware пропускает). +public class HealthController : Controller +{ + public ContentResult Info() => new ContentResult { Content = "watchbody api v1", ContentType = "text/plain" }; +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Controllers/ProgramController.cs b/3-app/2-cli/Server.WatchBody.Web/Controllers/ProgramController.cs new file mode 100644 index 0000000..f5fd777 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Controllers/ProgramController.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Web.Dto; + +namespace Server.WatchBody.Web.Controllers; + +/// GET api/v1/programs/active — активная версия программы (spec 05). +public class ProgramController : Controller +{ + private readonly IProgramRepository _programs; + private readonly IProgramVersionRepository _versions; + + public ProgramController(IProgramRepository programs, IProgramVersionRepository versions) + { + _programs = programs; + _versions = versions; + } + + public ActionResult Active() + { + var athleteId = AthleteId(); + if (athleteId < 0) return Error(401, "unauthorized"); + + var program = _programs.Select(athleteId).FirstOrDefault(); + var activeMeta = program == null + ? null + : _versions.Select(new ProgramVersionFilter + { + AthleteId = athleteId, + ProgramId = program.Id, + Status = "active", + }).FirstOrDefault(); + if (program == null || activeMeta == null) + return Error(404, "no_active_program"); + + // body — отдельным get (program_version_select его не возвращает) + var active = _versions.Get(athleteId, activeMeta.Id); + if (active == null) return Error(404, "no_active_program"); + + return new JsonResult(new ActiveProgramResponse + { + ProgramId = program.Id, + VersionId = active.Id, + Version = active.Version, + BasedOnVersion = active.BasedOnVersion, + Author = active.Author, + Status = active.Status, + Body = JsonDocument.Parse(active.Body).RootElement.Clone(), + Rationale = active.Rationale, + CreatedAt = active.CreatedAt, + }); + } + + private int AthleteId() => + HttpContext.Items.TryGetValue(Middleware.ApiKeyMiddleware.AthleteIdItem, out var value) + ? (int)value! + : -1; + + private ActionResult Error(int statusCode, string message) + { + Response.StatusCode = statusCode; + return new JsonResult(new ErrorResponse { Error = message }); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Controllers/SyncController.cs b/3-app/2-cli/Server.WatchBody.Web/Controllers/SyncController.cs new file mode 100644 index 0000000..1775752 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Controllers/SyncController.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using Server.WatchBody.Web.Dto; +using Server.WatchBody.Web.Services; + +namespace Server.WatchBody.Web.Controllers; + +/// POST /api/v1/sync — приём пакета телефона (spec 03-sync-post). +public class SyncController : Controller +{ + private readonly SyncService _sync; + + public SyncController(SyncService sync) => _sync = sync; + + public ActionResult Sync() + { + int athleteId; + try + { + athleteId = AthleteId(); + } + catch (InvalidOperationException) + { + return Error(401, "unauthorized"); + } + + List items; + try + { + using var reader = new StreamReader(Request.Body); + items = SyncItemParser.Parse(reader.ReadToEndAsync().Result); + } + catch (SyncException e) + { + return Error(e.StatusCode, e.Message); + } + + try + { + var accepted = _sync.Process(athleteId, items); + return new JsonResult(new SyncResponse { Accepted = accepted }); + } + catch (SyncException e) + { + return Error(e.StatusCode, e.Code); + } + catch (Exception e) + { + // сбой БД внутри пакета: откат (TransactionScope) + 400, текст — в лог + Console.Error.WriteLine($"sync error: {e}"); + return Error(400, "sync_failed: " + e.Message); + } + } + + private int AthleteId() => (int)HttpContext.Items[Middleware.ApiKeyMiddleware.AthleteIdItem]!; + + private ActionResult Error(int statusCode, string message) + { + Response.StatusCode = statusCode; + return new JsonResult(new ErrorResponse { Error = message }); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Controllers/WorkoutController.cs b/3-app/2-cli/Server.WatchBody.Web/Controllers/WorkoutController.cs new file mode 100644 index 0000000..f4f1f8f --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Controllers/WorkoutController.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Web.Dto; + +namespace Server.WatchBody.Web.Controllers; + +/// +/// Чтение тренировок (spec 04-workouts-get): +/// GET api/v1/workouts (пагинация), GET api/v1/workouts/{"id"} (карточка). +/// +public class WorkoutController : Controller +{ + private readonly IWorkoutRepository _workouts; + + public WorkoutController(IWorkoutRepository workouts) => _workouts = workouts; + + public ActionResult List(int? limit, int? offset) + { + var athleteId = AthleteId(); + if (athleteId < 0) return Error(401, "unauthorized"); + + var take = limit ?? 20; + var skip = offset ?? 0; + if (take is < 1 or > 100) return Error(400, "limit 1–100"); + if (skip < 0) return Error(400, "offset ≥ 0"); + + var all = _workouts.Select(new WorkoutFilter { AthleteId = athleteId }) + .OrderByDescending(w => w.StartedAt).ToList(); + var page = all.Skip(skip).Take(take) + .Select(Map).ToList(); + return new JsonResult(new WorkoutListResponse { Items = page, Total = all.Count }); + } + + public ActionResult Get(string id) + { + var athleteId = AthleteId(); + if (athleteId < 0) return Error(401, "unauthorized"); + if (!Guid.TryParse(id, out var workoutId)) return Error(400, "id не uuid"); + var w = _workouts.Get(athleteId, workoutId); + if (w == null) return Error(404, "not_found"); + + return new JsonResult(new WorkoutCardResponse + { + WorkoutId = w.Id, + StartedAt = w.StartedAt, + EndedAt = w.EndedAt, + Status = w.Status, + SessionKind = w.SessionKind, + AvgHr = w.AvgHr, + Sets = w.Sets.Select(s => new WorkoutSetDto + { + SetId = s.Id, + ExerciseId = s.ExerciseId, + Seq = s.Seq, + WeightKg = s.WeightKg, + Reps = s.Reps, + DoneAt = s.DoneAt, + Source = s.Source, + InPlan = s.InPlan, + Rpe = s.Rpe, + KneePain = s.KneePain, + }).ToList(), + CommandLog = w.Commands.Select(c => new CommandLogDto + { + Id = c.Id, + TOffsetS = c.TOffsetS, + RecognizedText = c.RecognizedText, + Semantics = JsonDocument.Parse(c.Semantics).RootElement.Clone(), + Accepted = c.Accepted, + }).ToList(), + }); + } + + private static WorkoutDto Map(Core.Dal.Src.Domain.Workout w) => new() + { + WorkoutId = w.Id, + StartedAt = w.StartedAt, + EndedAt = w.EndedAt, + Status = w.Status, + SessionKind = w.SessionKind, + AvgHr = w.AvgHr, + Sets = w.Sets.Select(s => new WorkoutSetDto + { + SetId = s.Id, + ExerciseId = s.ExerciseId, + Seq = s.Seq, + WeightKg = s.WeightKg, + Reps = s.Reps, + DoneAt = s.DoneAt, + Source = s.Source, + InPlan = s.InPlan, + Rpe = s.Rpe, + KneePain = s.KneePain, + }).ToList(), + }; + + private int AthleteId() => + HttpContext.Items.TryGetValue(Middleware.ApiKeyMiddleware.AthleteIdItem, out var value) + ? (int)value! + : -1; + + private ActionResult Error(int statusCode, string message) + { + Response.StatusCode = statusCode; + return new JsonResult(new ErrorResponse { Error = message }); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Db/PostgresConnectionString.cs b/3-app/2-cli/Server.WatchBody.Web/Db/PostgresConnectionString.cs new file mode 100644 index 0000000..a106e52 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Db/PostgresConnectionString.cs @@ -0,0 +1,13 @@ +using Kit.Helpers.SqlHelperAbstract; + +namespace Server.WatchBody.Web.Db; + +/// Строка подключения к PostgreSQL (единственная на процесс). +public class PostgresConnectionString : ConnectionString +{ + public PostgresConnectionString(string value) + { + Type = ConnectionStringType.Postgres; + Value = value; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Dto/ResponseDtos.cs b/3-app/2-cli/Server.WatchBody.Web/Dto/ResponseDtos.cs new file mode 100644 index 0000000..a434c0c --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Dto/ResponseDtos.cs @@ -0,0 +1,72 @@ +using System.Text.Json; + +namespace Server.WatchBody.Web.Dto; + +/// Ответы API (specs 03–05); сериализация camelCase, null-поля опускаются. +public class SyncResponse +{ + public List Accepted { get; set; } = []; +} + +public class ErrorResponse +{ + public string Error { get; set; } = string.Empty; +} + +public class WorkoutSetDto +{ + public Guid SetId { get; set; } + public Guid? ExerciseId { get; set; } + public int Seq { get; set; } + public decimal WeightKg { get; set; } + public int Reps { get; set; } + public DateTime DoneAt { get; set; } + public string Source { get; set; } = string.Empty; + public bool InPlan { get; set; } + public int? Rpe { get; set; } + public int? KneePain { get; set; } +} + +public class WorkoutDto +{ + public Guid WorkoutId { get; set; } + public DateTime StartedAt { get; set; } + public DateTime? EndedAt { get; set; } + public string Status { get; set; } = string.Empty; + public string SessionKind { get; set; } = string.Empty; + public int? AvgHr { get; set; } + public List Sets { get; set; } = []; +} + +public class WorkoutListResponse +{ + public List Items { get; set; } = []; + public int Total { get; set; } +} + +public class CommandLogDto +{ + public Guid Id { get; set; } + public int TOffsetS { get; set; } + public string RecognizedText { get; set; } = string.Empty; + public JsonElement Semantics { get; set; } + public bool Accepted { get; set; } +} + +public class WorkoutCardResponse : WorkoutDto +{ + public List CommandLog { get; set; } = []; +} + +public class ActiveProgramResponse +{ + public int ProgramId { get; set; } + public Guid VersionId { get; set; } + public int Version { get; set; } + public int? BasedOnVersion { get; set; } + public string Author { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public JsonElement Body { get; set; } + public string? Rationale { get; set; } + public DateTime CreatedAt { get; set; } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Dto/SyncDtos.cs b/3-app/2-cli/Server.WatchBody.Web/Dto/SyncDtos.cs new file mode 100644 index 0000000..4d3d8ef --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Dto/SyncDtos.cs @@ -0,0 +1,206 @@ +using System.Globalization; +using System.Text.Json; + +namespace Server.WatchBody.Web.Dto; + +/// Ошибка обработки пакета sync → HTTP-ответ с кодом и телом {"error": ...}. +public class SyncException(string message, int statusCode = 400, string code = "bad_request") : Exception(message) +{ + public int StatusCode { get; } = statusCode; + public string Code { get; } = code; +} + +/// Запись пакета sync (tech-requirements 2.1). +public class SyncItem +{ + public Guid ClientGuid { get; set; } + public string Entity { get; set; } = string.Empty; + public string Op { get; set; } = string.Empty; + public JsonElement Data { get; set; } +} + +public static class SyncItemParser +{ + public static List Parse(string json) + { + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException e) + { + throw new SyncException($"невалидный JSON: {e.Message}"); + } + + using (doc) + { + if (doc.RootElement.ValueKind != JsonValueKind.Array) + throw new SyncException("ожидается JSON-массив записей"); + var items = new List(); + foreach (var element in doc.RootElement.EnumerateArray()) + items.Add(ParseItem(element)); + if (items.Count > 50) + throw new SyncException("пакет больше 50 записей"); + return items; + } + } + + private static SyncItem ParseItem(JsonElement element) + { + var item = new SyncItem(); + if (!element.TryGetProperty("client_guid", out var guidEl) || + !Guid.TryParse(guidEl.GetString(), out var guid)) + throw new SyncException("client_guid отсутствует или не uuid"); + item.ClientGuid = guid; + item.Entity = GetString(element, "entity"); + item.Op = GetString(element, "op"); + if (item.Op != "upsert") + throw new SyncException($"op '{item.Op}' не поддерживается (только upsert)"); + if (!element.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) + throw new SyncException("data отсутствует или не объект"); + item.Data = data.Clone(); + ValidateEntity(item); + return item; + } + + private static string GetString(JsonElement element, string name, bool required = true) + { + if (!element.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) + { + if (required) throw new SyncException($"поле '{name}' отсутствует или не строка"); + return string.Empty; + } + return el.GetString() ?? string.Empty; + } + + private static void ValidateEntity(SyncItem item) + { + var d = item.Data; + switch (item.Entity) + { + case "workout": + RequireDate(d, "started_at"); + OneOf(d, "status", required: false, "active", "finished", "cancelled"); + OneOf(d, "session_kind", required: false, "normal", "return", "deload", "test"); + break; + case "workout_set": + RequireGuid(d, "workout_id"); + RequireNum(d, "seq"); + RequireNum(d, "weight_kg"); + RequireNum(d, "reps"); + RequireDate(d, "done_at"); + OneOf(d, "source", required: true, "voice", "watch", "edit"); + Range(d, "rpe", 1, 10); + Range(d, "knee_pain", 0, 10); + break; + case "exercise": + RequireString(d, "name"); + OneOf(d, "group", required: true, "push", "pull", "legs", "core", "cardio"); + break; + case "program_version": + RequireNum(d, "version"); // не используется (версию считает БД), но должен быть числом по схеме + break; + case "profile": + OneOf(d, "sex", required: false, "m", "f"); + break; + case "daily_log": + RequireDate(d, "date"); + Range(d, "wellbeing", 1, 5); + break; + case "break": + RequireDate(d, "started_on"); + if (d.TryGetProperty("source", out var src) && src.ValueKind == JsonValueKind.String && + src.GetString() == "auto") + throw new SyncException("break.source='auto' из sync не принимается — auto проставляет сервер"); + OneOf(d, "kind", required: false, "illness", "vacation", "workload", "injury", "other"); + break; + default: + throw new SyncException($"неизвестная entity '{item.Entity}'"); + } + } + + private static void RequireString(JsonElement d, string name) + { + if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(el.GetString())) + throw new SyncException($"поле '{name}' обязательно"); + } + + private static void RequireNum(JsonElement d, string name) + { + if (!d.TryGetProperty(name, out var el) || + (el.ValueKind != JsonValueKind.Number && el.ValueKind != JsonValueKind.Null)) + throw new SyncException($"поле '{name}' должно быть числом"); + } + + private static void RequireGuid(JsonElement d, string name) + { + if (!d.TryGetProperty(name, out var el) || !Guid.TryParse(el.GetString(), out _)) + throw new SyncException($"поле '{name}' отсутствует или не uuid"); + } + + private static void RequireDate(JsonElement d, string name) + { + if (!d.TryGetProperty(name, out var el) || !DateTime.TryParse(el.GetString(), out _)) + throw new SyncException($"поле '{name}' отсутствует или не дата"); + } + + private static void OneOf(JsonElement d, string name, bool required, params string[] allowed) + { + if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) + { + if (required) throw new SyncException($"поле '{name}' обязательно"); + return; + } + var value = el.GetString(); + if (value != null && !allowed.Contains(value)) + throw new SyncException($"поле '{name}' = '{value}' вне допустимых значений"); + } + + private static void Range(JsonElement d, string name, int min, int max) + { + if (!d.TryGetProperty(name, out var el)) return; + if (el.ValueKind == JsonValueKind.Null) return; + if (el.ValueKind != JsonValueKind.Number || !el.TryGetInt32(out var value) || value < min || value > max) + throw new SyncException($"поле '{name}' вне диапазона {min}–{max}"); + } + + public static DateTime? UtcDateOrNull(JsonElement d, string name) + { + if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return null; + var parsed = DateTime.Parse(el.GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + return parsed.ToUniversalTime(); + } + + public static DateOnly? DayOrNull(JsonElement d, string name) + { + if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return null; + return DateOnly.Parse(el.GetString()!, CultureInfo.InvariantCulture); + } + + public static int? IntOrNull(JsonElement d, string name) + { + if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number) return null; + return el.GetInt32(); + } + + public static decimal? DecimalOrNull(JsonElement d, string name) + { + if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.Number) return null; + return el.GetDecimal(); + } + + public static bool? BoolOrNull(JsonElement d, string name) + { + if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.True && + el.ValueKind != JsonValueKind.False) return null; + return el.GetBoolean(); + } + + public static string? StringOrNull(JsonElement d, string name) + { + if (!d.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String) return null; + return el.GetString(); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Middleware/ApiKeyMiddleware.cs b/3-app/2-cli/Server.WatchBody.Web/Middleware/ApiKeyMiddleware.cs new file mode 100644 index 0000000..b418d5e --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Middleware/ApiKeyMiddleware.cs @@ -0,0 +1,46 @@ +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Web.Services; + +namespace Server.WatchBody.Web.Middleware; + +/// +/// Аутентификация X-Api-Key → athlete_id (spec 02-api-key-middleware). +/// /health — без ключа. 401 единым телом: нет ключа / чужой / заблокирован. +/// +public class ApiKeyMiddleware(RequestDelegate next) +{ + public const string AthleteIdItem = "athlete_id"; + + public async Task InvokeAsync(HttpContext context, IAthleteRepository athletes) + { + // /health без ключа — только GET (З-42 п. 8): прочие методы идут + // в аутентификацию и получают 401, а не деградацию 404/405 + if (context.Request.Path == "/health" && + HttpMethods.IsGet(context.Request.Method)) + { + await next(context); + return; + } + + var key = context.Request.Headers["X-Api-Key"].FirstOrDefault(); + Athlete? athlete = null; + if (!string.IsNullOrEmpty(key)) + { + var hash = ApiKeyHash.Of(key); + athlete = athletes.Select(new AthleteFilter { ApiKeyHash = hash }) + .FirstOrDefault(); + } + + if (athlete == null || athlete.IsBlocked) + { + context.Response.StatusCode = 401; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync("""{"error":"unauthorized"}"""); + return; + } + + context.Items[AthleteIdItem] = athlete.Id; + await next(context); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Program.cs b/3-app/2-cli/Server.WatchBody.Web/Program.cs new file mode 100644 index 0000000..1f5af53 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Program.cs @@ -0,0 +1,89 @@ +using Server.WatchBody.Web; +using Server.WatchBody.Web.Config; +using Server.WatchBody.Web.Db; +using Server.WatchBody.Web.Services; + +// Точка входа сервера (m02; режимы — requirements 2.3, З-36): +// --mode serve REST API (по умолчанию) +// --mode migrate применение 2-db\migrations\ по журналу src.migration +// --config путь к appsettings (иначе WATCHBODY_APPSETTINGS либо +// appsettings.json рядом с исполняемым файлом) +// Валидация конфига — фатальна: лог + stderr, код возврата 1; +// migrate: успех — 0, сбой — 1. + +string? mode = null, configArg = null; +for (var i = 0; i < args.Length - 1; i++) +{ + switch (args[i]) + { + case "--mode": + mode = args[i + 1]; + break; + case "--config": + configArg = args[i + 1]; + break; + } +} +mode ??= "serve"; + +var configPath = configArg + ?? Environment.GetEnvironmentVariable("WATCHBODY_APPSETTINGS") + ?? Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + +ServerConfig config; +try +{ + config = ServerConfig.Load(configPath); +} +catch (Exception e) +{ + Console.Error.WriteLine($"конфиг {configPath} не читается: {e.Message}"); + return 1; +} + +if (mode == "migrate") +{ + try + { + var applied = new MigrationRunner( + new PostgresConnectionString(config.DbConnectionString)).Apply(config.MigrationsDir); + Console.WriteLine($"migrate: применено миграций — {applied}"); + return 0; + } + catch (Exception e) + { + Console.Error.WriteLine($"migrate: сбой — {e.Message}"); + return 1; + } +} + +if (mode != "serve") +{ + Console.Error.WriteLine($"неизвестный режим '{mode}' (serve|migrate)"); + return 1; +} + +var errors = config.Validate(); +if (errors.Count > 0) +{ + foreach (var error in errors) Console.Error.WriteLine($"конфиг: {error}"); + return 1; +} + +var connectionString = new PostgresConnectionString(config.DbConnectionString); +try +{ + WebAppStartup.Initialize(connectionString, config); +} +catch (Exception e) +{ + Console.Error.WriteLine($"инициализация не удалась: {e.Message}"); + return 1; +} + +var builder = WebApplication.CreateBuilder(args); +var app = WebAppStartup.Build(builder, config, connectionString); +app.Run(); +return 0; + +public partial class Program; diff --git a/3-app/2-cli/Server.WatchBody.Web/Server.WatchBody.Web.csproj b/3-app/2-cli/Server.WatchBody.Web/Server.WatchBody.Web.csproj new file mode 100644 index 0000000..df22285 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Server.WatchBody.Web.csproj @@ -0,0 +1,13 @@ + + + + + + + + net10.0 + enable + enable + + + diff --git a/3-app/2-cli/Server.WatchBody.Web/Services/AthleteBootstrap.cs b/3-app/2-cli/Server.WatchBody.Web/Services/AthleteBootstrap.cs new file mode 100644 index 0000000..6f83a13 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Services/AthleteBootstrap.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; +using System.Text; +using Kit.Helpers.SqlHelperAbstract; +using Server.WatchBody.Core.Dal.Src.Domain; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Web.Config; + +namespace Server.WatchBody.Web.Services; + +/// SHA-256 ключа в hex (lower-case) — формат src.athlete.api_key_hash. +public static class ApiKeyHash +{ + public static string Of(string apiKey) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} + +/// +/// Стартовая синхронизация атлетов конфига с src.athlete +/// (upsert по хэшу ключа; spec 01-config-bootstrap). is_blocked +/// конфигом не переопределяется — источник блокировки только БД. +/// +public static class AthleteBootstrap +{ + public static void Sync(IConnectionString connectionString, IReadOnlyList athletes) + { + var repo = new AthleteRepository(connectionString); + foreach (var configAthlete in athletes) + { + var hash = ApiKeyHash.Of(configAthlete.ApiKey); + var existing = repo.Select(new AthleteFilter { ApiKeyHash = hash }).FirstOrDefault(); + if (existing == null) + { + repo.Insert(new Athlete { Name = configAthlete.Name, ApiKeyHash = hash }); + } + else if (!string.Equals(existing.Name, configAthlete.Name, StringComparison.Ordinal)) + { + existing.Name = configAthlete.Name; + repo.Update(existing); + } + } + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Services/MigrationRunner.cs b/3-app/2-cli/Server.WatchBody.Web/Services/MigrationRunner.cs new file mode 100644 index 0000000..61bae6f --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Services/MigrationRunner.cs @@ -0,0 +1,48 @@ +using Npgsql; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Web.Db; + +namespace Server.WatchBody.Web.Services; + +/// +/// Режим --mode migrate (З-36, requirements 2.3): применение файлов +/// 2-db\migrations\ по возрастанию имён; файл не в журнале src.migration +/// — выполняется целиком в транзакции (скрипт + запись журнала), сбой — +/// откат и код возврата 1. Миграции идемпотентны (IF NOT EXISTS / +/// OR REPLACE) — повторный запуск безопасен. +/// +public class MigrationRunner(PostgresConnectionString connectionString) +{ + public int Apply(string migrationsDir) + { + if (!Directory.Exists(migrationsDir)) + throw new DirectoryNotFoundException($"каталог миграций не найден: {migrationsDir}"); + var repository = new MigrationRepository(connectionString); + var applied = 0; + foreach (var file in Directory.GetFiles(migrationsDir, "*.sql") + .OrderBy(f => Path.GetFileName(f), StringComparer.Ordinal)) + { + var name = Path.GetFileName(file); + if (repository.Select(name) != null) continue; + + var sql = File.ReadAllText(file); + using (var scope = new System.Transactions.TransactionScope( + System.Transactions.TransactionScopeAsyncFlowOption.Enabled)) + { + // скрипт и журнал — одна ambient-транзакция (оба соединения + // Npgsql, Enlist по умолчанию); сбой → откат всего файла + using (var connection = new NpgsqlConnection(connectionString.Value)) + { + connection.Open(); + using var command = new NpgsqlCommand(sql, connection); + command.ExecuteNonQuery(); + } + repository.Insert(name); + scope.Complete(); + } + Console.WriteLine($"миграция применена: {name}"); + applied++; + } + return applied; + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/Services/SyncService.cs b/3-app/2-cli/Server.WatchBody.Web/Services/SyncService.cs new file mode 100644 index 0000000..23fd9ff --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/Services/SyncService.cs @@ -0,0 +1,300 @@ +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 = "Программа" }); +} diff --git a/3-app/2-cli/Server.WatchBody.Web/WebAppStartup.cs b/3-app/2-cli/Server.WatchBody.Web/WebAppStartup.cs new file mode 100644 index 0000000..69ff56c --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/WebAppStartup.cs @@ -0,0 +1,73 @@ +using Kit.Helpers.SqlHelperAbstract; +using Microsoft.AspNetCore.Http.Json; +using Server.WatchBody.Core.Dal.Ref.Repository; +using Server.WatchBody.Core.Dal.Src.Repository; +using Server.WatchBody.Web.Config; +using Server.WatchBody.Web.Db; +using Server.WatchBody.Web.Middleware; +using Server.WatchBody.Web.Services; + +namespace Server.WatchBody.Web; + +/// +/// Сборка приложения (общая для Program и интеграционных тестов): +/// probe БД, bootstrap атлетов, DI, middleware и fluent-таблица +/// маршрутов (Kit.Helpers.Routes — Config\RouteConfig.cs). +/// +public static class WebAppStartup +{ + /// Тест подключения к БД — фатален при неудаче (spec 01). + public static void ProbeDatabase(IConnectionString connectionString) + { + // через хранимую функцию: сырой SQL во fluent Kit.Helpers не передают + new MigrationRepository(connectionString).Select("000_probe"); + } + + public static WebApplication Build(WebApplicationBuilder builder, ServerConfig config, IConnectionString cs) + { + builder.Services.AddSingleton(cs); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(config.Safety); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(config.Progression); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.Services.AddControllersWithViews(o => { o.EnableEndpointRouting = false; }) + // контроллеры живут в сборке Web — при хостинге из тестов входная + // сборка другая, часть нужно указать явно + .AddApplicationPart(typeof(WebAppStartup).Assembly) + .AddJsonOptions(o => + { + o.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + o.JsonSerializerOptions.DefaultIgnoreCondition = + System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull; + }); + + var app = builder.Build(); + app.UseMiddleware(); + app.UseRouting(); + app.UseMvc(routes => RouteConfig.Configure(routes)); + return app; + } + + /// Инициализация: probe БД + bootstrap атлетов (до старта Kestrel). + public static void Initialize(IConnectionString cs, ServerConfig config) + { + ProbeDatabase(cs); + AthleteBootstrap.Sync(cs, config.Athletes); + } +} diff --git a/3-app/2-cli/Server.WatchBody.Web/appsettings.json b/3-app/2-cli/Server.WatchBody.Web/appsettings.json new file mode 100644 index 0000000..ef066f2 --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.Web/appsettings.json @@ -0,0 +1,35 @@ +{ + "Api": { + "Key": "CHANGE-ME-owner-key", + "Athletes": [ + { "name": "Атлет 1", "apiKey": "CHANGE-ME-athlete-1" } + ] + }, + "Db": { + "ConnectionString": "Host=127.0.0.1;Port=5433;Database=watchbody;Username=postgres;Password=watchbody" + }, + "Safety": { + "bpCancelSys": 150, + "bpCancelDias": 90, + "bpElevatedSys": 140, + "bpElevatedDias": 95, + "hrRestingRise": 8, + "hrRestingDays": 2, + "wellbeingLow": 2, + "wellbeingDays": 2, + "lightDayCount": 3, + "kneePainMin": 1, + "kneeRepeatSessions": 2, + "hrBaselineDays": 14, + "hrBaselineMin": 3 + }, + "Progression": { + "baseStepKg": 2.5, + "isoStepKg": 1.0, + "backoffPercent": 5, + "deloadMaxWeeks": 6, + "missDaysForBreak": 4, + "returnMinFactor": 0.6, + "returnMaxFactor": 0.9 + } +} diff --git a/3-app/2-cli/Server.WatchBody.slnx b/3-app/2-cli/Server.WatchBody.slnx new file mode 100644 index 0000000..1abe04d --- /dev/null +++ b/3-app/2-cli/Server.WatchBody.slnx @@ -0,0 +1,6 @@ + + + + + +