meraproject/services/user-reader/app/labor_calendar.py
keboss-m 9fa9d5e3ed Add project_id to read API responses and remove duplicate id fields.
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>
2026-07-07 19:01:33 +03:00

473 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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