Этап 7: итерации 1–2 — m01–m05 серверной части + обе волны ревью 2026-10-18 (З-35…З-42)

Модули (d-dev, все закрыты): m01-server-db (38 psql-функций, 13 репозиториев,
psql 123/123), m02-rest-api (sync/workouts/programs + middleware),
m03-program-store (версии draft→active→archived), m04-metrics-engine
(метрики + 6 красных флагов, Safety.*), m05-progression-rules
(ProgressionRules + BreakEngine: правила 1–5, делоад, перерывы/возврат,
фильтр ограничений is_active/замены; Web 81/81 + Core 56/56).

Волны ревью: З-35 external_id до артефактов (миграция 003, program_insert
rev.2 с DROP старой сигнатуры); З-36 --mode migrate (Program.cs,
MigrationRunner, Db.MigrationsDir); З-37 tech-req 4.2 к факту (BpElevated*,
фактические параметры, camelCase); З-38 шлюзы m05; З-39 примечание Ф-14.2
(illness → перезапуск); З-40/З-42 документация к факту (dto, SqlHelperAbstract,
spec m05/m04); З-41(а) is_active=false в sync → exercise_delete (Ф-4.2).

Инфраструктура: docker watchbody-pg (PostgreSQL 17, порт 5433); Kit.Helpers
в 3-app/1-lib (не изменялся). Эксплуатационные файлы (program-trenirovok.*,
program-import.json, presentation-trenirovok.html, журнал метрик) в коммит
не включены.
This commit is contained in:
virus-home 2026-10-18 20:11:40 +05:00
parent a1b4969f57
commit f232d592a4
316 changed files with 23827 additions and 62 deletions

View File

@ -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`).

View File

@ -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<SyncService>`; `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` — вхождения
в миграции, функции, спецификации, домене и репозитории.

View File

@ -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,

View File

@ -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):

View File

@ -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` |

View File

@ -8,7 +8,9 @@
## Общие сведения
- **СУБД:** PostgreSQL 17+; доступ — только через хранимые функции
(`Kit.Helpers\Db.Postgres.SqlServerHelper`, tech-requirements разд. 3).
(Kit.Helpers `SqlHelperAbstract`/`SqlHelperPostgres` — fluent
`PrepareExecute<T>()`, 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 <path>`) — файлы каталога `2-db\migrations\` по
возрастанию имён, журнал — `src.migration` (`MigrationRepository`),
транзакция на файл, коды возврата 0/1; ключ конфига
`Db.MigrationsDir` (умолчание — migrations рядом с исполняемым
файлом).
## Таблица `llm_calls` (v2)

View File

@ -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 строк, без исключения |

View File

@ -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) |

View File

@ -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 строк |

View File

@ -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 |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 |

View File

@ -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 строк |

View File

@ -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 |

View File

@ -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 |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 |

View File

@ -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 |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 |

View File

@ -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 | все упражнения атлета |

View File

@ -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 |

View File

@ -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 |

View File

@ -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 строк |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 |

View File

@ -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 строк |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 — на стороне приложения.

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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) | да | `{"<exercise_id>": 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` (изоляция 🛡) |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 строк (изоляция 🛡) |

View File

@ -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 |

View File

@ -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 |

View File

@ -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 | проверка «применена ли миграция» |

View File

@ -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<T>().AsStoredProcedure().AddParameter()…` (З-42: фактическое имя компонента вместо имени из ранней редакции спецификации) |
| `Log.Logger` | — | Файловые логи; `Log.Path`, уровень из конфигурации. В v1 не настроен (errata m04 №4) — sync-контур использует `ILogger<T>`; файловые логи — последующие модули |
| `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

View File

@ -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<SyncService>` (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 — вопрос развёртывания, вне кода.

View File

@ -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).

View File

@ -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 и утверждён.

View File

@ -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`.

View File

@ -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`.

View File

@ -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 |

View File

@ -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 |

View File

@ -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) |

View File

@ -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) |

View File

@ -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) |

View File

@ -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='["<uuid e2-замены>"]'.
## Тесты: `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 |

View File

@ -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 |

View File

@ -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 |

View File

@ -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) |

View File

@ -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 |

View File

@ -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) |

View File

@ -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`).

View File

@ -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" } }
```

View File

@ -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
```

View File

@ -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..."]}
```

View File

@ -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.

View File

@ -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 |

View File

@ -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<Guid> Accepted` | `{"accepted": [...]}` |
| `ErrorResponse` | `string Error` | `{"error": "..."}` для 400/401/404/409 |
| `WorkoutListResponse` | `List<WorkoutDto> Items`, `int Total` | список тренировок |
| `WorkoutDto` | `Guid WorkoutId`, `DateTime? StartedAt/EndedAt`, `string Status`, `string SessionKind`, `int? AvgHr`, `List<WorkoutSetDto> 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` |

View File

@ -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).

View File

@ -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.

View File

@ -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`).

View File

@ -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 | ошибка |

View File

@ -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<ProgramVersion>`
Версии программы атлета (без body — поле пустое), по возрастанию
номера. Программы нет → пустой список.
| # | Тест | Ожидание |
|---|---|---|
| 4 | три версии | 3 записи, номера 1–3 по возрастанию, статусы соответствуют, body пустые |
| 5 | чужой атлет | пустой список (изоляция) |
## 3. `GetVersion(int athleteId, Guid versionId)` → `ProgramVersion?`
Версия с body; чужая версия атлета / не существует → `null` (различие
не выдаётся).
| # | Тест | Ожидание |
|---|---|---|
| 6 | своя версия | версия с body |
| 7 | чужая версия | null |

View File

@ -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` — зелёные;
сборка решения без ошибок.

View File

@ -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.

View File

@ -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/)

View File

@ -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-А).

View File

@ -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": <workout.avg_hr>, "hr_recovery_s":
<workout.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`).

View File

@ -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).

View File

@ -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).

View File

@ -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` — зелёные;
сборка решения без ошибок.

View File

@ -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<DateTime>` падал `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<SyncService>`.
5. В DI добавлены `IWorkoutMetricsRepository`, `ProgramStore`,
`MetricsEngine`, `MetricsConfig` (секция `Safety` в appsettings).
Решение по порогам АД: коридоры документа метрик перекрываются
(отмена >150/**90**, «только кардио» 140–150/**90–95**) — проверка
валидации `bpElevatedDias ≤ bpCancelDias` не применяется (spec 01).

View File

@ -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/)

View File

@ -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`.

View File

@ -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).

View File

@ -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)».
🛡 Активация — только человеком; авто-делоада активной версии нет.

View File

@ -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).

View File

@ -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<SyncService>` (errata m04 №4).
## 2. DI (WebAppStartup)
```
AddSingleton(config.Progression); // ProgressionConfig
AddSingleton<BreakEngine>();
AddSingleton<ProgressionRules>();
```
Конструкторы (по факту, З-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).

View File

@ -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` — зелёные;
сборка решения без ошибок.

View File

@ -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).

View File

@ -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;

View File

@ -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()
);

View File

@ -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);

View File

@ -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;
$$;

View File

@ -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;

View File

@ -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;
$$;

View File

@ -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;
$$;

View File

@ -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;
$$;

Some files were not shown because too many files have changed in this diff Show More