Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
"""Отчёт «По сотрудникам»: количество и качество выполненных нарядов и задач за период, трудоёмкость; список и календарь.
|
||||
|
||||
Количество: выполненные наряды (исполнитель, дата завершения в периоде) и задачи/поручения (исполнитель, день задачи в периоде).
|
||||
Качество: доля выполненного в срок, просрочки, возвраты нарядов с проверки, отклонения от нормы в чек-листах, отменённые
|
||||
и приостановленные задачи. Трудоёмкость, ч: у задачи — норма, если указана, иначе фактическое время в статусе «в работе»;
|
||||
у наряда — фактическое время от начала до сдачи (не больше 24 ч на наряд)."""
|
||||
import csv
|
||||
import io
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from fpdf import FontFace
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from . import config
|
||||
from .models import DailyTask, Department, Order, OrderItem, Position, TaskEvent, User
|
||||
from .reports import FONTS, Report, fmt_dt, safe_cell
|
||||
|
||||
MAX_ORDER_HOURS = 24.0
|
||||
|
||||
|
||||
def _hours(a: datetime | None, b: datetime | None, cap: float = MAX_ORDER_HOURS) -> float:
|
||||
if not a or not b or b <= a:
|
||||
return 0.0
|
||||
return min(cap, (b - a).total_seconds() / 3600)
|
||||
|
||||
|
||||
def _task_fact_hours(events: list[TaskEvent], end: datetime) -> float:
|
||||
"""Сумма интервалов, когда задача была «в работе»."""
|
||||
total, started = 0.0, None
|
||||
for e in sorted(events, key=lambda x: (x.at, x.id)):
|
||||
if e.to_status == "in_progress" and e.kind != "transfer":
|
||||
started = started or e.at
|
||||
elif started and e.to_status != "in_progress":
|
||||
total += _hours(started, e.at, cap=1e9)
|
||||
started = None
|
||||
if started:
|
||||
total += _hours(started, min(end, config.now()), cap=1e9)
|
||||
return min(total, 24.0 * 7)
|
||||
|
||||
|
||||
def people(db: Session, start: datetime, end: datetime, department_id: int | None = None, position_id: int | None = None,
|
||||
user_id: int | None = None) -> dict:
|
||||
deps = {d.id: d for d in db.scalars(select(Department)).all()}
|
||||
|
||||
def dep_tree(root: int) -> set[int]:
|
||||
out, grown = {root}, True
|
||||
while grown:
|
||||
grown = False
|
||||
for d in deps.values():
|
||||
if d.id not in out and d.parent_id in out:
|
||||
out.add(d.id)
|
||||
grown = True
|
||||
return out
|
||||
allowed_deps = dep_tree(department_id) if department_id else None
|
||||
users = {u.id: u for u in db.scalars(select(User).order_by(User.full_name, User.username)).all()
|
||||
if (not user_id or u.id == user_id) and (not position_id or u.position_id == position_id)
|
||||
and (allowed_deps is None or u.department_id in allowed_deps)}
|
||||
first, last = start.date(), (end - timedelta(seconds=1)).date()
|
||||
days = [first + timedelta(days=i) for i in range((last - first).days + 1)]
|
||||
|
||||
def blank():
|
||||
return {"orders_done": 0, "orders_on_time": 0, "orders_overdue": 0, "returns": 0, "deviations": 0, "order_hours": 0.0,
|
||||
"tasks_total": 0, "tasks_done": 0, "tasks_on_time": 0, "tasks_cancelled": 0, "tasks_paused": 0, "tasks_open": 0,
|
||||
"task_hours": 0.0, "norm_hours": 0.0, "fact_hours": 0.0, "calendar": defaultdict(lambda: {"n": 0, "h": 0.0})}
|
||||
stat: dict[int, dict] = {uid: blank() for uid in users}
|
||||
|
||||
# наряды: выполнены в периоде
|
||||
orders = db.scalars(select(Order).where(Order.status == "completed", Order.completed_at >= start, Order.completed_at < end,
|
||||
Order.executor_id.in_(list(users) or [-1]))).all()
|
||||
for o in orders:
|
||||
s = stat[o.executor_id]
|
||||
s["orders_done"] += 1
|
||||
late = bool(o.due_at and (o.submitted_at or o.completed_at) > o.due_at)
|
||||
s["orders_overdue" if late else "orders_on_time"] += 1
|
||||
s["returns"] += o.returns or 0
|
||||
s["deviations"] += sum(1 for it in o.items if it.out_of_range)
|
||||
hrs = _hours(o.started_at or o.created_at, o.submitted_at or o.completed_at)
|
||||
s["order_hours"] += hrs
|
||||
cell = s["calendar"][o.completed_at.date().isoformat()]
|
||||
cell["n"] += 1
|
||||
cell["h"] += hrs
|
||||
|
||||
# задачи и поручения: день задачи в периоде
|
||||
tasks = db.scalars(select(DailyTask).where(DailyTask.day >= first, DailyTask.day <= last,
|
||||
DailyTask.assignee_id.in_(list(users) or [-1]))).all()
|
||||
evs: dict[int, list] = defaultdict(list)
|
||||
if tasks:
|
||||
for e in db.scalars(select(TaskEvent).where(TaskEvent.task_id.in_([t.id for t in tasks]))).all():
|
||||
evs[e.task_id].append(e)
|
||||
for t in tasks:
|
||||
s = stat[t.assignee_id]
|
||||
s["tasks_total"] += 1
|
||||
s["tasks_paused"] += sum(1 for e in evs[t.id] if e.to_status == "paused" and e.kind != "transfer")
|
||||
if t.status == "cancelled":
|
||||
s["tasks_cancelled"] += 1
|
||||
continue
|
||||
if t.status != "done":
|
||||
s["tasks_open"] += 1
|
||||
continue
|
||||
s["tasks_done"] += 1
|
||||
if t.finished_at and t.finished_at.date() <= t.day:
|
||||
s["tasks_on_time"] += 1
|
||||
fact = _task_fact_hours(evs[t.id], t.finished_at or end)
|
||||
hrs = t.labor_hours if t.labor_hours is not None else fact
|
||||
s["task_hours"] += hrs
|
||||
s["norm_hours" if t.labor_hours is not None else "fact_hours"] += hrs
|
||||
cell = s["calendar"][t.day.isoformat()]
|
||||
cell["n"] += 1
|
||||
cell["h"] += hrs
|
||||
|
||||
rows = []
|
||||
for uid, u in users.items():
|
||||
s = stat[uid]
|
||||
done = s["orders_done"] + s["tasks_done"]
|
||||
if not done and not s["tasks_total"] and user_id is None:
|
||||
continue # без активности за период — не показываем (если не выбран конкретный человек)
|
||||
on_time = s["orders_on_time"] + s["tasks_on_time"]
|
||||
hours = s["order_hours"] + s["task_hours"]
|
||||
rows.append({
|
||||
"user_id": uid, "name": u.full_name or u.username, "position": u.position.name if u.position else "",
|
||||
"department": deps[u.department_id].name if u.department_id in deps else "",
|
||||
"done": done, "orders_done": s["orders_done"], "tasks_done": s["tasks_done"], "tasks_total": s["tasks_total"],
|
||||
"tasks_open": s["tasks_open"], "on_time_pct": round(on_time * 100 / done) if done else None,
|
||||
"overdue": s["orders_overdue"] + (s["tasks_done"] - s["tasks_on_time"]), "returns": s["returns"], "deviations": s["deviations"],
|
||||
"tasks_cancelled": s["tasks_cancelled"], "tasks_paused": s["tasks_paused"], "hours": round(hours, 1),
|
||||
"calendar": {k: {"n": v["n"], "h": round(v["h"], 1)} for k, v in sorted(s["calendar"].items())},
|
||||
})
|
||||
rows.sort(key=lambda r: (-r["done"], r["name"]))
|
||||
totals = {k: sum(r[k] for r in rows) for k in ("done", "orders_done", "tasks_done", "overdue", "returns", "deviations", "tasks_cancelled")}
|
||||
totals["hours"] = round(sum(r["hours"] for r in rows), 1)
|
||||
return {"days": [d.isoformat() for d in days], "rows": rows, "totals": totals}
|
||||
|
||||
|
||||
def to_csv(data: dict, calendar: bool = False) -> bytes:
|
||||
buf = io.StringIO()
|
||||
w = csv.writer(buf, delimiter=";")
|
||||
if calendar:
|
||||
w.writerow(["Сотрудник", *(d[8:10] + "." + d[5:7] for d in data["days"]), "Итого задач", "Итого ч"])
|
||||
for r in data["rows"]:
|
||||
cells = [f"{r['calendar'][d]['n']} / {r['calendar'][d]['h']:g} ч" if d in r["calendar"] else "" for d in data["days"]]
|
||||
w.writerow([safe_cell(r["name"]), *cells, r["done"], r["hours"]])
|
||||
else:
|
||||
w.writerow(["Сотрудник", "Должность", "Отдел", "Выполнено всего", "Нарядов", "Задач", "В срок, %", "Просрочено", "Возвраты с проверки",
|
||||
"Отклонения от нормы", "Отменено задач", "Приостановок", "Трудоёмкость, ч"])
|
||||
for r in data["rows"]:
|
||||
w.writerow([safe_cell(x) for x in (r["name"], r["position"], r["department"], r["done"], r["orders_done"], r["tasks_done"],
|
||||
"—" if r["on_time_pct"] is None else r["on_time_pct"], r["overdue"], r["returns"], r["deviations"],
|
||||
r["tasks_cancelled"], r["tasks_paused"], r["hours"])])
|
||||
return ("" + buf.getvalue()).encode("utf-8")
|
||||
|
||||
|
||||
def _fill(p):
|
||||
if p is None:
|
||||
return (240, 240, 240)
|
||||
return (220, 252, 231) if p >= 90 else (254, 243, 199) if p >= 70 else (254, 226, 226)
|
||||
|
||||
|
||||
def to_pdf(data: dict, start: datetime, end: datetime, filters_text: list[str], who: str) -> bytes:
|
||||
pdf = Report(orientation="L", unit="mm", format="A4")
|
||||
pdf.add_font("DejaVu", "", str(FONTS / "DejaVuSans.ttf"))
|
||||
pdf.add_font("DejaVu", "B", str(FONTS / "DejaVuSans-Bold.ttf"))
|
||||
pdf.set_margins(10, 10, 10)
|
||||
pdf.set_auto_page_break(True, 14)
|
||||
pdf.alias_nb_pages()
|
||||
pdf.add_page()
|
||||
pdf.set_font("DejaVu", "B", 15)
|
||||
pdf.cell(0, 8, "Отчёт: работа сотрудников", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.set_font("DejaVu", "", 9)
|
||||
pdf.set_text_color(70)
|
||||
pdf.cell(0, 5, f"Период: {fmt_dt(start)} — {fmt_dt(end)}", new_x="LMARGIN", new_y="NEXT")
|
||||
if filters_text:
|
||||
pdf.cell(0, 5, "Фильтры: " + "; ".join(filters_text), new_x="LMARGIN", new_y="NEXT")
|
||||
t = data["totals"]
|
||||
pdf.cell(0, 5, f"Сформировано: {fmt_dt(config.now())}, {who}. Выполнено: {t['done']} (нарядов {t['orders_done']}, задач {t['tasks_done']}), "
|
||||
f"трудоёмкость {t['hours']:g} ч", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.ln(3)
|
||||
pdf.set_text_color(0)
|
||||
pdf.set_font("DejaVu", "", 8.5)
|
||||
if not data["rows"]:
|
||||
pdf.cell(0, 8, "За период выполненных работ нет.", new_x="LMARGIN", new_y="NEXT")
|
||||
return bytes(pdf.output())
|
||||
head = FontFace(emphasis="BOLD", fill_color=(230, 233, 240))
|
||||
with pdf.table(col_widths=(52, 36, 34, 17, 15, 15, 17, 17, 17, 17, 16, 18), line_height=5, headings_style=head, padding=1.2, repeat_headings=1,
|
||||
text_align=("LEFT", "LEFT", "LEFT") + ("CENTER",) * 9) as tb:
|
||||
hd = tb.row()
|
||||
for c in ("Сотрудник", "Должность", "Отдел", "Выполн.", "Наряды", "Задачи", "В срок", "Просроч.", "Возвраты", "Отклон.", "Отмены", "Часы"):
|
||||
hd.cell(c)
|
||||
for r in data["rows"]:
|
||||
row = tb.row()
|
||||
for v in (r["name"], r["position"], r["department"], str(r["done"]), str(r["orders_done"]), str(r["tasks_done"])):
|
||||
row.cell(v)
|
||||
row.cell("—" if r["on_time_pct"] is None else f"{r['on_time_pct']}%", style=FontFace(fill_color=_fill(r["on_time_pct"])))
|
||||
for v in (r["overdue"], r["returns"], r["deviations"], r["tasks_cancelled"]):
|
||||
row.cell(str(v) if v else "—")
|
||||
row.cell(f"{r['hours']:g}")
|
||||
days = data["days"]
|
||||
if len(days) <= 31:
|
||||
pdf.add_page()
|
||||
pdf.set_font("DejaVu", "B", 12)
|
||||
pdf.cell(0, 7, "Календарь: выполнено задач и нарядов / часов по дням", new_x="LMARGIN", new_y="NEXT")
|
||||
pdf.set_font("DejaVu", "", 6.5)
|
||||
w_day = (277 - 45 - 16) / max(1, len(days))
|
||||
with pdf.table(col_widths=(45, *([w_day] * len(days)), 16), line_height=4, headings_style=head, padding=0.6, repeat_headings=1,
|
||||
text_align=("LEFT",) + ("CENTER",) * (len(days) + 1)) as tb:
|
||||
hd = tb.row()
|
||||
hd.cell("Сотрудник")
|
||||
for d in days:
|
||||
hd.cell(d[8:10])
|
||||
hd.cell("Итого")
|
||||
for r in data["rows"]:
|
||||
row = tb.row()
|
||||
row.cell(r["name"])
|
||||
for d in days:
|
||||
c = r["calendar"].get(d)
|
||||
row.cell(f"{c['n']}\n{c['h']:g}ч" if c else "", style=FontFace(fill_color=(219, 234, 254)) if c else None)
|
||||
row.cell(f"{r['done']}\n{r['hours']:g}ч")
|
||||
return bytes(pdf.output())
|
||||
Reference in New Issue
Block a user