meraproject/services/user-reader/app/labor_calendar.py

467 lines
19 KiB
Python
Raw Normal View History

"""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,
}