36 KiB
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, строки 314–318):
$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):
Emp::getSubEmps(acting)— подчинённые по оргструктуре (директора отделов)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 (ключи сохраняются):
{
"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— полный порт RulesPUT /api/time-entries— алгоритм addFromCalendar + _insertPUT /api/absences— setAbsence + afterUaddPUT /api/absences/range— setDaysGET /api/time-calendar— getTimeTableGET /api/time-summary— getMySummaryGET /api/calendar-days,/api/absence-typesGET /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 на стороне внешнего сервиса |