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

36 KiB
Raw Blame 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):

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:

{ "emp_id": 46, "project_id": 86, "date": "2026-06-10", "time": 8, "over": 0 }

Менеджер (emp 10) вносит часы за подчинённого (emp 46):

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.

Чтение календаря подчинённого:

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:

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-эндпоинтов: эхо идентичности

Во всех ответах на запись обязательно возвращать (для отладки и аудита):

{
  "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. Внешний сервис использует существующий справочник:

# Пример: найти 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):

$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 (ключи сохраняются):

{
  "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 (основные блоки):

-- Записи времени сотрудника (опционально по проекту)
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

Тело запроса:

{
  "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:

{
  "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

{
  "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:

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:

{
  "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

{
  "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:

{
  "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 да

Ответ:

{
  "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 — справочник

Ответ:

{
  "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 опционально

Ответ:

{
  "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 времени

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:

-- Если 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:

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 Суммы за день (для лимитов)

SELECT is_over, SUM(duration) AS cc
FROM tMerakomisTime
WHERE emp = :emp AND date = :date
GROUP BY is_over;

6.5 Часы по проекту за день

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