"""Read API табеля: calendar, summary, справочники, permissions.""" from __future__ import annotations 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.labor import _column_lookup, _prefixed_col, _resolve_table, _parse_date_param from app.labor_day import get_by_range_formatted from app.labor_identity import ( ensure_emp_exists, parse_acting_emp_id, resolve_target_emp_id, ) from app.labor_permissions import ( can_read_time_calendar, can_write_project_member, can_write_time_entry, is_admin, is_write_other_table_write, ) from app.main import ( _db_name, _quote_ident, _table_columns, get_conn, require_api_key, resolve_emp_table, ) from app.merakomis_schema import ( ABSENCE_DICT_TABLE, PROJECT_TABLE, TIME_ABSENCE_TABLE, TIME_TABLE, ) router = APIRouter() def _emp_name(cur, db: str, emp_id: int) -> str: table = resolve_emp_table(cur, db) cols = _table_columns(cur, db, table) lut = _column_lookup(cols) id_col = lut.get(f"{EMP_TABLE_CANONICAL.lower()}_id") or lut.get("id") name_col = lut.get(f"{EMP_TABLE_CANONICAL.lower()}_name") or lut.get("name") if not id_col or not name_col: return str(emp_id) cur.execute( f""" SELECT {_quote_ident(name_col)} AS n FROM {_quote_ident(table)} WHERE {_quote_ident(id_col)} = %s LIMIT 1 """, (emp_id,), ) row = cur.fetchone() return str(row["n"]) if row and row.get("n") else str(emp_id) def _project_row(cur, db: str, project_id: int) -> dict | None: if not project_id: return None 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") if not id_col: return None sel = [f"{_quote_ident(id_col)} AS id"] for f in ("code", "name", "archive", "date", "team"): c = _prefixed_col(lut, PROJECT_TABLE, f) if c: sel.append(f"{_quote_ident(c)} AS {f}") cur.execute( f"SELECT {', '.join(sel)} FROM {_quote_ident(table)} WHERE {_quote_ident(id_col)} = %s LIMIT 1", (project_id,), ) return cur.fetchone() @router.get("/api/calendar-days") def calendar_days( _auth: Annotated[None, Depends(require_api_key)], date_from: str = Query(..., description="YYYY-MM-DD"), date_to: str = Query(..., description="YYYY-MM-DD"), ) -> dict[str, Any]: 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") with get_conn() as c: with c.cursor() as cur: items = get_by_range_formatted(cur, _db_name(cur), d_from, d_to) return {"date_from": d_from.isoformat(), "date_to": d_to.isoformat(), "items": items} @router.get("/api/absence-types") def absence_types( _auth: Annotated[None, Depends(require_api_key)], ) -> dict[str, Any]: with get_conn() as c: with c.cursor() as cur: db = _db_name(cur) table = _resolve_table(cur, db, ABSENCE_DICT_TABLE) cols = _table_columns(cur, db, table) lut = _column_lookup(cols) id_col = _prefixed_col(lut, ABSENCE_DICT_TABLE, "id") name_col = _prefixed_col(lut, ABSENCE_DICT_TABLE, "name") code_col = _prefixed_col(lut, ABSENCE_DICT_TABLE, "code") vis_col = _prefixed_col(lut, ABSENCE_DICT_TABLE, "vis") items: list[dict] = [{"id": 0, "title": "—", "code": ""}] if id_col and name_col: vis_sql = f" AND {_quote_ident(vis_col)} = 1" if vis_col else "" code_sql = f", {_quote_ident(code_col)} AS code" if code_col else ", '' AS code" cur.execute( f""" SELECT {_quote_ident(id_col)} AS id, {_quote_ident(name_col)} AS title {code_sql} FROM {_quote_ident(table)} WHERE 1=1 {vis_sql} ORDER BY {_quote_ident(id_col)} """ ) for row in cur.fetchall(): items.append( { "id": int(row["id"]), "title": row["title"], "code": row.get("code") or "", "vis": 1, } ) return {"items": items} @router.get("/api/labor/permissions") def labor_permissions( _auth: Annotated[None, Depends(require_api_key)], acting_emp_id: Annotated[int, Depends(parse_acting_emp_id)], target_emp_id: int = Query(..., ge=1), project_id: int | None = Query(None, ge=0), member_id: int | None = Query(None, ge=1), ) -> dict[str, Any]: with get_conn() as c: with c.cursor() as cur: db = _db_name(cur) ensure_emp_exists(cur, db, acting_emp_id, kind="acting") ensure_emp_exists(cur, db, target_emp_id, kind="target") pid = int(project_id or 0) can_member = False if pid: can_member = can_write_project_member( cur, db, acting_emp_id, pid, member_id=member_id, target_emp_id=target_emp_id, ) return { "acting_emp_id": acting_emp_id, "target_emp_id": target_emp_id, "project_id": pid or None, "member_id": member_id, "can_read": can_read_time_calendar( cur, db, acting_emp_id, target_emp_id, pid or None ), "can_write_time": can_write_time_entry( cur, db, acting_emp_id, target_emp_id, pid ) if pid else False, "can_write_absence": can_read_time_calendar( cur, db, acting_emp_id, target_emp_id, pid or None ), "can_write_member": can_member, "is_admin": is_admin(cur, db, acting_emp_id), "is_delegate_writer": is_write_other_table_write( cur, db, acting_emp_id, target_emp_id ), } @router.get("/api/time-summary") def time_summary( _auth: Annotated[None, Depends(require_api_key)], acting_emp_id: Annotated[int, Depends(parse_acting_emp_id)], emp_id: int | None = Query(None, ge=1), ) -> dict[str, Any]: target = resolve_target_emp_id(acting_emp_id, emp_id) today = date.today() periods = { "month": (date(today.year, today.month, 1), today), "year": (date(today.year, 1, 1), today), } res: dict[str, Any] = {} with get_conn() as c: with c.cursor() as cur: db = _db_name(cur) ensure_emp_exists(cur, db, acting_emp_id, kind="acting") ensure_emp_exists(cur, db, target, kind="target") if not can_read_time_calendar(cur, db, acting_emp_id, target): raise HTTPException(status_code=403, detail={"code": "forbidden", "message": "Нет прав"}) tt = _resolve_table(cur, db, TIME_TABLE) pt = _resolve_table(cur, db, PROJECT_TABLE) tcols = _column_lookup(_table_columns(cur, db, tt)) pcols = _column_lookup(_table_columns(cur, db, pt)) t_emp = _prefixed_col(tcols, TIME_TABLE, "emp") t_date = _prefixed_col(tcols, TIME_TABLE, "date") t_dur = _prefixed_col(tcols, TIME_TABLE, "duration") t_over = _prefixed_col(tcols, TIME_TABLE, "is_over") t_proj = _prefixed_col(tcols, TIME_TABLE, "project") p_id = _prefixed_col(pcols, PROJECT_TABLE, "id") p_code = _prefixed_col(pcols, PROJECT_TABLE, "code") p_name = _prefixed_col(pcols, PROJECT_TABLE, "name") for key, (begin, end) in periods.items(): block: dict[str, Any] = { "project": [], "total": [], "absence": [], "title": f"{begin.year}" if key == "year" else f"{begin.month:02d}.{begin.year}", } hours = 0.0 over = 0.0 if all([t_emp, t_date, t_dur, t_over, t_proj, p_id]): cur.execute( f""" SELECT p.{_quote_ident(p_code)} AS code, p.{_quote_ident(p_name)} AS name, SUM(t.{_quote_ident(t_dur)}) AS dur, t.{_quote_ident(t_over)} AS is_over FROM {_quote_ident(tt)} t LEFT JOIN {_quote_ident(pt)} p ON p.{_quote_ident(p_id)} = t.{_quote_ident(t_proj)} WHERE t.{_quote_ident(t_emp)} = %s AND t.{_quote_ident(t_date)} BETWEEN %s AND %s GROUP BY t.{_quote_ident(t_proj)}, t.{_quote_ident(t_over)} """, (target, begin.isoformat(), end.isoformat()), ) proj_acc: dict[str, float] = {} for row in cur.fetchall(): title = row.get("code") or row.get("name") or "?" dur = float(row["dur"] or 0) if int(row["is_over"] or 0): over += dur else: hours += dur proj_acc[title] = proj_acc.get(title, 0) + dur ci = 1 for title, val in proj_acc.items(): block["project"].append( {"title": title, "value": val, "class": f"chart-color{ci}"} ) ci += 1 block["total"] = [ {"title": "Раб.", "value": hours, "class": "color-green"}, {"title": "Сверх.", "value": over, "class": "color-brown"}, ] res[key] = block res["text"] = "" return res @router.get("/api/time-calendar") def time_calendar( _auth: Annotated[None, Depends(require_api_key)], acting_emp_id: Annotated[int, Depends(parse_acting_emp_id)], emp_id: int | None = Query(None, ge=1), project_id: int = Query(0, ge=0), ) -> dict[str, Any]: target = resolve_target_emp_id(acting_emp_id, emp_id) today = date.today() with get_conn() as c: with c.cursor() as cur: db = _db_name(cur) ensure_emp_exists(cur, db, acting_emp_id, kind="acting") ensure_emp_exists(cur, db, target, kind="target") if not can_read_time_calendar(cur, db, acting_emp_id, target, project_id or None): raise HTTPException(status_code=403, detail={"code": "forbidden", "message": "Нет прав"}) can_edit = False archive = False project_label = "Сводный табель" project_date = None day_begin = date(2000, 1, 1) if project_id: prow = _project_row(cur, db, project_id) if not prow: raise HTTPException(status_code=404, detail={"code": "project_not_found", "message": "Проект не найден"}) project_label = prow.get("code") or prow.get("name") or str(project_id) project_date = prow.get("date") if project_date: if isinstance(project_date, date): day_begin = project_date else: day_begin = date.fromisoformat(str(project_date)[:10]) can_edit = can_write_time_entry( cur, db, acting_emp_id, target, project_id ) archive = bool(int(prow.get("archive") or 0)) tt = _resolve_table(cur, db, TIME_TABLE) tcols = _column_lookup(_table_columns(cur, db, tt)) t_emp = _prefixed_col(tcols, TIME_TABLE, "emp") t_date = _prefixed_col(tcols, TIME_TABLE, "date") t_dur = _prefixed_col(tcols, TIME_TABLE, "duration") t_over = _prefixed_col(tcols, TIME_TABLE, "is_over") t_proj = _prefixed_col(tcols, TIME_TABLE, "project") dates: dict[str, dict[str, float]] = {} base: dict[str, dict[str, float]] = {} total_hours = 0.0 total_over = 0.0 totals: dict[str, dict[str, str]] = {} if all([t_emp, t_date, t_dur, t_over]): conds = [f"{_quote_ident(t_emp)} = %s", f"{_quote_ident(t_dur)} > 0"] params: list[Any] = [target] if project_id and t_proj: conds.append(f"{_quote_ident(t_proj)} = %s") params.append(project_id) cur.execute( f""" SELECT {_quote_ident(t_date)} AS d, {_quote_ident(t_dur)} AS duration, {_quote_ident(t_over)} AS is_over, {_quote_ident(t_proj)} AS project FROM {_quote_ident(tt)} WHERE {' AND '.join(conds)} """, tuple(params), ) for row in cur.fetchall(): ds = row["d"].isoformat() if isinstance(row["d"], date) else str(row["d"])[:10] dur = float(row["duration"] or 0) key = "over" if int(row["is_over"] or 0) else "hours" dates.setdefault(ds, {"hours": 0.0, "over": 0.0}) dates[ds][key] += dur if not project_id: base.setdefault(ds, {"hours": 0.0, "over": 0.0}) base[ds][key] += dur if key == "hours": total_hours += dur else: total_over += dur cur.execute( f""" SELECT {_quote_ident(t_date)} AS d, {_quote_ident(t_over)} AS is_over, SUM({_quote_ident(t_dur)}) AS cc FROM {_quote_ident(tt)} WHERE {_quote_ident(t_emp)} = %s GROUP BY {_quote_ident(t_date)}, {_quote_ident(t_over)} """, (target,), ) for row in cur.fetchall(): ds = row["d"].isoformat() if isinstance(row["d"], date) else str(row["d"])[:10] key = "over" if int(row["is_over"] or 0) else "hours" base.setdefault(ds, {"hours": 0.0, "over": 0.0}) base[ds][key] += float(row["cc"] or 0) days = get_by_range_formatted(cur, db, day_begin, today) events: dict[str, list[str]] = {} for ds, info in days.items(): if info.get("text"): events.setdefault(ds, []).append(info["text"]) absence: dict[str, dict] = {} at = _resolve_table(cur, db, TIME_ABSENCE_TABLE) acols = _column_lookup(_table_columns(cur, db, at)) a_emp = _prefixed_col(acols, TIME_ABSENCE_TABLE, "emp") a_date = _prefixed_col(acols, TIME_ABSENCE_TABLE, "date") a_type = _prefixed_col(acols, TIME_ABSENCE_TABLE, "absence") if a_emp and a_date and a_type: dt = _resolve_table(cur, db, ABSENCE_DICT_TABLE) dcols = _column_lookup(_table_columns(cur, db, dt)) d_id = _prefixed_col(dcols, ABSENCE_DICT_TABLE, "id") d_name = _prefixed_col(dcols, ABSENCE_DICT_TABLE, "name") d_code = _prefixed_col(dcols, ABSENCE_DICT_TABLE, "code") cur.execute( f""" SELECT a.{_quote_ident(a_date)} AS d, a.{_quote_ident(a_type)} AS absence_id, d.{_quote_ident(d_name)} AS title, d.{_quote_ident(d_code)} AS code FROM {_quote_ident(at)} a LEFT JOIN {_quote_ident(dt)} d ON d.{_quote_ident(d_id)} = a.{_quote_ident(a_type)} WHERE a.{_quote_ident(a_emp)} = %s AND a.{_quote_ident(a_type)} <> 0 """, (target,), ) for row in cur.fetchall(): ds = row["d"].isoformat() if isinstance(row["d"], date) else str(row["d"])[:10] absence[ds] = { "id": int(row["absence_id"] or 0), "title": row.get("title") or "", "code": row.get("code") or "", "absence_id": int(row["absence_id"] or 0), } absence_options: list[dict] = [{"id": 0, "title": "—"}] dt = _resolve_table(cur, db, ABSENCE_DICT_TABLE) dcols = _column_lookup(_table_columns(cur, db, dt)) d_id = _prefixed_col(dcols, ABSENCE_DICT_TABLE, "id") d_name = _prefixed_col(dcols, ABSENCE_DICT_TABLE, "name") d_vis = _prefixed_col(dcols, ABSENCE_DICT_TABLE, "vis") if d_id and d_name: vis_sql = f" AND {_quote_ident(d_vis)} = 1" if d_vis else "" cur.execute( f""" SELECT {_quote_ident(d_id)} AS id, {_quote_ident(d_name)} AS title FROM {_quote_ident(dt)} WHERE 1=1 {vis_sql} ORDER BY {_quote_ident(d_id)} """ ) for row in cur.fetchall(): absence_options.append( {"id": int(row["id"]), "title": row["title"]} ) return { "can_edit": can_edit, "cant_edit": not can_edit, "dates": dates, "days": days, "day_begin": day_begin.isoformat(), "day_end": today.isoformat(), "name": _emp_name(cur, db, target), "project": project_label, "text": "", "archive": archive, "month": {}, "absence": absence, "absence_stat": {}, "absence_options": absence_options, "project_date": ( project_date.isoformat() if isinstance(project_date, date) else (str(project_date)[:10] if project_date else None) ), "events": events, "base": base, "graph1": {}, "graph2": {}, "totals": totals, "total": { "hours": f"{total_hours:g} час.", "over": f"{total_over:g} час.", "total": f"{total_hours + total_over:g} час.", }, "acting_emp_id": acting_emp_id, "target_emp_id": target, }