"""Трудозатраты: проекты, разделы, команды, часы (с переработками, без денег).""" 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_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 = [ 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": [ 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": 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_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 = [ { "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