diff --git a/DEVELOPERS.md b/DEVELOPERS.md index efc9e483..8077c0b4 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -196,6 +196,10 @@ uvicorn app.main:app --host 0.0.0.0 --port 8090 | `2` | Завершён | | `3` | Пауза | +### Id проекта (`project_id`) + +Во всех read-ответах с данными проекта обязательно есть **`project_id`**. Поле `id` в **`/api/work-report`** — это id **сотрудника**; id проекта — только **`project_id`**. В **`/api/projects`** и **`/api/project-report`** — только **`project_id`**. + > **Не путать:** поле `step` в **`tMerakomisDSection`** — фильтр разделов по стадии, это другая сущность. ### Переработки (`is_over`) @@ -352,7 +356,7 @@ loop: | `include_removed` | `false` | Включать `removed = 1` | | `include_archive` | `true` | Не фильтровать архивные | -**Поля `items`:** `id`, `code`, `name`, `director`, `step` (id стадии), `status`, `status_name`, `team`, `archive`, `archive_date`, `removed`, `date`, `date_end`. +**Поля `items`:** **`project_id`**, `code`, `name`, `director`, `step` (id стадии), `status`, `status_name`, `team`, `archive`, `archive_date`, `removed`, `date`, `date_end`. Для человекочитаемой стадии и статуса используйте join через `/api/project-members` или агрегаты ниже (`step_name`, `status_name`). @@ -450,7 +454,7 @@ Upsert участника команды: если `(team, emp)` уже есть | `is_over` | `0` — рабочие, `1` — переработка | | `limit` | 1…2000 (по умолчанию 500) | -**Поля:** `id`, `emp`, `project`, `date`, `duration`, `is_over`. +**Поля:** `id`, `emp`, **`project_id`**, `project`, `date`, `duration`, `is_over`. ### `GET /api/work-report` — защищённый ⭐ основной контракт @@ -474,6 +478,7 @@ Upsert участника команды: если `(team, emp)` уже есть | `department` | string \| null | Орготдел (коды через запятую) | | `staffing_title` | string \| null | Должность (Архитектор, ГИП…) | | `section` | string \| null | Раздел в проекте | +| **`project_id`** | int | Id проекта | | `project_code` | string | Код проекта | | **`step_name`** | string | Стадия (ПД, РД…) | | **`status`** | int \| null | Код статуса проекта | @@ -492,6 +497,7 @@ Upsert участника команды: если `(team, emp)` уже есть "department": "ОВ", "staffing_title": "Инженер", "section": "ОВ1", + "project_id": 86, "project_code": "2025-0016", "step_name": "РД", "status": 1, @@ -520,7 +526,7 @@ Upsert участника команды: если `(team, emp)` уже есть | `project_id` | Фильтр по проекту | | `limit`, `offset` | Пагинация (1…2000) | -**Поля `items`:** `id` (project_id), `project_code`, **`step_name`**, **`status`**, **`status_name`**, **`archive`**, **`archive_date`**, `project_name`, `department`, `section`, `hours`, `over`, `over1`, `over2`, `total`. +**Поля `items`:** **`project_id`**, `project_code`, **`step_name`**, **`status`**, **`status_name`**, **`archive`**, **`archive_date`**, `project_name`, `department`, `section`, `hours`, `over`, `over1`, `over2`, `total`. Одна строка = уникальная тройка **проект + department + section**. @@ -648,7 +654,7 @@ Upsert участника команды: если `(team, emp)` уже есть ### C. Список проектов сотрудника для UI -`GET /api/project-members?emp_id={id}&active_only=true` — в ответе `project_code`, `step_name`, `status_name`, `archive`, `project_name`. +`GET /api/project-members?emp_id={id}&active_only=true` — в ответе **`project_id`**, `project_code`, `step_name`, `status_name`, `archive`, `project_name`. ### D. Запись часов из мобильного/бота diff --git a/docs/user-reader-api.md b/docs/user-reader-api.md index 2601ccc9..ed5d49f8 100644 --- a/docs/user-reader-api.md +++ b/docs/user-reader-api.md @@ -207,6 +207,20 @@ loop: | `archive` | `/api/projects`, эндпоинты с `project_name` | `0` — активный, `1` — в архиве | | `archive_date` | `/api/projects`, эндпоинты с `project_name` | Дата архивации (`YYYY-MM-DD`) или `null` | +### Id проекта (`project_id`) + +Во всех ответах, где есть **`project_code`**, **`project_name`** или поле **`project`** (сырой табель), дополнительно отдаётся **`project_id`** — числовой id из `tMerakomisProject`. Используйте его для однозначной идентификации проекта (код может повторяться в истории). + +| Эндпоинт | Поле с id проекта | +|----------|-------------------| +| `GET /api/projects` | **`project_id`** | +| `GET /api/project-members`, `GET /api/labor-summary` | **`project_id`** | +| `GET /api/work-report` | **`project_id`** (`id` — это `emp_id`) | +| `GET /api/project-report` | **`project_id`** | +| `GET /api/time-entries` | **`project_id`** и `project` (оба — id проекта) | +| `GET /api/time-calendar` | **`project_id`** в корне ответа (если фильтр по проекту) | +| `GET /api/time-summary` | **`project_id`** в элементах массива `project[]` | + Коды **`status`** (порт `eStatus.php`): | `status` | `status_name` | @@ -370,7 +384,7 @@ since = сохранённый_у_себя_маркер # 0 при первом | `include_removed` | `false` | Включать удалённые | | `include_archive` | `true` | Не скрывать архивные | -**Поля `items`:** `id`, `code`, `name`, `director`, `step`, `status`, `status_name`, `team`, `archive`, `archive_date`, `removed`, `date`, `date_end`. +**Поля `items`:** **`project_id`**, `code`, `name`, `director`, `step`, `status`, `status_name`, `team`, `archive`, `archive_date`, `removed`, `date`, `date_end`. --- @@ -455,7 +469,7 @@ since = сохранённый_у_себя_маркер # 0 при первом | `limit` | `500` | 1…2000 | | `offset` | `0` | | -**Поля `items`:** `id`, `emp`, `project`, `date`, `duration`, `is_over`. +**Поля `items`:** `id`, `emp`, **`project_id`**, `project`, `date`, `duration`, `is_over`. Ошибка `400`, если `date_from > date_to`. @@ -483,6 +497,7 @@ since = сохранённый_у_себя_маркер # 0 при первом | `department` | string \| null | Орготдел | | `staffing_title` | string \| null | Должность (Архитектор, ГИП…) | | `section` | string \| null | Раздел в проекте | +| **`project_id`** | int | Id проекта Merakomis | | `project_code` | string | | | `step_name` | string | Стадия | | `status` | int \| null | Код статуса проекта | @@ -510,7 +525,7 @@ since = сохранённый_у_себя_маркер # 0 при первом | `project_id` | | Фильтр | | `limit`, `offset` | | 1…2000 | -**Поля `items`:** `id` (project_id), `project_code`, `step_name`, `status`, `status_name`, `archive`, `archive_date`, `project_name`, `department`, `section`, `hours`, `over`, `over1`, `over2`, `total`. +**Поля `items`:** **`project_id`**, `project_code`, `step_name`, `status`, `status_name`, `archive`, `archive_date`, `project_name`, `department`, `section`, `hours`, `over`, `over1`, `over2`, `total`. --- diff --git a/services/user-reader/app/labor.py b/services/user-reader/app/labor.py index c2e158b1..c92facc2 100644 --- a/services/user-reader/app/labor.py +++ b/services/user-reader/app/labor.py @@ -24,6 +24,11 @@ from app.main import ( ) from app.emp_departments import load_emp_departments from app.emp_staffing import load_emp_staffing_titles +from app.project_fields import ( + ensure_project_id, + ensure_project_ids, + project_catalog_items, +) from app.project_status import ( enrich_project_status_fields, project_status_name, @@ -424,9 +429,9 @@ def projects( cur.execute(f"SELECT COUNT(*) AS n FROM {tq}{where_sql}") total = cur.fetchone()["n"] - items = [ + items = project_catalog_items([ enrich_project_status_fields(row) for row in json_rows(rows) - ] + ]) return { "physical_table": table, "total": int(total), @@ -642,9 +647,9 @@ def project_members( "offset": resp_offset, "fetch_all": fetch_all, "count": len(rows), - "items": [ + "items": ensure_project_ids([ enrich_project_status_fields(row) for row in json_rows(rows) - ], + ]), } except pymysql.Error as e: raise HTTPException(status_code=500, detail=str(e)) from e @@ -754,7 +759,7 @@ def time_entries( "date_from": d_from.isoformat() if d_from else None, "date_to": d_to.isoformat() if d_to else None, "skipped_unknown_in_db": skipped, - "items": json_rows(rows), + "items": ensure_project_ids(json_rows(rows)), } except HTTPException: raise @@ -1024,6 +1029,7 @@ def work_report( "department": it.get("department"), "staffing_title": it.get("staffing_title"), "section": it.get("section_name"), + "project_id": it["project_id"], "project_code": it.get("project_code"), "project_name": it.get("project_name"), "step_name": it.get("step_name"), @@ -1084,7 +1090,7 @@ def project_report( report = [ { - "id": it["project_id"], + "project_id": it["project_id"], "project_code": it.get("project_code"), "project_name": it.get("project_name"), "step_name": it.get("step_name"), diff --git a/services/user-reader/app/labor_calendar.py b/services/user-reader/app/labor_calendar.py index 2572f4f6..9cfbe60a 100644 --- a/services/user-reader/app/labor_calendar.py +++ b/services/user-reader/app/labor_calendar.py @@ -234,7 +234,8 @@ def time_summary( if all([t_emp, t_date, t_dur, t_over, t_proj, p_id]): cur.execute( f""" - SELECT p.{_quote_ident(p_code)} AS code, + SELECT t.{_quote_ident(t_proj)} AS project_id, + p.{_quote_ident(p_code)} AS code, p.{_quote_ident(p_name)} AS name, SUM(t.{_quote_ident(t_dur)}) AS dur, t.{_quote_ident(t_over)} AS is_over @@ -246,21 +247,25 @@ def time_summary( """, (target, begin.isoformat(), end.isoformat()), ) - proj_acc: dict[str, float] = {} + proj_acc: dict[int, dict[str, Any]] = {} for row in cur.fetchall(): - title = row.get("code") or row.get("name") or "?" + pid = int(row["project_id"] or 0) + title = row.get("code") or row.get("name") or str(pid or "?") dur = float(row["dur"] or 0) if int(row["is_over"] or 0): over += dur else: hours += dur - proj_acc[title] = proj_acc.get(title, 0) + dur - ci = 1 - for title, val in proj_acc.items(): - block["project"].append( - {"title": title, "value": val, "class": f"chart-color{ci}"} - ) - ci += 1 + if pid not in proj_acc: + ci = len(proj_acc) + 1 + proj_acc[pid] = { + "project_id": pid, + "title": title, + "value": 0.0, + "class": f"chart-color{ci}", + } + proj_acc[pid]["value"] += dur + block["project"] = list(proj_acc.values()) block["total"] = [ {"title": "Раб.", "value": hours, "class": "color-green"}, {"title": "Сверх.", "value": over, "class": "color-brown"}, @@ -439,6 +444,7 @@ def time_calendar( "day_begin": day_begin.isoformat(), "day_end": today.isoformat(), "name": _emp_name(cur, db, target), + "project_id": project_id or None, "project": project_label, "text": "", "archive": archive, diff --git a/services/user-reader/app/project_fields.py b/services/user-reader/app/project_fields.py new file mode 100644 index 00000000..3dfa4eb0 --- /dev/null +++ b/services/user-reader/app/project_fields.py @@ -0,0 +1,50 @@ +"""Единообразные поля проекта в JSON API.""" + +from __future__ import annotations + +from typing import Any + + +def ensure_project_id(item: dict[str, Any]) -> dict[str, Any]: + """Добавляет project_id, если в записи уже есть данные проекта.""" + if item.get("project_id") is not None: + return item + proj = item.get("project") + if proj is not None: + try: + item["project_id"] = int(proj) + except (TypeError, ValueError): + pass + return item + # id в work-report — emp_id; project_id задаётся явно в эндпоинте + if "employee" in item: + return item + pid = item.get("id") + if pid is None: + return item + if any( + k in item + for k in ("project_code", "project_name", "code", "director", "step", "team") + ): + try: + item["project_id"] = int(pid) + except (TypeError, ValueError): + pass + return item + + +def ensure_project_ids(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ensure_project_id(dict(it)) for it in items] + + +def project_catalog_item(item: dict[str, Any]) -> dict[str, Any]: + """Элемент GET /api/projects: только project_id, без id.""" + row = dict(item) + pid = row.pop("id", None) + if pid is not None and row.get("project_id") is None: + row["project_id"] = int(pid) + return row + + +def project_catalog_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [project_catalog_item(it) for it in items] diff --git a/services/user-reader/app/project_members_helpers.py b/services/user-reader/app/project_members_helpers.py index 0a488a4f..d678c3b4 100644 --- a/services/user-reader/app/project_members_helpers.py +++ b/services/user-reader/app/project_members_helpers.py @@ -7,6 +7,7 @@ from typing import Any from app.emp_schema import EMP_TABLE_CANONICAL from app.labor import _column_lookup, _prefixed_col, _resolve_table from app.main import _quote_ident, _table_columns, resolve_emp_table +from app.project_fields import ensure_project_id from app.project_status import enrich_project_status_fields from app.merakomis_schema import ( PROJECT_SECTION_TABLE, @@ -194,7 +195,7 @@ def fetch_member_item( row = cur.fetchone() if not row: return None - return enrich_project_status_fields({k: row[k] for k in row}) + return ensure_project_id(enrich_project_status_fields({k: row[k] for k in row})) def emp_is_active_for_team(cur, db: str, emp_id: int) -> bool: diff --git a/services/user-reader/static/project-report.html b/services/user-reader/static/project-report.html index a45cdf69..05511fdb 100644 --- a/services/user-reader/static/project-report.html +++ b/services/user-reader/static/project-report.html @@ -54,7 +54,7 @@ const STORE_KEY = 'meraproject_user_reader_api_key'; const COLUMNS = [ - { key: 'id', label: 'id' }, + { key: 'project_id', label: 'project_id', num: true }, { key: 'project_code', label: 'project_code' }, { key: 'step_name', label: 'Стадия' }, { key: 'status_name', label: 'Статус' }, diff --git a/services/user-reader/static/summary.html b/services/user-reader/static/summary.html index a724e33b..fd0f73fa 100644 --- a/services/user-reader/static/summary.html +++ b/services/user-reader/static/summary.html @@ -59,6 +59,7 @@ { key: 'staffing_title', label: 'Должность' }, { key: 'department', label: 'department' }, { key: 'section', label: 'section' }, + { key: 'project_id', label: 'project_id', num: true }, { key: 'project_code', label: 'project_code' }, { key: 'step_name', label: 'Стадия' }, { key: 'status_name', label: 'Статус' }, diff --git a/services/user-reader/tests/test_project_fields.py b/services/user-reader/tests/test_project_fields.py new file mode 100644 index 00000000..150ac4af --- /dev/null +++ b/services/user-reader/tests/test_project_fields.py @@ -0,0 +1,46 @@ +from app.project_fields import ( + ensure_project_id, + ensure_project_ids, + project_catalog_item, +) + + +def test_project_id_from_project_column(): + row = ensure_project_id({"project": 86, "date": "2026-01-01"}) + assert row["project_id"] == 86 + assert row["project"] == 86 + + +def test_project_id_from_catalog_id(): + row = ensure_project_id({"id": 96, "code": "0000-0000", "name": "Test"}) + assert row["project_id"] == 96 + + +def test_project_id_from_project_report_id(): + row = ensure_project_id({"id": 86, "project_code": "2025-0016"}) + assert row["project_id"] == 86 + + +def test_work_report_does_not_confuse_emp_id(): + row = ensure_project_id({"id": 46, "employee": "Иванов", "project_code": "X"}) + assert "project_id" not in row + + +def test_ensure_project_ids_list(): + rows = ensure_project_ids([{"project": 1}, {"id": 2, "code": "A"}]) + assert rows[0]["project_id"] == 1 + assert rows[1]["project_id"] == 2 + + +def test_project_catalog_item_uses_only_project_id(): + row = project_catalog_item( + {"id": 96, "code": "0000-0000", "name": "Test", "project_id": 96} + ) + assert row["project_id"] == 96 + assert "id" not in row + + +def test_project_catalog_item_from_id(): + row = project_catalog_item({"id": 86, "code": "2025-0016"}) + assert row["project_id"] == 86 + assert "id" not in row