2026-06-24 08:04:05 +00:00
|
|
|
|
"""Трудозатраты: проекты, разделы, команды, часы (с переработками, без денег)."""
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-07 16:01:33 +00:00
|
|
|
|
from app.project_fields import (
|
|
|
|
|
|
ensure_project_id,
|
|
|
|
|
|
ensure_project_ids,
|
|
|
|
|
|
project_catalog_items,
|
|
|
|
|
|
)
|
2026-06-24 08:04:05 +00:00
|
|
|
|
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"]
|
|
|
|
|
|
|
2026-07-07 16:01:33 +00:00
|
|
|
|
items = project_catalog_items([
|
2026-06-24 08:04:05 +00:00
|
|
|
|
enrich_project_status_fields(row) for row in json_rows(rows)
|
2026-07-07 16:01:33 +00:00
|
|
|
|
])
|
2026-06-24 08:04:05 +00:00
|
|
|
|
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),
|
2026-07-07 16:01:33 +00:00
|
|
|
|
"items": ensure_project_ids([
|
2026-06-24 08:04:05 +00:00
|
|
|
|
enrich_project_status_fields(row) for row in json_rows(rows)
|
2026-07-07 16:01:33 +00:00
|
|
|
|
]),
|
2026-06-24 08:04:05 +00:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-07-07 16:01:33 +00:00
|
|
|
|
"items": ensure_project_ids(json_rows(rows)),
|
2026-06-24 08:04:05 +00:00
|
|
|
|
}
|
|
|
|
|
|
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"),
|
2026-07-07 16:01:33 +00:00
|
|
|
|
"project_id": it["project_id"],
|
2026-06-24 08:04:05 +00:00
|
|
|
|
"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 = [
|
|
|
|
|
|
{
|
2026-07-07 16:01:33 +00:00
|
|
|
|
"project_id": it["project_id"],
|
2026-06-24 08:04:05 +00:00
|
|
|
|
"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
|