Этап 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:
parent
a1b4969f57
commit
f232d592a4
|
|
@ -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`).
|
||||
|
|
@ -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` — вхождения
|
||||
в миграции, функции, спецификации, домене и репозитории.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 строк, без исключения |
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 строк |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 строк |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 | все упражнения атлета |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 строк |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 строк |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 — на стороне приложения.
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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` (изоляция 🛡) |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 строк (изоляция 🛡) |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 | проверка «применена ли миграция» |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 — вопрос развёртывания, вне кода.
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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 и утверждён.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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) |
|
||||
|
|
@ -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) |
|
||||
|
|
@ -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) |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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) |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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) |
|
||||
|
|
@ -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`).
|
||||
|
|
@ -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" } }
|
||||
```
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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..."]}
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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` |
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`).
|
||||
|
|
@ -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 | ошибка |
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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` — зелёные;
|
||||
сборка решения без ошибок.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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/)
|
||||
|
|
@ -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-А).
|
||||
|
|
@ -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`).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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` — зелёные;
|
||||
сборка решения без ошибок.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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/)
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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)».
|
||||
|
||||
🛡 Активация — только человеком; авто-делоада активной версии нет.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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` — зелёные;
|
||||
сборка решения без ошибок.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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;
|
||||
|
|
@ -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()
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
$$;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
$$;
|
||||
|
|
@ -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;
|
||||
$$;
|
||||
|
|
@ -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
Loading…
Reference in New Issue