# Change Proposal: Labor API Write — полная замена UI табеля MeraProject **Статус:** proposal (на согласование) **Дата:** 2026-06-10 **Сервис:** `services/user-reader` (расширение; далее — **Labor API**) **Цель:** перенести заполнение рабочих часов сотрудниками во внешний сервис с сохранением поведения PHP/React UI 1:1. --- ## 1. Контекст ### 1.1 Что заменяем | PHP API (`/api/{module}/{action}/`) | Назначение в UI | |-------------------------------------|-----------------| | `themes.merakomis.time/getTimeTable` | Календарь табеля (сводный + по проекту) | | `themes.merakomis.time/addFromCalendar` | Запись/изменение часов в ячейке | | `themes.merakomis.time/setAbsence` | Отметка отсутствия по выбранным датам | | `themes.merakomis.time/getSummary` | Виджет «месяц/год» на дашборде табеля | | `themes.merakomis.time.absence/setDays` | Массовая установка отсутствия (диапазон дат) | | `themes.merakomis.day/getByRange` | Производственный календарь (внутри `getTimeTable`) | ### 1.2 Что уже есть в Labor API (read) | Эндпоинт | Покрытие | |----------|----------| | `GET /api/employees` | Справочник сотрудников | | `GET /api/projects` | Справочник проектов | | `GET /api/project-members` | Состав команд | | `GET /api/time-entries` | Сырые записи времени | | `GET /api/work-report`, `/api/labor-summary` | Отчёты (не UI табеля) | ### 1.3 Вне scope этого proposal | PHP API | Причина | |---------|---------| | `themes.merakomis.time/getStat` | Аналитика/отчёты, не ввод часов | | `themes.merakomis.time/getTableData` | Админ-таблица CMS; при необходимости — отдельный proposal | | CRUD через `merakomisControllerTable` | Не используется мобильным табелём | --- ## 2. Таблицы MySQL | Логическое имя | PHP `model.php` | Уникальный ключ | |----------------|-----------------|-----------------| | Время | `tMerakomisTime` | `(project, emp, is_over, date)` | | Отсутствия | `tMerakomisTimeAbsence` | `(emp, date)` | | Кэш отчётов | `tMerakomisTimeCache` | `key` (md5) | | Производственный календарь | `tMerakomisDay` | по `date` | | Справочник отсутствий | `tMerakomisDAbsence` | `id` | | Участники команд | `tMerakomisTeamMember` | — | | Проекты | `tMerakomisProject` | `id` | | Сотрудники | `tMerakomisEmp` | `id` | ### 2.1 Поля `tMerakomisTime` | Поле | Тип | Описание | |------|-----|----------| | `id` | int AI | PK | | `emp` | int | ID сотрудника | | `project` | int | ID проекта | | `date` | date | День | | `duration` | double(10,2) | Часы (0 = удалить запись) | | `is_over` | tinyint | `0` рабочие, `1` переработка | | `portal` | int | Служебное (default `0` при записи через API) | | `account` | int | Служебное (default `0` при записи через API) | --- ## 3. Аутентификация и авторизация ### 3.1 Два уровня (как сейчас + acting user) ``` Внешний сервис ──X-Api-Key──▶ Labor API ──▶ MySQL X-Acting-Emp-Id (новое) ``` | Заголовок | Обязательность | Описание | |-----------|----------------|----------| | `X-Api-Key` или `Authorization: Bearer` | Если задан `USER_READER_API_KEY` | Service-to-service | | `X-Acting-Emp-Id` | **Обязателен** для write и permission-sensitive read | ID сотрудника, от имени которого выполняется действие (аналог `Emp::$AUTH_ID`) | ### 3.1.1 Модель идентичности (Identity model) Labor API **не** принимает логин/пароль Merakomis. Идентификация разделена на два слоя: **кто вызывает API** (доверенный сервис) и **от чьего имени выполняется действие** (сотрудник в Merakomis). #### Термины | Термин | Источник в запросе | Поле в БД | Описание | |--------|-------------------|-----------|----------| | **acting** (`acting_emp_id`) | Заголовок `X-Acting-Emp-Id` | — | Кто сейчас «залогинен» во внешнем UI. Используется только для **проверки прав**. В `tMerakomisTime` не пишется. | | **target** (`target_emp_id`) | `emp_id` в body или query | `tMerakomisTime.emp` | **За кого** создаётся/меняется запись времени или отсутствия. | **Правило по умолчанию:** если `emp_id` в body/query **не передан**, то `target_emp_id = acting_emp_id` (сотрудник работает со своим табелем). #### Аналогия с PHP UI | PHP (сессия) | Labor API | |--------------|-----------| | `Emp::$AUTH_ID` после `login` | `X-Acting-Emp-Id` | | `$_POST['emp']` или default `Emp::$AUTH_ID` | `emp_id` в body/query | | Cookie сессии | Нет; вместо неё пара API-key + acting | | `intval($_POST['emp']) ?: Emp::$AUTH_ID` в `addFromCalendar` | `target = body.emp_id ?? header.acting` | #### Поток во внешнем сервисе ``` 1. Пользователь логинится во внешнем сервисе (SSO, email+password, и т.д.) 2. Внешний сервис резолвит Merakomis emp_id: GET /api/employees → сопоставление по login / email / tg_acc 3. Внешний сервис хранит emp_id в своей сессии/JWT 4. При вызове Labor API бэкенд добавляет: X-Api-Key: <секрет сервиса> X-Acting-Emp-Id: 5. При записи за другого сотрудника (менеджер) бэкенд дополнительно передаёт emp_id в body ``` ``` ┌──────────────┐ login ┌──────────────────┐ │ Браузер │───────────────▶│ Ваш бэкенд │ │ (без ключа) │◀───────────────│ знает emp_id │ └──────────────┘ JWT/session └────────┬─────────┘ │ X-Api-Key │ X-Acting-Emp-Id │ body.emp_id (опц.) ▼ ┌──────────────────┐ │ Labor API :8090 │ └────────┬─────────┘ │ emp = target_emp_id ▼ ┌──────────────────┐ │ tMerakomisTime │ └──────────────────┘ ``` #### Безопасность | Правило | Обоснование | |---------|-------------| | `USER_READER_API_KEY` **только на бэкенде** внешнего сервиса | С ключом можно писать за любого `emp_id` | | Браузер **никогда** не шлёт `X-Api-Key` напрямую в Labor API | Клиент ходит только в ваш API | | `X-Acting-Emp-Id` выставляет **только ваш бэкенд** после своей аутентификации | Нельзя доверять `emp_id` из тела запроса браузера как «кто я» | | `emp_id` в body от браузера — только если ваш бэкенд **перепроверяет** право менеджера писать за подчинённого | Иначе подмена target | Рекомендация: для менеджерского UI ваш бэкенд принимает от фронта `target_emp_id`, сверяет с собственной моделью прав (или вызывает `GET /api/labor/permissions`), и только потом проксирует в Labor API. #### Где задаётся target (`emp_id`) по эндпоинтам | Эндпоинт | acting | target (`emp_id`) | Default target | |----------|--------|-------------------|----------------| | `PUT /api/time-entries` | заголовок | body `emp_id` | `= acting` | | `PUT /api/absences` | заголовок | body `emp_id` | `= acting` | | `PUT /api/absences/range` | заголовок | body `emp_id` | **обязателен** (как PHP `setDays`) | | `GET /api/time-calendar` | заголовок | query `emp_id` | `= acting` | | `GET /api/time-summary` | заголовок | query `emp_id` | `= acting` | | `GET /api/time-entries` | заголовок¹ | query `emp_id` | нет default² | | `GET /api/labor/permissions` | заголовок | query `target_emp_id` | **обязателен** | ¹ Для read-only справочников (`/api/employees`, `/api/projects`) `X-Acting-Emp-Id` **не обязателен** — достаточно API-key. ² Существующий `GET /api/time-entries` без `emp_id` возвращает всех; для UI табеля всегда передавайте `emp_id` явно. #### Примеры запросов **Сотрудник пишет 8 часов за себя** (acting = target): ```bash curl -s -X PUT "http://HOST:8090/api/time-entries" \ -H "X-Api-Key: local-dev-key-change-in-prod" \ -H "X-Acting-Emp-Id: 46" \ -H "Content-Type: application/json" \ -d '{ "project_id": 86, "date": "2026-06-10", "time": 8, "over": 0 }' ``` `emp_id` опущен → запись в `tMerakomisTime` с `emp = 46`. **Тот же случай с явным `emp_id`:** ```json { "emp_id": 46, "project_id": 86, "date": "2026-06-10", "time": 8, "over": 0 } ``` **Менеджер (emp 10) вносит часы за подчинённого (emp 46):** ```bash curl -s -X PUT "http://HOST:8090/api/time-entries" \ -H "X-Api-Key: local-dev-key-change-in-prod" \ -H "X-Acting-Emp-Id: 10" \ -H "Content-Type: application/json" \ -d '{ "emp_id": 46, "project_id": 86, "date": "2026-06-10", "time": 8, "over": 0 }' ``` Проверка: `can_write_time_entry(acting=10, target=46, project_id=86)` — true, если `46` в `write_other_table_write_ids(10)` или `10` — admin. **Чтение календаря подчинённого:** ```bash curl -s "http://HOST:8090/api/time-calendar?emp_id=46&project_id=86" \ -H "X-Api-Key: local-dev-key-change-in-prod" \ -H "X-Acting-Emp-Id: 10" ``` **Проверка прав до отрисовки UI:** ```bash curl -s "http://HOST:8090/api/labor/permissions?target_emp_id=46&project_id=86" \ -H "X-Api-Key: local-dev-key-change-in-prod" \ -H "X-Acting-Emp-Id: 10" ``` #### Ответ write-эндпоинтов: эхо идентичности Во всех ответах на запись **обязательно** возвращать (для отладки и аудита): ```json { "acting_emp_id": 10, "target_emp_id": 46, "ok": true } ``` #### Ошибки, связанные с идентичностью | HTTP | `detail.code` | Когда | |------|---------------|-------| | `400` | `missing_acting_emp` | Нет заголовка `X-Acting-Emp-Id` на write / permission-sensitive read | | `400` | `invalid_acting_emp` | `X-Acting-Emp-Id` не целое число или ≤ 0 | | `404` | `acting_emp_not_found` | acting нет в `tMerakomisEmp` или `removed = 1` | | `404` | `target_emp_not_found` | target нет в `tMerakomisEmp` или `removed = 1` | | `403` | `forbidden` | acting не может выполнить операцию для target | #### Резолв `login → emp_id` (вне Labor API) Labor API не содержит эндпоинта login. Внешний сервис использует существующий справочник: ```bash # Пример: найти id по login (на стороне клиента) curl -s -H "X-Api-Key: ..." "http://HOST:8090/api/employees?limit=500" \ | jq '.items[] | select(.login=="ivanov") | .id' ``` Поля для сопоставления (режим `merakomis_emp`): `id`, `login`, `email`, `name`. Кэшировать каталог на стороне внешнего сервиса; для дельты — `GET /api/employees/delta`. ### 3.2 Правила доступа (порт `themes/merakomis/time/model.php` + `emp/Rules.php`) #### `can_read_time_calendar(acting, target, project_id)` - `acting == target` → разрешено - `is_admin(acting)` → разрешено (`tMerakomisEmp.type = 1`) - `target in write_other_table_write_ids(acting)` → разрешено (см. §3.3) - иначе → `403` #### `can_write_time_entry(acting, target, project_id)` `project_id` **обязателен** и `> 0` (сводный табель `project_id=0` — только чтение, как в PHP `can_edit=false`). ``` can_write = is_admin(acting) OR ( target in write_other_table_write_ids(acting) ) OR ( acting == target AND member.active = 1 AND project.archive = 0 AND member exists for (target, project) ) ``` Источник PHP (`getTimeTable`, строки 314–318): ```php $isCanEdit = (boolval($member[Member::$ACTIVE]) and $member[Member::$EMP] == Emp::$AUTH_ID and !$project[Project::$ARCHIVE]) || Rules::isWriteOtherTableWrite($emp_id); ``` #### `can_write_absence(acting, target)` Повторяет PHP `setAbsence` / `setDays`: в PHP проверяется только `Emp::$IS_AUTH`. **Решение proposal:** разрешать, если `can_read_time_calendar(acting, target, *)` (т.е. за себя или подчинённых/админ). Ужесточение возможно отдельным решением. ### 3.3 `write_other_table_write_ids(acting)` — полный порт Объединение (уникальные int): 1. `Emp::getSubEmps(acting)` — подчинённые по оргструктуре (директора отделов) 2. `Rules::getMyCitySubEmps()` — сотрудники городов из групп `acting` `getSubEmps` требует портирования `Department::getStructure()` + `Emp::getRoles()` — **отдельный модуль** `app/labor_permissions.py`. До полного порта допустим fallback: вызов SQL-эвристики + интеграционные тесты против PHP на фикстурах. ### 3.4 Коды ошибок авторизации | HTTP | `detail.code` | Когда | |------|---------------|-------| | `401` | `unauthorized` | Нет/неверный API-ключ | | `400` | `missing_acting_emp` | Нет `X-Acting-Emp-Id` (см. §3.1.1) | | `400` | `invalid_acting_emp` | Некорректный `X-Acting-Emp-Id` | | `404` | `acting_emp_not_found` | acting не найден в `tMerakomisEmp` | | `404` | `target_emp_not_found` | target (`emp_id`) не найден в `tMerakomisEmp` | | `403` | `forbidden` | Нет прав на операцию | | `403` | `project_readonly` | `project_id=0` или архивный проект для self-write | | `403` | `not_team_member` | Self-write, но нет записи в `tMerakomisTeamMember` | --- ## 4. Общие соглашения API - Базовый URL: `http://HOST:8090` (docker-compose) - `Content-Type: application/json` для write - Даты: `YYYY-MM-DD` (ISO) - Часы: `number`, шаг после нормализации: `0`, `0.5`, `1` - Поля ответа: snake_case - Ошибки FastAPI: `{ "detail": "..." }` или `{ "detail": { "code": "...", "message": "..." } }` для бизнес-ошибок ### 4.1 Общие коды ошибок | HTTP | `code` | Когда | |------|--------|-------| | `400` | `invalid_date` | Невалидная дата | | `400` | `invalid_duration` | duration < 0 или > 24 до нормализации | | `400` | `date_range_invalid` | `begin > end` | | `400` | `missing_field` | Обязательное поле | | `404` | `project_not_found` | | | `404` | `absence_type_not_found` | | | `422` | — | Ошибка валидации Pydantic | | `500` | `db_error` | MySQL | --- ## 5. Эндпоинты (новые и расширенные) ### 5.1 `GET /api/time-calendar` — замена `getTimeTable` **PHP:** `POST themes.merakomis.time/getTimeTable` **Параметры:** | Query | Тип | Обяз. | Default | Описание | |-------|-----|-------|---------|----------| | `emp_id` | int | нет | `X-Acting-Emp-Id` | Чей табель | | `project_id` | int | нет | `0` | `0` = сводный табель | | `year` | int | нет | текущий | | | `month` | int | нет | текущий | 1–12 | **Заголовки:** `X-Api-Key`, `X-Acting-Emp-Id` **Ответ `200`:** структура совместима с PHP `getTimeTable` (ключи сохраняются): ```json { "can_edit": true, "cant_edit": false, "dates": { "2026-06-10": { "hours": 8, "over": 0 } }, "days": { "2026-06-01": { "date": "2026-06-01", "type": 1, "text": "" } }, "day_begin": "2024-01-15", "day_end": "2026-06-10", "name": "Иванов Иван", "project": "2025-0016", "text": "...", "archive": false, "month": { "2026": { "6": { "hours": 80, "over": 4, "total": 84 } } }, "absence": { "2026-06-05": { "id": 1, "title": "Отпуск", "code": "ОТ", "absence_id": 3 } }, "absence_stat": { "2026": { "6": 2 } }, "absence_options": [{ "id": 0, "title": "—" }, { "id": 3, "title": "Отпуск" }], "project_date": "2024-01-15", "events": { "2026-06-12": ["Корпоратив"] }, "base": { "2026-06-10": { "hours": 8, "over": 0 } }, "graph1": {}, "graph2": {}, "totals": { "2026-06-10": { "hours": "2025-0016: 8", "over": "" } }, "total": { "hours": "80 час.", "over": "4 час.", "total": "84 час." } } ``` **SQL (основные блоки):** ```sql -- Записи времени сотрудника (опционально по проекту) SELECT t.date, t.is_over, t.duration, p.id, p.name, p.code, p.step FROM tMerakomisTime t LEFT JOIN tMerakomisProject p ON p.id = t.project WHERE t.duration > 0 AND t.emp = :emp_id AND (:project_id = 0 OR t.project = :project_id); -- Сводка по дням (base) SELECT date, is_over, SUM(duration) AS cc FROM tMerakomisTime WHERE emp = :emp_id GROUP BY date, is_over; -- Отсутствия SELECT a.id, a.date, a.absence, d.name, d.code FROM tMerakomisTimeAbsence a LEFT JOIN tMerakomisDAbsence d ON d.id = a.absence WHERE a.emp = :emp_id AND a.absence <> 0; -- Производственный календарь SELECT date, type, text FROM tMerakomisDay WHERE date BETWEEN :day_begin AND :day_end; -- Участник команды + проект SELECT m.*, p.archive, p.date AS project_date, p.code, p.team FROM tMerakomisProject p LEFT JOIN tMerakomisTeamMember m ON m.team = p.team AND m.emp = :emp_id WHERE p.id = :project_id LIMIT 1; -- Справочник отсутствий (vis=1 + zero option) SELECT id, name, code FROM tMerakomisDAbsence WHERE vis = 1; ``` **Логика `day_begin`:** `project.date` или `'2000-01-01'`; `day_end` = `CURRENT_DATE`. **Логика `events`:** все записи `tMerakomisDay.text` по диапазону (как PHP). --- ### 5.2 `PUT /api/time-entries` — замена `addFromCalendar` **PHP:** `POST themes.merakomis.time/addFromCalendar` **Тело запроса:** ```json { "emp_id": 46, "project_id": 86, "date": "2026-06-10", "time": 7.3, "over": 0 } ``` | Поле | Тип | Обяз. | PHP-аналог | |------|-----|-------|------------| | `emp_id` | int | нет | `$_POST['emp']` (default: acting) | | `project_id` | int | да | `$_POST['project']` | | `date` | string | да | `$_POST['date']` | | `time` | number | да | `$_POST['time']` — **до** нормализации | | `over` | int 0\|1 | нет | `$_POST['over']`, default `0` | #### Алгоритм (1:1 с `controller.php` + `model.php _insert`) ``` 1. Проверить can_write_time_entry(acting, emp_id, project_id) 2. time = float(time) 3. Нормализация дробной части: x = (time - floor(time)) * 100 x <= 25 → 0 x >= 75 → 100 (т.е. +1 час) иначе → 50 (т.е. +0.5) time = floor(time) + x/100 4. time = clamp(time, 0, 24) 5. Загрузить суммы за день по emp_id и date: total_work = SUM(duration) WHERE is_over=0 total_over = SUM(duration) WHERE is_over=1 6. max_work = get_work_hours_by_date(date) -- §6.1 max_over = 24 - max_work 7. info = часы за (emp, project, date) — отдельно work и over 8. total_work -= info.hours; total_over -= info.over 9. new_work = total_work + time (если over=0) new_over = total_over + time (если over=1) 10. Если over=1 и new_over > max_over: time = max_over - total_over (= leftOverHours) Если over=0 и new_work > max_work: time = max_work - total_work (= leftWorkHours) 11. UPSERT в tMerakomisTime (§6.2) 12. Если time == 0 после шага 10: DELETE строки (как PHP _insert) 13. invalidate_time_cache(date, emp_id, project_id) (§6.3) 14. Вернуть ответ с диагностикой (как PHP) ``` **Ответ `200`:** ```json { "ok": true, "acting_emp_id": 46, "target_emp_id": 46, "id": 12345, "emp_id": 46, "project_id": 86, "date": "2026-06-10", "duration": 7.5, "is_over": 0, "info": { "hours": 7.5, "over": 0, "over1": 0, "over2": 0, "total": 7.5 }, "limits": { "max_work_hours": 8, "max_over_hours": 16, "left_work_hours": 0.5, "left_over_hours": 16, "new_work_hours": 8, "new_over_hours": 0, "clamped": true } } ``` PHP возвращал `$newWorkHours`, `$leftWorkHours` и т.д. с префиксом `$` — в API переименовать в `limits.*` (документировать breaking change для клиента). **Ошибки:** | HTTP | code | Когда | |------|------|-------| | `403` | `forbidden` | Нет `can_write` | | `400` | `invalid_date` | | | `404` | `project_not_found` | | --- ### 5.3 `PUT /api/absences` — замена `setAbsence` **PHP:** `POST themes.merakomis.time/setAbsence` ```json { "emp_id": 46, "project_id": 86, "type": 3, "dates": ["2026-06-10", "2026-06-11"] } ``` | Поле | Описание | |------|----------| | `type` | `absence_id`; `0` = снять отсутствие | | `dates` | Массив дат (не пустой) | | `project_id` | В PHP передаётся, но **не используется** в логике — опционально для совместимости | **Алгоритм (порт `Absence::uadd` + `afterUadd`):** Для каждой `date` в `dates`: ``` 1. can_write_absence(acting, emp_id) 2. UPSERT tMerakomisTimeAbsence (emp, date) ON DUPLICATE UPDATE absence=type 3. Если type == 0: - DELETE FROM tMerakomisTimeAbsence WHERE emp AND date 4. Если type != 0 (после upsert, как afterUadd): - DELETE FROM tMerakomisTime WHERE emp=:emp AND date=:date AND is_over=0 5. invalidate_time_cache(date, emp_id, project_id=NULL) ``` **SQL:** ```sql INSERT INTO tMerakomisTimeAbsence (emp, date, absence, portal, account) VALUES (:emp, :date, :type, 0, 0) ON DUPLICATE KEY UPDATE absence = VALUES(absence); -- type = 0: DELETE FROM tMerakomisTimeAbsence WHERE emp = :emp AND date = :date; -- type != 0: удалить рабочие часы за день DELETE FROM tMerakomisTime WHERE emp = :emp AND date = :date AND is_over = 0; ``` **Ответ `200`:** ```json { "ok": true, "acting_emp_id": 10, "target_emp_id": 46, "emp_id": 46, "results": [ { "date": "2026-06-10", "absence_type_id": 3, "cache_rows_deleted": 2 }, { "date": "2026-06-11", "absence_type_id": 3, "cache_rows_deleted": 1 } ] } ``` --- ### 5.4 `PUT /api/absences/range` — замена `setDays` **PHP:** `POST themes.merakomis.time.absence/setDays` ```json { "emp_id": 46, "absence_id": 3, "begin": "2026-06-10", "end": "2026-06-14" } ``` **Валидация (как `Absence::setDays`):** | Условие | code | message | |---------|------|---------| | `!begin` | `missing_begin` | Укажите дату начала | | `!end` | `missing_end` | Укажите дату окончания | | `end < begin` | `date_range_invalid` | Дата окончания должна быть больше даты начала | | `!emp_id` | `missing_emp` | Укажите сотрудника | **Алгоритм:** ``` 1. Сгенерировать список дат [begin..end] включительно 2. DELETE FROM tMerakomisTimeAbsence WHERE emp AND date IN (dates) 3. DELETE FROM tMerakomisTime WHERE emp AND date IN (dates) AND is_over=0 4. Если absence_id > 0: INSERT для каждой даты 5. Для каждой даты: invalidate_time_cache(date, emp_id, NULL) ``` **Ответ `200`:** ```json { "ok": true, "acting_emp_id": 10, "target_emp_id": 46, "e": 0, "m": "Успешно сохранено", "dates_count": 5 } ``` (`e`/`m` — совместимость с PHP notify.) --- ### 5.5 `GET /api/time-summary` — замена `getSummary` **PHP:** `POST themes.merakomis.time/getSummary` | Query | Default | |-------|---------| | `emp_id` | `X-Acting-Emp-Id` | **Ответ:** структура `Time::getMySummary` — блоки `month` и `year` с `project[]`, `total[]`, `absence[]`, `title`, `text`. **SQL:** агрегация `SUM(duration)` по проектам + `COUNT` отсутствий по типам за: - месяц: `[YYYY-MM-01, TODAY]` - год: `[YYYY-01-01, TODAY]` --- ### 5.6 `GET /api/calendar-days` — замена `day/getByRange` | Query | Обяз. | |-------|-------| | `date_from` | да | | `date_to` | да | **Ответ:** ```json { "items": { "2026-06-10": { "date": "2026-06-10", "type": 1, "text": "" } } } ``` `type`: `1` WORK, `2` NO_WORK, `3` SHORT (`EDayType`). --- ### 5.7 `GET /api/absence-types` — справочник **Ответ:** ```json { "items": [ { "id": 0, "title": "—", "code": "" }, { "id": 3, "title": "Отпуск", "code": "ОТ", "vis": 1 } ] } ``` Фильтр: `vis = 1` (+ zero option), как `DAbsence::getNameList`. --- ### 5.8 `GET /api/labor/permissions` — вспомогательный (новый) Для внешнего UI — проверка до отрисовки кнопок. | Query | Описание | |-------|----------| | `target_emp_id` | | | `project_id` | опционально | **Ответ:** ```json { "acting_emp_id": 10, "target_emp_id": 46, "project_id": 86, "can_read": true, "can_write_time": false, "can_write_absence": true, "is_admin": false, "is_delegate_writer": true } ``` --- ## 6. SQL-операции (ядро) ### 6.1 `get_work_hours_by_date(date)` — порт `Day::getWorkHoursByDate` ``` 1. SELECT * FROM tMerakomisDay WHERE date = :date LIMIT 1 2. Если строка есть: return day.hours 3. Иначе: если weekday in (Mon..Fri): return 8 иначе: return 0 ``` Константа `8` = `Day::getPerHour()`. ### 6.2 Upsert времени ```sql INSERT INTO tMerakomisTime (emp, project, date, duration, is_over, portal, account) VALUES (:emp, :project, :date, :duration, :is_over, 0, 0) ON DUPLICATE KEY UPDATE duration = VALUES(duration); ``` После insert: ```sql -- Если duration = 0: DELETE FROM tMerakomisTime WHERE emp = :emp AND project = :project AND date = :date AND is_over = :is_over; ``` ### 6.3 `invalidate_time_cache(date, emp, project)` Порт `Cache::removeRows`: ```sql DELETE FROM tMerakomisTimeCache WHERE begin <= :date AND end >= :date AND (:emp IS NULL OR emp = :emp) AND ( :project IS NULL OR project = :project OR project = 0 ); ``` ### 6.4 Суммы за день (для лимитов) ```sql SELECT is_over, SUM(duration) AS cc FROM tMerakomisTime WHERE emp = :emp AND date = :date GROUP BY is_over; ``` ### 6.5 Часы по проекту за день ```sql SELECT is_over, duration FROM tMerakomisTime WHERE emp = :emp AND project = :project AND date = :date; ``` --- ## 7. Побочные эффекты (чеклист паритета) | # | Поведение PHP | Обязательно в API | |---|---------------|-------------------| | 1 | Upsert по `(project, emp, is_over, date)` | да | | 2 | `duration=0` → DELETE | да | | 3 | Округление 0/0.5/1 | да | | 4 | Clamp 0..24 | да | | 5 | Лимит рабочих/переработки за день | да | | 6 | Инвалидация `tMerakomisTimeCache` | да | | 7 | Отсутствие удаляет `is_over=0` за день | да | | 8 | `setDays` удаляет старые absence+work перед insert | да | | 9 | Сводный табель `project_id=0` read-only | да | | 10 | Архивный проект — read-only для self | да | | 11 | `portal`/`account` = 0 | да | | 12 | Пересчёт `over1`/`over2` в отчётах | автоматически при чтении (уже в `labor.py`) | --- ## 8. Структура кода (реализация) ``` services/user-reader/app/ labor.py # существующие GET labor_write.py # PUT time-entries, absences labor_calendar.py # GET time-calendar, calendar-days, time-summary labor_permissions.py # can_write*, write_other_table_write_ids labor_cache.py # invalidate_time_cache labor_day.py # get_work_hours_by_date, is_no_work_day merakomis_schema.py # + TIME_CACHE_TABLE, ABSENCE_DICT_TABLE ``` Обновить `DEVELOPERS.md` — раздел Write API. --- ## 9. Тест-план (паритет с PHP) | # | Сценарий | Ожидание | |---|----------|----------| | 1 | 8ч рабочие в будний день | OK | | 2 | 9ч рабочие при max=8 | clamp до 8 | | 3 | 7.2ч ввод | → 7.5 | | 4 | 7.8ч ввод | → 8.0 | | 5 | Два проекта по 4ч | OK, сумма 8 | | 6 | Замена 4ч→2ч на проекте A | лимит пересчитывается с вычитанием старых часов проекта | | 7 | `duration=0` | строка удалена | | 8 | Отсутствие type=3 | рабочие часы за день удалены | | 9 | `setDays` на диапазон | старые записи очищены | | 10 | Запись в архивный проект self | 403 | | 11 | Менеджер пишет за подчинённого | OK | | 12 | После записи `work-report` | актуальные часы (кэш сброшен) | | 13 | Сводный `project_id=0` write | 403 | | 14 | Суббота без записи в Day | max_work=0 | **Метод:** параллельные запросы к PHP API (с тестовой сессией) и Labor API на копии БД; diff JSON. --- ## 10. Миграция потребителей ``` Этап 1: Реализовать API + тесты паритета Этап 2: Внешний сервис читает time-calendar, пишет time-entries Этап 3: Пилот на группе сотрудников Этап 4: Скрыть маршрут табеля в MeraProject React (feature flag) Этап 5: Мониторинг расхождений work-report ``` PHP UI **не удалять** до стабилизации; писать в ту же БД. --- ## 11. Риски | Риск | Митигация | |------|-----------| | Неполный порт `writeOtherTableWrite` | Модуль permissions + тесты против PHP | | Расхождение имён колонок (`tMerakomisTime_emp`) | `_prefixed_col` как в `labor.py` | | Двойной ввод (старый + новый UI) | Коммуникация + скрытие UI | | `Cache::removeRows` echo в PHP | Не влияет на API; SQL идентичен | --- ## 12. Задачи реализации - [ ] `labor_day.py` — календарь и норма часов - [ ] `labor_cache.py` — инвалидация кэша - [ ] `labor_permissions.py` — полный порт Rules - [ ] `PUT /api/time-entries` — алгоритм addFromCalendar + _insert - [ ] `PUT /api/absences` — setAbsence + afterUadd - [ ] `PUT /api/absences/range` — setDays - [ ] `GET /api/time-calendar` — getTimeTable - [ ] `GET /api/time-summary` — getMySummary - [ ] `GET /api/calendar-days`, `/api/absence-types` - [ ] `GET /api/labor/permissions` - [ ] Интеграционные тесты паритета - [ ] `DEVELOPERS.md` — документация write API --- ## Приложение A: маппинг полей запроса PHP → REST | PHP | REST | |-----|------| | `Emp::$AUTH_ID` (сессия) | заголовок `X-Acting-Emp-Id` | | `$_POST['emp']` | `emp_id` в body/query (`target`); если пусто → `= acting` | | PHP `$_POST` | REST | |--------------|------| | `emp` | `emp_id` | | `project` | `project_id` | | `date` | `date` | | `time` | `time` | | `over` | `over` / `is_over` в ответе | | `dates[]` | `dates` | | `type` | `type` / `absence_type_id` | | `begin` / `end` | `begin` / `end` | | `absence_id` | `absence_id` | ## Приложение B: ссылки на PHP | Логика | Файл | |--------|------| | addFromCalendar | `themes/merakomis/time/controller.php:43-128` | | Time _insert | `themes/merakomis/time/model.php:174-186` | | getTimeTable | `themes/merakomis/time/model.php:189-418` | | getMySummary | `themes/merakomis/time/model.php:75-172` | | setAbsence | `themes/merakomis/time/controller.php:130-145` | | Absence afterUadd | `themes/merakomis/time/absence/model.php:111-129` | | setDays | `themes/merakomis/time/absence/model.php:46-99` | | Cache::removeRows | `themes/merakomis/time/cache/model.php:75-87` | | Day::getWorkHoursByDate | `themes/merakomis/day/model.php:127-179` | | can_edit | `themes/merakomis/time/model.php:314-318` | | writeOtherTableWrite | `themes/merakomis/emp/Rules.php:626-647` | ## Приложение C: шпаргалка Identity model | Вопрос | Ответ | |--------|-------| | Кто вносит запись? | `X-Acting-Emp-Id` (заголовок) | | За кого вносится запись в БД? | `emp_id` в body/query → `tMerakomisTime.emp` | | Не указал `emp_id` | Пишем за себя (`target = acting`) | | Менеджер за подчинённого | `X-Acting-Emp-Id: <менеджер>`, `emp_id: <подчинённый>` | | Где login/password? | Только во внешнем сервисе; Labor API их не принимает | | Как получить `emp_id` по логину? | `GET /api/employees` на стороне внешнего сервиса |