meraproject/openspec/changes/project-members-write/design.md
keboss-m 5c21d25d45 Initial commit: Merakomis portal, Docker stack and user-reader API.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 11:04:05 +03:00

173 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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