Expose project_id across labor and calendar endpoints for unambiguous project matching. Use only project_id in /api/projects and /api/project-report; update docs, UI tables, and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
473 lines
20 KiB
Python
473 lines
20 KiB
Python
"""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 t.{_quote_ident(t_proj)} AS project_id,
|
||
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[int, dict[str, Any]] = {}
|
||
for row in cur.fetchall():
|
||
pid = int(row["project_id"] or 0)
|
||
title = row.get("code") or row.get("name") or str(pid or "?")
|
||
dur = float(row["dur"] or 0)
|
||
if int(row["is_over"] or 0):
|
||
over += dur
|
||
else:
|
||
hours += dur
|
||
if pid not in proj_acc:
|
||
ci = len(proj_acc) + 1
|
||
proj_acc[pid] = {
|
||
"project_id": pid,
|
||
"title": title,
|
||
"value": 0.0,
|
||
"class": f"chart-color{ci}",
|
||
}
|
||
proj_acc[pid]["value"] += dur
|
||
block["project"] = list(proj_acc.values())
|
||
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_id": project_id or None,
|
||
"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,
|
||
}
|