meraproject/docs/change-proposal-labor-api-write.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

933 lines
36 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.

# 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: <emp_id из шага 2>
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`, строки 314318):
```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 | нет | текущий | 112 |
**Заголовки:** `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` на стороне внешнего сервиса |