meraproject/services/user-reader/app/labor.py
keboss-m 9fa9d5e3ed 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>
2026-07-07 19:01:33 +03:00

1131 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Трудозатраты: проекты, разделы, команды, часы (с переработками, без денег)."""
from __future__ import annotations
import os
import traceback
from datetime import date
from typing import Annotated, Any
import pymysql
from fastapi import APIRouter, Depends, HTTPException, Query
from app.emp_schema import EMP_TABLE_CANONICAL
from app.main import (
_column_lookup,
_db_name,
_json_cell,
_quote_ident,
_table_columns,
get_conn,
json_rows,
require_api_key,
resolve_emp_table,
)
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,
_json_date,
)
from app.pagination_helpers import DEFAULT_FETCH_ALL_MAX, slice_page
from app.merakomis_schema import (
DAY_TABLE,
DAY_TYPE_NO_WORK,
PROJECT_FIELDS,
PROJECT_TABLE,
SECTION_FIELDS,
SECTION_TABLE,
STEP_TABLE,
TEAM_MEMBER_FIELDS,
TEAM_MEMBER_TABLE,
TIME_ABSENCE_TABLE,
TIME_FIELDS,
TIME_TABLE,
)
router = APIRouter()
def _resolve_table(cur, db: str, canonical: str) -> str:
want = canonical.lower()
cur.execute(
"""
SELECT TABLE_NAME AS n
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = %s AND LOWER(TABLE_NAME) = %s
LIMIT 1
""",
(db, want),
)
row = cur.fetchone()
if row:
return row["n"]
raise RuntimeError(f"Таблица {canonical!r} не найдена в БД {db!r}.")
def _prefixed_col(lut: dict[str, str], table_canonical: str, field: str) -> str | None:
key = f"{table_canonical.lower()}_{field.lower()}"
if key in lut:
return lut[key]
return lut.get(field.lower())
def _select_aliases(
cols: list[str], table_canonical: str, fields: tuple[str, ...]
) -> tuple[str, list[str], list[str]]:
lut = _column_lookup(cols)
parts: list[str] = []
aliases: list[str] = []
skipped: list[str] = []
for f in fields:
db_col = _prefixed_col(lut, table_canonical, f)
if not db_col:
skipped.append(f)
continue
parts.append(f"{_quote_ident(db_col)} AS {_quote_ident(f)}")
aliases.append(f)
if not parts:
raise RuntimeError(
f"В {table_canonical!r} нет полей {fields}. Колонки: {cols[:20]}"
)
return ", ".join(parts), aliases, skipped
def _http_500(e: Exception) -> HTTPException:
dbg = os.environ.get("DEBUG", "")
msg = str(e)
if dbg == "1":
msg = f"{msg}\n{traceback.format_exc()}"
return HTTPException(status_code=500, detail=msg)
def _parse_date_param(value: str | None, name: str) -> date | None:
if not value or not value.strip():
return None
try:
return date.fromisoformat(value.strip())
except ValueError as e:
raise HTTPException(
status_code=400, detail=f"{name}: ожидается YYYY-MM-DD"
) from e
def _is_weekend(d: date) -> bool:
return d.weekday() >= 5
def _load_no_work_days(cur, db: str, begin: date, end: date) -> set[str]:
table = _resolve_table(cur, db, DAY_TABLE)
cols = _table_columns(cur, db, table)
lut = _column_lookup(cols)
date_col = _prefixed_col(lut, DAY_TABLE, "date")
type_col = _prefixed_col(lut, DAY_TABLE, "type")
if not date_col or not type_col:
return set()
tq = _quote_ident(table)
dq = _quote_ident(date_col)
tyq = _quote_ident(type_col)
cur.execute(
f"""
SELECT {dq} AS d FROM {tq}
WHERE {dq} BETWEEN %s AND %s AND {tyq} = %s
""",
(begin.isoformat(), end.isoformat(), DAY_TYPE_NO_WORK),
)
out: set[str] = set()
for row in cur.fetchall():
v = row.get("d")
if isinstance(v, date):
out.add(v.isoformat())
elif v is not None:
out.add(str(v)[:10])
return out
def _load_absences(cur, db: str, begin: date, end: date) -> dict[int, set[str]]:
table = _resolve_table(cur, db, TIME_ABSENCE_TABLE)
cols = _table_columns(cur, db, table)
lut = _column_lookup(cols)
emp_col = _prefixed_col(lut, TIME_ABSENCE_TABLE, "emp")
date_col = _prefixed_col(lut, TIME_ABSENCE_TABLE, "date")
if not emp_col or not date_col:
return {}
tq = _quote_ident(table)
eq = _quote_ident(emp_col)
dq = _quote_ident(date_col)
cur.execute(
f"""
SELECT {eq} AS emp_id, {dq} AS d FROM {tq}
WHERE {dq} BETWEEN %s AND %s
""",
(begin.isoformat(), end.isoformat()),
)
out: dict[int, set[str]] = {}
for row in cur.fetchall():
emp_id = int(row["emp_id"])
v = row["d"]
ds = v.isoformat() if isinstance(v, date) else str(v)[:10]
out.setdefault(emp_id, set()).add(ds)
return out
def _classify_duration(
emp_id: int,
day_str: str,
duration: float,
is_over: bool,
no_work: set[str],
absences: dict[int, set[str]],
) -> tuple[float, float, float, float]:
"""hours, over, over1, over2 — как в themes/merakomis/time/model.php getEmpsTime."""
if not is_over:
return duration, 0.0, 0.0, 0.0
over = duration
if (
day_str in no_work
or _is_weekend(date.fromisoformat(day_str))
or day_str in absences.get(emp_id, set())
):
return 0.0, over, 0.0, over
return 0.0, over, over, 0.0
def _member_section_map(cur, db: str) -> dict[tuple[int, int], dict[str, Any]]:
"""(project_id, emp_id) -> section_id, section_name, role, active."""
member_t = _resolve_table(cur, db, TEAM_MEMBER_TABLE)
project_t = _resolve_table(cur, db, PROJECT_TABLE)
section_t = _resolve_table(cur, db, SECTION_TABLE)
m_cols = _table_columns(cur, db, member_t)
p_cols = _table_columns(cur, db, project_t)
s_cols = _table_columns(cur, db, section_t)
ml = _column_lookup(m_cols)
pl = _column_lookup(p_cols)
sl = _column_lookup(s_cols)
m_emp = _prefixed_col(ml, TEAM_MEMBER_TABLE, "emp")
m_team = _prefixed_col(ml, TEAM_MEMBER_TABLE, "team")
m_section = _prefixed_col(ml, TEAM_MEMBER_TABLE, "section")
m_role = _prefixed_col(ml, TEAM_MEMBER_TABLE, "role")
m_active = _prefixed_col(ml, TEAM_MEMBER_TABLE, "active")
p_id = _prefixed_col(pl, PROJECT_TABLE, "id")
p_team = _prefixed_col(pl, PROJECT_TABLE, "team")
s_id = _prefixed_col(sl, SECTION_TABLE, "id")
s_name = _prefixed_col(sl, SECTION_TABLE, "name")
if not all([m_emp, m_team, m_section, p_id, p_team, s_id, s_name]):
return {}
mq = _quote_ident(member_t)
pq = _quote_ident(project_t)
sq = _quote_ident(section_t)
role_sql = f", {_quote_ident(m_role)} AS role" if m_role else ", NULL AS role"
active_sql = (
f", {_quote_ident(m_active)} AS active" if m_active else ", 1 AS active"
)
cur.execute(
f"""
SELECT
{_quote_ident(p_id)} AS project_id,
{_quote_ident(m_emp)} AS emp_id,
{_quote_ident(m_section)} AS section_id,
{_quote_ident(s_name)} AS section_name
{role_sql}
{active_sql}
FROM {mq} m
INNER JOIN {pq} p ON {_quote_ident(p_team)} = {_quote_ident(m_team)}
LEFT JOIN {sq} s ON {_quote_ident(s_id)} = {_quote_ident(m_section)}
"""
)
out: dict[tuple[int, int], dict[str, Any]] = {}
for row in cur.fetchall():
key = (int(row["project_id"]), int(row["emp_id"]))
out[key] = {
"section_id": row.get("section_id"),
"section_name": row.get("section_name"),
"role": row.get("role"),
"active": bool(row.get("active")),
}
return out
def _emp_names(cur, db: str) -> dict[int, str]:
try:
emp_t = resolve_emp_table(cur, db)
cols = _table_columns(cur, db, emp_t)
lut = _column_lookup(cols)
id_col = _prefixed_col(lut, EMP_TABLE_CANONICAL, "id")
name_col = _prefixed_col(lut, EMP_TABLE_CANONICAL, "name")
if not id_col or not name_col:
return {}
cur.execute(
f"""
SELECT {_quote_ident(id_col)} AS id, {_quote_ident(name_col)} AS name
FROM {_quote_ident(emp_t)}
"""
)
return {int(r["id"]): str(r["name"] or "") for r in cur.fetchall()}
except RuntimeError:
return {}
def _step_names(cur, db: str) -> dict[int, str]:
try:
table = _resolve_table(cur, db, STEP_TABLE)
except RuntimeError:
return {}
cols = _table_columns(cur, db, table)
lut = _column_lookup(cols)
id_col = _prefixed_col(lut, STEP_TABLE, "id")
name_col = _prefixed_col(lut, STEP_TABLE, "name")
if not id_col or not name_col:
return {}
cur.execute(
f"""
SELECT
{_quote_ident(id_col)} AS id,
{_quote_ident(name_col)} AS name
FROM {_quote_ident(table)}
"""
)
return {int(r["id"]): str(r["name"] or "") for r in cur.fetchall()}
def _project_labels(cur, db: str) -> dict[int, dict[str, Any]]:
table = _resolve_table(cur, db, PROJECT_TABLE)
cols = _table_columns(cur, db, table)
lut = _column_lookup(cols)
id_col = _prefixed_col(lut, PROJECT_TABLE, "id")
code_col = _prefixed_col(lut, PROJECT_TABLE, "code")
name_col = _prefixed_col(lut, PROJECT_TABLE, "name")
step_col = _prefixed_col(lut, PROJECT_TABLE, "step")
status_col = _prefixed_col(lut, PROJECT_TABLE, "status")
archive_col = _prefixed_col(lut, PROJECT_TABLE, "archive")
archive_date_col = _prefixed_col(lut, PROJECT_TABLE, "archive_date")
if not id_col:
return {}
step_names = _step_names(cur, db)
step_sql = (
f"{_quote_ident(step_col)} AS step" if step_col else "NULL AS step"
)
status_sql = (
f"{_quote_ident(status_col)} AS status" if status_col else "NULL AS status"
)
archive_sql = (
f"{_quote_ident(archive_col)} AS archive" if archive_col else "NULL AS archive"
)
archive_date_sql = (
f"{_quote_ident(archive_date_col)} AS archive_date"
if archive_date_col
else "NULL AS archive_date"
)
cur.execute(
f"""
SELECT
{_quote_ident(id_col)} AS id,
{_quote_ident(code_col) if code_col else 'NULL'} AS code,
{_quote_ident(name_col) if name_col else 'NULL'} AS name,
{step_sql},
{status_sql},
{archive_sql},
{archive_date_sql}
FROM {_quote_ident(table)}
"""
)
out: dict[int, dict[str, Any]] = {}
for r in cur.fetchall():
step_id = int(r["step"] or 0) if r.get("step") is not None else 0
st = r.get("status")
out[int(r["id"])] = {
"code": r.get("code") or "",
"name": r.get("name") or "",
"step_name": step_names.get(step_id, "") if step_id else "",
"status": int(st) if st is not None else 0,
"status_name": project_status_name(st) or None,
"archive": bool(int(r.get("archive") or 0))
if r.get("archive") is not None
else False,
"archive_date": _json_date(r.get("archive_date")),
}
return out
@router.get("/api/labor/meta")
def labor_meta(_auth: Annotated[None, Depends(require_api_key)]) -> dict[str, Any]:
try:
with get_conn() as c:
with c.cursor() as cur:
db = _db_name(cur)
tables = {
"project": _resolve_table(cur, db, PROJECT_TABLE),
"section": _resolve_table(cur, db, SECTION_TABLE),
"team_member": _resolve_table(cur, db, TEAM_MEMBER_TABLE),
"time": _resolve_table(cur, db, TIME_TABLE),
"day": _resolve_table(cur, db, DAY_TABLE),
"time_absence": _resolve_table(cur, db, TIME_ABSENCE_TABLE),
}
return {
"database": db,
"tables": tables,
"overtime": {
"is_over_field": "is_over",
"over1": "сверхурочные в рабочий день",
"over2": "сверхурочные в выходной/праздник/день отсутствия",
},
"section_source": "tMerakomisTeamMember.section через project.team",
}
except pymysql.Error as e:
raise HTTPException(status_code=500, detail=str(e)) from e
except Exception as e:
raise _http_500(e) from e
@router.get("/api/projects")
def projects(
_auth: Annotated[None, Depends(require_api_key)],
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
include_removed: bool = Query(False),
include_archive: bool = Query(True),
) -> dict[str, Any]:
try:
with get_conn() as c:
with c.cursor() as cur:
db = _db_name(cur)
table = _resolve_table(cur, db, PROJECT_TABLE)
cols = _table_columns(cur, db, table)
select_sql, aliases, skipped = _select_aliases(
cols, PROJECT_TABLE, PROJECT_FIELDS
)
lut = _column_lookup(cols)
conds: list[str] = []
removed_col = _prefixed_col(lut, PROJECT_TABLE, "removed")
archive_col = _prefixed_col(lut, PROJECT_TABLE, "archive")
if not include_removed and removed_col:
conds.append(f"{_quote_ident(removed_col)} = 0")
if not include_archive and archive_col:
conds.append(f"{_quote_ident(archive_col)} = 0")
where_sql = (" WHERE " + " AND ".join(conds)) if conds else ""
tq = _quote_ident(table)
id_col = _prefixed_col(lut, PROJECT_TABLE, "id")
code_col = _prefixed_col(lut, PROJECT_TABLE, "code")
order_col = code_col or id_col or cols[0]
sql = (
f"SELECT {select_sql} FROM {tq}{where_sql}"
f" ORDER BY {_quote_ident(order_col)} LIMIT %s OFFSET %s"
)
cur.execute(sql, (limit, offset))
rows = cur.fetchall()
cur.execute(f"SELECT COUNT(*) AS n FROM {tq}{where_sql}")
total = cur.fetchone()["n"]
items = project_catalog_items([
enrich_project_status_fields(row) for row in json_rows(rows)
])
return {
"physical_table": table,
"total": int(total),
"limit": limit,
"offset": offset,
"skipped_unknown_in_db": skipped,
"items": items,
}
except pymysql.Error as e:
raise HTTPException(status_code=500, detail=str(e)) from e
except Exception as e:
raise _http_500(e) from e
@router.get("/api/sections")
def sections(
_auth: Annotated[None, Depends(require_api_key)],
limit: int = Query(500, ge=1, le=1000),
offset: int = Query(0, ge=0),
step: int | None = Query(None, ge=0),
) -> dict[str, Any]:
try:
with get_conn() as c:
with c.cursor() as cur:
db = _db_name(cur)
table = _resolve_table(cur, db, SECTION_TABLE)
cols = _table_columns(cur, db, table)
select_sql, aliases, skipped = _select_aliases(
cols, SECTION_TABLE, SECTION_FIELDS
)
lut = _column_lookup(cols)
conds: list[str] = []
params: list[Any] = []
if step is not None:
step_col = _prefixed_col(lut, SECTION_TABLE, "step")
if step_col:
conds.append(f"{_quote_ident(step_col)} = %s")
params.append(step)
where_sql = (" WHERE " + " AND ".join(conds)) if conds else ""
tq = _quote_ident(table)
name_col = _prefixed_col(lut, SECTION_TABLE, "name")
order_col = name_col or cols[0]
sql = (
f"SELECT {select_sql} FROM {tq}{where_sql}"
f" ORDER BY {_quote_ident(order_col)} LIMIT %s OFFSET %s"
)
cur.execute(sql, (*params, limit, offset))
rows = cur.fetchall()
cur.execute(
f"SELECT COUNT(*) AS n FROM {tq}{where_sql}", tuple(params)
)
total = cur.fetchone()["n"]
return {
"physical_table": table,
"total": int(total),
"limit": limit,
"offset": offset,
"skipped_unknown_in_db": skipped,
"items": json_rows(rows),
}
except pymysql.Error as e:
raise HTTPException(status_code=500, detail=str(e)) from e
except Exception as e:
raise _http_500(e) from e
@router.get("/api/project-members")
def project_members(
_auth: Annotated[None, Depends(require_api_key)],
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
fetch_all: bool = Query(False, description="Вернуть все строки без пагинации"),
project_id: int | None = Query(None, ge=1),
emp_id: int | None = Query(None, ge=1),
active_only: bool = Query(True),
) -> dict[str, Any]:
try:
with get_conn() as c:
with c.cursor() as cur:
db = _db_name(cur)
member_t = _resolve_table(cur, db, TEAM_MEMBER_TABLE)
project_t = _resolve_table(cur, db, PROJECT_TABLE)
section_t = _resolve_table(cur, db, SECTION_TABLE)
try:
step_t = _resolve_table(cur, db, STEP_TABLE)
except RuntimeError:
step_t = None
emp_t = resolve_emp_table(cur, db)
m_cols = _table_columns(cur, db, member_t)
p_cols = _table_columns(cur, db, project_t)
s_cols = _table_columns(cur, db, section_t)
st_cols = _table_columns(cur, db, step_t) if step_t else []
e_cols = _table_columns(cur, db, emp_t)
ml, pl, sl, stl, el = (
_column_lookup(m_cols),
_column_lookup(p_cols),
_column_lookup(s_cols),
_column_lookup(st_cols) if step_t else {},
_column_lookup(e_cols),
)
m_id = _prefixed_col(ml, TEAM_MEMBER_TABLE, "id")
m_emp = _prefixed_col(ml, TEAM_MEMBER_TABLE, "emp")
m_team = _prefixed_col(ml, TEAM_MEMBER_TABLE, "team")
m_section = _prefixed_col(ml, TEAM_MEMBER_TABLE, "section")
m_role = _prefixed_col(ml, TEAM_MEMBER_TABLE, "role")
m_active = _prefixed_col(ml, TEAM_MEMBER_TABLE, "active")
p_id = _prefixed_col(pl, PROJECT_TABLE, "id")
p_code = _prefixed_col(pl, PROJECT_TABLE, "code")
p_name = _prefixed_col(pl, PROJECT_TABLE, "name")
p_step = _prefixed_col(pl, PROJECT_TABLE, "step")
p_status = _prefixed_col(pl, PROJECT_TABLE, "status")
p_archive = _prefixed_col(pl, PROJECT_TABLE, "archive")
p_archive_date = _prefixed_col(pl, PROJECT_TABLE, "archive_date")
p_removed = _prefixed_col(pl, PROJECT_TABLE, "removed")
p_team = _prefixed_col(pl, PROJECT_TABLE, "team")
st_id = _prefixed_col(stl, STEP_TABLE, "id") if step_t else None
st_name = _prefixed_col(stl, STEP_TABLE, "name") if step_t else None
step_name_sql = (
f"COALESCE(st.{_quote_ident(st_name)}, '') AS step_name"
if st_name and step_t and st_id and p_step
else "'' AS step_name"
)
step_join_sql = (
f"""
LEFT JOIN {_quote_ident(step_t)} st
ON {_quote_ident(st_id)} = {_quote_ident(p_step)}"""
if step_t and st_id and p_step
else ""
)
s_id = _prefixed_col(sl, SECTION_TABLE, "id")
s_name = _prefixed_col(sl, SECTION_TABLE, "name")
e_id = _prefixed_col(el, EMP_TABLE_CANONICAL, "id")
e_name = _prefixed_col(el, EMP_TABLE_CANONICAL, "name")
conds = []
params: list[Any] = []
if p_removed:
conds.append(f"{_quote_ident(p_removed)} = 0")
if active_only and m_active:
conds.append(f"{_quote_ident(m_active)} = 1")
if project_id is not None and p_id:
conds.append(f"{_quote_ident(p_id)} = %s")
params.append(project_id)
if emp_id is not None and m_emp:
conds.append(f"{_quote_ident(m_emp)} = %s")
params.append(emp_id)
where_sql = (" WHERE " + " AND ".join(conds)) if conds else ""
sql = f"""
SELECT
{_quote_ident(m_id)} AS member_id,
{_quote_ident(m_emp)} AS emp_id,
{_quote_ident(e_name)} AS emp_name,
{_quote_ident(p_id)} AS project_id,
{_quote_ident(p_code)} AS project_code,
{_quote_ident(p_name)} AS project_name,
{step_name_sql},
{_quote_ident(p_status) if p_status else 'NULL'} AS status,
{_quote_ident(p_archive) if p_archive else '0'} AS archive,
{_quote_ident(p_archive_date) if p_archive_date else 'NULL'} AS archive_date,
{_quote_ident(m_section)} AS section_id,
{_quote_ident(s_name)} AS section_name,
{_quote_ident(m_role)} AS role,
{_quote_ident(m_active)} AS active
FROM {_quote_ident(member_t)} m
INNER JOIN {_quote_ident(project_t)} p
ON {_quote_ident(p_team)} = {_quote_ident(m_team)}{step_join_sql}
LEFT JOIN {_quote_ident(emp_t)} e
ON {_quote_ident(e_id)} = {_quote_ident(m_emp)}
LEFT JOIN {_quote_ident(section_t)} s
ON {_quote_ident(s_id)} = {_quote_ident(m_section)}
{where_sql}
ORDER BY {_quote_ident(p_code)}, {_quote_ident(e_name)}
"""
count_sql = f"""
SELECT COUNT(*) AS n
FROM {_quote_ident(member_t)} m
INNER JOIN {_quote_ident(project_t)} p
ON {_quote_ident(p_team)} = {_quote_ident(m_team)}
{where_sql}
"""
cur.execute(count_sql, tuple(params))
total = int(cur.fetchone()["n"])
if fetch_all:
if total > DEFAULT_FETCH_ALL_MAX:
raise HTTPException(
status_code=400,
detail={
"code": "too_many_rows",
"message": (
f"Слишком много строк ({total}); "
f"максимум {DEFAULT_FETCH_ALL_MAX}"
),
"total": total,
},
)
cur.execute(sql, tuple(params))
rows = cur.fetchall()
resp_limit, resp_offset = total, 0
else:
cur.execute(f"{sql} LIMIT %s OFFSET %s", (*params, limit, offset))
rows = cur.fetchall()
resp_limit, resp_offset = limit, offset
return {
"total": total,
"limit": resp_limit,
"offset": resp_offset,
"fetch_all": fetch_all,
"count": len(rows),
"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
except Exception as e:
raise _http_500(e) from e
@router.get("/api/time-entries")
def time_entries(
_auth: Annotated[None, Depends(require_api_key)],
limit: int = Query(500, ge=1, le=2000),
offset: int = Query(0, ge=0),
fetch_all: bool = Query(False, description="Вернуть все строки за период без пагинации"),
emp_id: int | None = Query(None, ge=1),
project_id: int | None = Query(None, ge=1),
date_from: str | None = Query(None, description="YYYY-MM-DD"),
date_to: str | None = Query(None, description="YYYY-MM-DD"),
is_over: int | None = Query(None, ge=0, le=1),
) -> dict[str, Any]:
try:
d_from = _parse_date_param(date_from, "date_from")
d_to = _parse_date_param(date_to, "date_to")
if d_from and d_to and d_from > d_to:
raise HTTPException(status_code=400, detail="date_from > date_to")
with get_conn() as c:
with c.cursor() as cur:
db = _db_name(cur)
table = _resolve_table(cur, db, TIME_TABLE)
cols = _table_columns(cur, db, table)
select_sql, aliases, skipped = _select_aliases(
cols, TIME_TABLE, TIME_FIELDS
)
lut = _column_lookup(cols)
conds: list[str] = []
params: list[Any] = []
emp_col = _prefixed_col(lut, TIME_TABLE, "emp")
proj_col = _prefixed_col(lut, TIME_TABLE, "project")
date_col = _prefixed_col(lut, TIME_TABLE, "date")
over_col = _prefixed_col(lut, TIME_TABLE, "is_over")
if emp_id is not None and emp_col:
conds.append(f"{_quote_ident(emp_col)} = %s")
params.append(emp_id)
if project_id is not None and proj_col:
conds.append(f"{_quote_ident(proj_col)} = %s")
params.append(project_id)
if d_from and date_col:
conds.append(f"{_quote_ident(date_col)} >= %s")
params.append(d_from.isoformat())
if d_to and date_col:
conds.append(f"{_quote_ident(date_col)} <= %s")
params.append(d_to.isoformat())
if is_over is not None and over_col:
conds.append(f"{_quote_ident(over_col)} = %s")
params.append(is_over)
where_sql = (" WHERE " + " AND ".join(conds)) if conds else ""
tq = _quote_ident(table)
order_parts = []
if date_col:
order_parts.append(f"{_quote_ident(date_col)} DESC")
id_col = _prefixed_col(lut, TIME_TABLE, "id")
if id_col:
order_parts.append(f"{_quote_ident(id_col)} DESC")
order_sql = ", ".join(order_parts) if order_parts else "1"
sql_base = (
f"SELECT {select_sql} FROM {tq}{where_sql}"
f" ORDER BY {order_sql}"
)
cur.execute(
f"SELECT COUNT(*) AS n FROM {tq}{where_sql}", tuple(params)
)
total = int(cur.fetchone()["n"])
if fetch_all:
if total > DEFAULT_FETCH_ALL_MAX:
raise HTTPException(
status_code=400,
detail={
"code": "too_many_rows",
"message": (
f"Слишком много строк ({total}); "
f"максимум {DEFAULT_FETCH_ALL_MAX}"
),
"total": total,
},
)
cur.execute(sql_base, tuple(params))
rows = cur.fetchall()
resp_limit, resp_offset = total, 0
else:
cur.execute(
f"{sql_base} LIMIT %s OFFSET %s", (*params, limit, offset)
)
rows = cur.fetchall()
resp_limit, resp_offset = limit, offset
return {
"physical_table": table,
"total": total,
"limit": resp_limit,
"offset": resp_offset,
"fetch_all": fetch_all,
"count": len(rows),
"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": ensure_project_ids(json_rows(rows)),
}
except HTTPException:
raise
except pymysql.Error as e:
raise HTTPException(status_code=500, detail=str(e)) from e
except Exception as e:
raise _http_500(e) from e
def _department_label(
dept_map: dict[int, list[dict[str, Any]]], emp_id: int
) -> str | None:
deps = dept_map.get(emp_id, [])
codes: list[str] = []
seen: set[str] = set()
for d in deps:
c = d.get("code")
if c and c not in seen:
seen.add(c)
codes.append(c)
return ", ".join(codes) if codes else None
def _compute_labor_summary_items(
cur,
db: str,
d_from: date,
d_to: date,
emp_id: int | None = None,
project_id: int | None = None,
) -> list[dict[str, Any]]:
table = _resolve_table(cur, db, TIME_TABLE)
cols = _table_columns(cur, db, table)
lut = _column_lookup(cols)
emp_col = _prefixed_col(lut, TIME_TABLE, "emp")
proj_col = _prefixed_col(lut, TIME_TABLE, "project")
date_col = _prefixed_col(lut, TIME_TABLE, "date")
dur_col = _prefixed_col(lut, TIME_TABLE, "duration")
over_col = _prefixed_col(lut, TIME_TABLE, "is_over")
if not all([emp_col, proj_col, date_col, dur_col, over_col]):
raise RuntimeError("В таблице времени не хватает обязательных колонок")
conds = [f"{_quote_ident(date_col)} BETWEEN %s AND %s"]
params: list[Any] = [d_from.isoformat(), d_to.isoformat()]
if emp_id is not None:
conds.append(f"{_quote_ident(emp_col)} = %s")
params.append(emp_id)
if project_id is not None:
conds.append(f"{_quote_ident(proj_col)} = %s")
params.append(project_id)
where_sql = " WHERE " + " AND ".join(conds)
cur.execute(
f"""
SELECT
{_quote_ident(emp_col)} AS emp_id,
{_quote_ident(proj_col)} AS project_id,
{_quote_ident(date_col)} AS d,
{_quote_ident(dur_col)} AS duration,
{_quote_ident(over_col)} AS is_over
FROM {_quote_ident(table)}
{where_sql}
""",
tuple(params),
)
raw = cur.fetchall()
no_work = _load_no_work_days(cur, db, d_from, d_to)
absences = _load_absences(cur, db, d_from, d_to)
sections = _member_section_map(cur, db)
emp_names = _emp_names(cur, db)
projects = _project_labels(cur, db)
staffing_titles = load_emp_staffing_titles(cur, db)
dept_map = load_emp_departments(cur, db)
agg: dict[tuple[int, int], dict[str, Any]] = {}
for row in raw:
eid = int(row["emp_id"])
pid = int(row["project_id"])
key = (eid, pid)
if key not in agg:
sec = sections.get((pid, eid), {})
pl = projects.get(pid, {})
agg[key] = {
"emp_id": eid,
"emp_name": emp_names.get(eid, ""),
"department": _department_label(dept_map, eid),
"staffing_title": staffing_titles.get(eid) or None,
"project_id": pid,
"project_code": pl.get("code", ""),
"project_name": pl.get("name", ""),
"step_name": pl.get("step_name", ""),
"status": pl.get("status", 0),
"status_name": pl.get("status_name"),
"archive": pl.get("archive", False),
"archive_date": pl.get("archive_date"),
"section_id": sec.get("section_id"),
"section_name": sec.get("section_name"),
"role": sec.get("role"),
"hours": 0.0,
"over": 0.0,
"over1": 0.0,
"over2": 0.0,
"total": 0.0,
}
d_val = row["d"]
day_str = (
d_val.isoformat() if isinstance(d_val, date) else str(d_val)[:10]
)
dur = float(row["duration"] or 0)
is_ov = bool(int(row["is_over"] or 0))
h, ov, ov1, ov2 = _classify_duration(
eid, day_str, dur, is_ov, no_work, absences
)
bucket = agg[key]
bucket["hours"] += h
bucket["over"] += ov
bucket["over1"] += ov1
bucket["over2"] += ov2
bucket["total"] += h + ov
items = sorted(
agg.values(),
key=lambda x: (x.get("emp_name") or "", x.get("project_code") or ""),
)
for it in items:
for k in ("hours", "over", "over1", "over2", "total"):
it[k] = round(it[k], 2)
return items
def _compute_project_report_items(
cur,
db: str,
d_from: date,
d_to: date,
project_id: int | None = None,
) -> list[dict[str, Any]]:
"""Агрегат часов по проект + орготдел + раздел (сумма по всем сотрудникам)."""
emp_items = _compute_labor_summary_items(
cur, db, d_from, d_to, project_id=project_id
)
agg: dict[tuple[int, str, str], dict[str, Any]] = {}
for it in emp_items:
pid = int(it["project_id"])
dept = it.get("department") or ""
sec = it.get("section_name") or ""
key = (pid, dept, sec)
if key not in agg:
agg[key] = {
"project_id": pid,
"project_code": it.get("project_code", ""),
"project_name": it.get("project_name", ""),
"step_name": it.get("step_name", ""),
"status": it.get("status", 0),
"status_name": it.get("status_name"),
"archive": it.get("archive", False),
"archive_date": it.get("archive_date"),
"department": it.get("department"),
"section": it.get("section_name"),
"hours": 0.0,
"over": 0.0,
"over1": 0.0,
"over2": 0.0,
"total": 0.0,
}
bucket = agg[key]
for k in ("hours", "over", "over1", "over2", "total"):
bucket[k] += it[k]
items = sorted(
agg.values(),
key=lambda x: (
x.get("project_code") or "",
x.get("section") or "",
x.get("department") or "",
),
)
for it in items:
for k in ("hours", "over", "over1", "over2", "total"):
it[k] = round(it[k], 2)
return items
def _parse_labor_period(
date_from: str, date_to: str
) -> tuple[date, date]:
d_from = _parse_date_param(date_from, "date_from")
d_to = _parse_date_param(date_to, "date_to")
if not d_from or not d_to:
raise HTTPException(status_code=400, detail="Нужны date_from и date_to")
if d_from > d_to:
raise HTTPException(status_code=400, detail="date_from > date_to")
return d_from, d_to
@router.get("/api/labor-summary")
def labor_summary(
_auth: Annotated[None, Depends(require_api_key)],
date_from: str = Query(..., description="YYYY-MM-DD"),
date_to: str = Query(..., description="YYYY-MM-DD"),
emp_id: int | None = Query(None, ge=1),
project_id: int | None = Query(None, ge=1),
limit: int = Query(500, ge=1, le=2000),
offset: int = Query(0, ge=0),
fetch_all: bool = Query(False, description="Вернуть все строки без пагинации"),
) -> dict[str, Any]:
"""Агрегат часов по сотрудник+проект+раздел (без денег)."""
try:
d_from, d_to = _parse_labor_period(date_from, date_to)
with get_conn() as c:
with c.cursor() as cur:
db = _db_name(cur)
items = _compute_labor_summary_items(
cur, db, d_from, d_to, emp_id, project_id
)
page, total, resp_limit, resp_offset = slice_page(
items, offset=offset, limit=limit, fetch_all=fetch_all
)
return {
"date_from": d_from.isoformat(),
"date_to": d_to.isoformat(),
"total": total,
"limit": resp_limit,
"offset": resp_offset,
"fetch_all": fetch_all,
"count": len(page),
"items": page,
}
except HTTPException:
raise
except pymysql.Error as e:
raise HTTPException(status_code=500, detail=str(e)) from e
except Exception as e:
raise _http_500(e) from e
@router.get("/api/work-report")
def work_report(
_auth: Annotated[None, Depends(require_api_key)],
date_from: str = Query(..., description="YYYY-MM-DD"),
date_to: str = Query(..., description="YYYY-MM-DD"),
emp_id: int | None = Query(None, ge=1),
project_id: int | None = Query(None, ge=1),
limit: int = Query(500, ge=1, le=2000),
offset: int = Query(0, ge=0),
fetch_all: bool = Query(False, description="Вернуть все строки без пагинации"),
) -> dict[str, Any]:
"""Сводка: сотрудник + отдел + раздел проекта + часы (страница /summary)."""
try:
d_from, d_to = _parse_labor_period(date_from, date_to)
with get_conn() as c:
with c.cursor() as cur:
db = _db_name(cur)
items = _compute_labor_summary_items(
cur, db, d_from, d_to, emp_id, project_id
)
report = [
{
"id": it["emp_id"],
"employee": it["emp_name"],
"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"),
"status": it.get("status"),
"status_name": it.get("status_name"),
"archive": it.get("archive"),
"archive_date": it.get("archive_date"),
"hours": it["hours"],
"over": it["over"],
"over1": it["over1"],
"over2": it["over2"],
"total": it["total"],
}
for it in items
]
page, total, resp_limit, resp_offset = slice_page(
report, offset=offset, limit=limit, fetch_all=fetch_all
)
return {
"date_from": d_from.isoformat(),
"date_to": d_to.isoformat(),
"total": total,
"limit": resp_limit,
"offset": resp_offset,
"fetch_all": fetch_all,
"count": len(page),
"items": page,
}
except HTTPException:
raise
except pymysql.Error as e:
raise HTTPException(status_code=500, detail=str(e)) from e
except Exception as e:
raise _http_500(e) from e
@router.get("/api/project-report")
def project_report(
_auth: Annotated[None, Depends(require_api_key)],
date_from: str = Query(..., description="YYYY-MM-DD"),
date_to: str = Query(..., description="YYYY-MM-DD"),
project_id: int | None = Query(None, ge=1),
limit: int = Query(500, ge=1, le=2000),
offset: int = Query(0, ge=0),
fetch_all: bool = Query(False, description="Вернуть все строки без пагинации"),
) -> dict[str, Any]:
"""Сводка: проект + отдел + раздел + суммарные часы (страница /project-report)."""
try:
d_from, d_to = _parse_labor_period(date_from, date_to)
with get_conn() as c:
with c.cursor() as cur:
db = _db_name(cur)
items = _compute_project_report_items(
cur, db, d_from, d_to, project_id
)
report = [
{
"project_id": it["project_id"],
"project_code": it.get("project_code"),
"project_name": it.get("project_name"),
"step_name": it.get("step_name"),
"status": it.get("status"),
"status_name": it.get("status_name"),
"archive": it.get("archive"),
"archive_date": it.get("archive_date"),
"department": it.get("department"),
"section": it.get("section"),
"hours": it["hours"],
"over": it["over"],
"over1": it["over1"],
"over2": it["over2"],
"total": it["total"],
}
for it in items
]
page, total, resp_limit, resp_offset = slice_page(
report, offset=offset, limit=limit, fetch_all=fetch_all
)
return {
"date_from": d_from.isoformat(),
"date_to": d_to.isoformat(),
"total": total,
"limit": resp_limit,
"offset": resp_offset,
"fetch_all": fetch_all,
"count": len(page),
"items": page,
}
except HTTPException:
raise
except pymysql.Error as e:
raise HTTPException(status_code=500, detail=str(e)) from e
except Exception as e:
raise _http_500(e) from e