# Design: Project Members Write ## Context `services/user-reader` уже читает составы через `GET /api/project-members` (join `tMerakomisTeamMember` → `tMerakomisProject` → `tMerakomisDSection`) и пишет табель (`labor-api-write`). Запись составов в PHP CMS идёт через `themes.merakomis.team.member` + generic CRUD `merakomisControllerTable`; отдельного REST-action нет. Модель данных: ``` tMerakomisProject.team ──► tMerakomisTeam tMerakomisProjectSection (project_id, section_id) ──► допустимые разделы tMerakomisTeamMember (team, emp, section, role, active, text) UNIQUE (team, emp) ``` Права в PHP: `Rules::isRwMember($team_id, $member_id)` — admin, РП проекта, ГИП в проекте, директор отдела (для подчинённых при редактировании конкретного member). Валидация в PHP `Member::beforeUadd`: обязательны `emp`, `role`, `section` (ненулевые). ## Goals / Non-Goals **Goals:** - Внешний сервис добавляет/обновляет участника команды через `PUT /api/project-members` с тем же auth-паттерном, что табель - Поведение 1:1 с CMS: upsert по `(team, emp)`, валидация section по проекту, права `isRwMember` - Вспомогательные read-эндпоинты для построения UI без знания внутренних id ролей/разделов - Расширение `GET /api/labor/permissions` флагом `can_write_member` **Non-Goals:** - Full-sync всего состава проекта одним запросом - Delta-инкремент составов - Физическое DELETE из `tMerakomisTeamMember` - Автодобавление всех сотрудников (`Project::checkAddAllEmps` / `is_all_emp`) - Изменение PHP CMS или React UI ## Decisions ### 1. Один upsert-эндпоинт `PUT /api/project-members` **Решение:** единый `PUT` с телом `{ project_id, emp_id, section_id, role, active?, text? }`. **Почему:** unique `(team, emp)` в БД; интегратору не нужен `member_id` для первичного добавления. Если запись есть — UPDATE, иначе INSERT (team из `project.team`). **Альтернатива:** REST `POST/PATCH/DELETE` по `member_id` — ближе к CMS, но требует предварительного GET для id; отклонено для v1. ### 2. Деактивация вместо удаления **Решение:** `active: false` в теле PUT; физический DELETE не экспонировать. **Почему:** CMS использует switcher `active`; сохраняется история; `GET /api/project-members?active_only=true` уже фильтрует неактивных. ### 3. Новый модуль `project_members_write.py` + `project_members_read.py` **Решение:** отдельные файлы, не раздувать `labor_write.py`. **Почему:** другая доменная область (TeamMember vs Time), другие права; `labor.py` остаётся read-only агрегатами. ### 4. Порт `Rules::isRwMember` в `labor_permissions.py` **Решение:** функции `can_write_project_member(cur, db, acting_emp_id, project_id, member_id=None, target_emp_id=None)`. Логика: | Условие | Разрешено | |---------|-----------| | `is_admin(acting)` | да | | `acting == project.director` (РП) | да | | роль acting в проекте == `MAIN_ENGINEER` (2) | да | | при update: `target_emp_id in get_sub_emp_ids(acting)` | да | | при create (member_id=None): `get_sub_emp_ids(acting)` непустой (директор отдела) | да | | иначе | нет | **Порт `getRoleInProject`:** SELECT role FROM TeamMember WHERE emp=acting AND team=project.team LIMIT 1. **Альтернатива:** только admin + РП — проще, но ломает сценарий ГИП/директора отдела из CMS. ### 5. Валидация `section_id` через `tMerakomisProjectSection` **Решение:** перед INSERT/UPDATE проверять, что `section_id` ∈ разрешённых для `project_id`. Если у проекта нет записей в `ProjectSection` — отклонять запись с `400` (как CMS: нельзя выбрать раздел). **Порт `ProjectSection::get($project_id)`** → helper `_project_section_ids(cur, db, project_id)`. ### 6. Справочник ролей — статический dict в Python **Решение:** `member_roles.py` с константами из `eMemberRole.php` (id → name, archive flag); `GET /api/member-roles` отдаёт неархивные + текущую роль при edit. **Почему:** справочник редко меняется; не тянуть PHP. При изменении ролей — обновить dict (допустимый trade-off). ### 7. Auth на write-маршрутах **Решение:** `require_api_key` + `require_acting_emp_id` (переиспользовать из `labor_identity.py` если есть, иначе тот же Depends). Read вспомогательных эндпоинтов (`project-sections`, `member-roles`) — только API-ключ, без Acting (как `GET /api/project-members`). ### 8. Ответ PUT — тот же shape, что элемент `GET /api/project-members` **Решение:** после upsert выполнить SELECT с теми же join'ами и вернуть один объект + `ok: true`. **Почему:** клиент сразу получает `member_id`, `step_name`, `section_name` без повторного GET. ## Architecture ``` Внешний сервис │ X-Api-Key │ X-Acting-Emp-Id ▼ project_members_write.py ──► labor_permissions.can_write_project_member │ project_members_read._project_section_ids ▼ MySQL: tMerakomisTeamMember (+ join Project для team_id) ``` Расширение `merakomis_schema.py`: ```python PROJECT_SECTION_TABLE = "tMerakomisProjectSection" TEAM_MEMBER_FIELDS += "text" # если есть в БД ``` ## API Contract (кратко) ### `PUT /api/project-members` | Поле | Обязательно | Описание | |------|:-----------:|----------| | `project_id` | да | id проекта | | `emp_id` | да | id сотрудника | | `section_id` | да | id из `GET /api/project-sections` | | `role` | да | id из `GET /api/member-roles` | | `active` | нет | default `true` | | `text` | нет | комментарий, default `""` | Ошибки: `400` validation, `403` forbidden, `404` project/emp not found, `409` duplicate conflict (не ожидается при upsert). ### `GET /api/project-sections?project_id=` `items[]`: `{ section_id, section_name }` — join `tMerakomisProjectSection` + `tMerakomisDSection`. ### `GET /api/member-roles?role=` `items[]`: `{ id, title }` — неархивные роли; query `role` включает архивную текущую при edit. ### `GET /api/labor/permissions` (расширение) Новые query: `project_id` (уже есть), опционально `member_id`, `target_emp_id`. Новое поле ответа: `can_write_member: bool`. ## Risks / Trade-offs | Риск | Митигация | |------|-----------| | Статический список ролей устареет | Вынести в конфиг/таблицу в follow-up; документировать sync с `eMemberRole.php` | | `isRwMember` сложнее, чем табель | Unit-тесты на матрицу ролей; сверка с PHP на staging | | Race при параллельном upsert `(team, emp)` | UNIQUE в БД; catch duplicate → retry as UPDATE | | Запись section не из ProjectSection | Жёсткая валидация до INSERT | | Нет инвалидации кэша | TeamMember не входит в `tMerakomisTimeCache`; кэш часов не затрагивается | ## Migration Plan 1. Деплой user-reader с новыми эндпоинтами (обратно совместимо — только добавления) 2. Обновить документацию и выдать API-ключ интегратору 3. Staging: сравнить upsert с ручным добавлением в CMS для РП/ГИП/директора отдела 4. Rollback: откат образа user-reader; данные в MySQL остаются (записи не удаляются) ## Open Questions 1. Нужен ли `GET /api/project-members` с `active_only=false` по умолчанию для интеграции — **нет изменений в v1** 2. Проверять ли `emp.archive` / `emp.removed` при добавлении — **да, отклонять 400** (как фильтр в форме CMS) 3. Поле `portal` в TeamMember — **писать 0** (default), как в time-entries