meraproject/docs/change-proposal-labor-api-write.md

933 lines
36 KiB
Markdown
Raw Normal View History

# 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` на стороне внешнего сервиса |