Add project_id to read API responses and remove duplicate id fields.

Expose project_id across labor and calendar endpoints for unambiguous project matching. Use only project_id in /api/projects and /api/project-report; update docs, UI tables, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keboss-m 2026-07-07 19:01:33 +03:00
parent 5c21d25d45
commit 9fa9d5e3ed
9 changed files with 156 additions and 25 deletions

View File

@ -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. Запись часов из мобильного/бота

View File

@ -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`.
---

View File

@ -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"),

View File

@ -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,

View File

@ -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]

View File

@ -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:

View File

@ -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: 'Статус' },

View File

@ -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: 'Статус' },

View File

@ -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