9.2 KiB
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:
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
- Деплой user-reader с новыми эндпоинтами (обратно совместимо — только добавления)
- Обновить документацию и выдать API-ключ интегратору
- Staging: сравнить upsert с ручным добавлением в CMS для РП/ГИП/директора отдела
- Rollback: откат образа user-reader; данные в MySQL остаются (записи не удаляются)
Open Questions
- Нужен ли
GET /api/project-membersсactive_only=falseпо умолчанию для интеграции — нет изменений в v1 - Проверять ли
emp.archive/emp.removedпри добавлении — да, отклонять 400 (как фильтр в форме CMS) - Поле
portalв TeamMember — писать 0 (default), как в time-entries