Files
ACORE/apps/reports/it_monthly/builder.py
T
lokses 31bd4c8fa7 it_manager: справочник типовых работ в журнале WorkLog.
Категория/работа/required_details вместо свободного work_type; seed Directory, отчёт и тесты.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 13:38:07 +03:00

507 lines
19 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.
"""Месячный IT-отчёт из WorkLog (логика как в report-tool)."""
from __future__ import annotations
import calendar
import re
from collections import Counter, defaultdict
from datetime import date
from django.contrib.auth import get_user_model
from django.utils import timezone
from apps.it_manager.models import WorkLog
from apps.it_manager.report_import import default_report_tool_root, load_company, normalize_name
from apps.it_manager.work_directory import detail_labels_from_db, worklog_display_title
from apps.locations.models.base import pharmacy_object_code
from apps.reports.it_monthly.conclusions import build_conclusions
Employee = get_user_model()
MONTHS_RU = [
'', 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь',
]
INFRA_DIRECTION = 'Администрирование серверов, БД, мониторинг и безопасность'
SYSTEM_DESC_RE = re.compile(r'^([^:]{3,80}):\s+(.+)$', re.DOTALL)
# Коды аптек по юрлицам: А12, П10, Т5, Г3, Л1 (лат./кир.)
_PHARMACY_OBJECT_RE = re.compile(r'^[АAПPТTГGЛL]\d+$', re.I)
_OBJECT_LAT_TO_CYR = str.maketrans('APTGL', 'АПТГЛ')
def _is_pharmacy_object_code(code: str) -> bool:
raw = (code or '').strip().upper().translate(_OBJECT_LAT_TO_CYR)
return bool(_PHARMACY_OBJECT_RE.match(raw))
def split_work_items(text: str) -> list[str]:
"""Длинные журналы вида «а; б; в» → отдельные пункты для читаемого UI."""
text = re.sub(r'\s+', ' ', (text or '').strip())
if not text:
return []
parts = [p.strip(' .;') for p in re.split(r'\s*;\s*', text) if p.strip(' .;')]
if len(parts) <= 1:
return [text]
return [p[0].upper() + p[1:] if p else p for p in parts]
def department_head(department_id) -> Employee | None:
"""Керівник відділу — та сама евристика, що на /structure/."""
if not department_id:
return None
emps = list(
Employee.objects.filter(
is_active=True,
department_id=department_id,
user_location_type='office',
)
)
ids = {e.pk for e in emps}
managers_in_dept = {e.manager_id for e in emps if e.manager_id in ids}
candidates = [e for e in emps if e.pk in managers_in_dept]
if not candidates:
return None
return next((e for e in candidates if e.manager_id not in ids), None) or candidates[0]
def user_is_department_head(user) -> bool:
if not getattr(user, 'is_authenticated', False):
return False
head = department_head(getattr(user, 'department_id', None))
return head is not None and head.pk == user.pk
def user_can_manage_it_monthly_report(user) -> bool:
"""Черновик, публикация, шаблоны — руководитель отдела или superuser."""
if not getattr(user, 'is_authenticated', False):
return False
if getattr(user, 'is_superuser', False):
return True
return user_is_department_head(user)
def user_can_view_all_department_reports(user) -> bool:
"""Право видеть опубликованные отчёты всех отделов (не только своего)."""
if not getattr(user, 'is_authenticated', False):
return False
if getattr(user, 'is_superuser', False):
return True
return user.has_perm('reports.view_all_departments')
def user_can_view_it_monthly_report(user) -> bool:
"""Карточка IT-отчёта: руководитель; view-all; сотрудники отдела после публикации."""
if user_can_manage_it_monthly_report(user):
return True
if user_can_view_all_department_reports(user):
return True
dept_id = getattr(user, 'department_id', None)
if not dept_id:
return False
from apps.reports.models import ReportPublication
return ReportPublication.objects.filter(
report_type=ReportPublication.REPORT_IT_MONTHLY,
department_id=dept_id,
status=ReportPublication.STATUS_PUBLISHED,
).exists()
def parse_month_id(value: str | None) -> tuple[int, int]:
today = timezone.localdate()
if not value:
return today.year, today.month
m = re.fullmatch(r'(\d{4})-(\d{2})', (value or '').strip())
if not m:
return today.year, today.month
year, month = int(m.group(1)), int(m.group(2))
if not (1 <= month <= 12):
return today.year, today.month
return year, month
def month_id(year: int, month: int) -> str:
return f'{year:04d}-{month:02d}'
def available_report_months(limit: int = 24) -> list[str]:
"""YYYY-MM из журнала + текущий месяц, новые сверху."""
today = timezone.localdate()
months = {month_id(today.year, today.month)}
for row in WorkLog.objects.dates('date', 'month', order='DESC')[:limit]:
months.add(month_id(row.year, row.month))
return sorted(months, reverse=True)
def location_object_code(location) -> str:
if location is None:
return ''
if location.location_type == 'office':
return 'ОФИС'
if location.location_type == 'warehouse':
return 'Склад'
pharmacy = getattr(location, 'pharmacy', None)
if pharmacy is not None:
return pharmacy_object_code(pharmacy)
return ''
def classify_direction(text: str, directions: list[dict]) -> str:
low = (text or '').lower()
for direction in directions:
for kw in direction.get('keywords') or []:
if kw.lower() in low:
return direction.get('name') or direction.get('id') or 'Прочие работы'
return 'Прочие работы'
def _staff_order(company: dict) -> list[dict]:
return list(company.get('staff') or [])
def _match_staff_id(employee, company: dict) -> str | None:
last = normalize_name(employee.last_name)
full = normalize_name(employee.get_full_name() or '')
for staff in _staff_order(company):
for token in staff.get('match') or []:
if normalize_name(token) and normalize_name(token) in full:
return staff['id']
name_last = normalize_name((staff.get('name') or '').split()[0] if staff.get('name') else '')
if name_last and name_last == last:
return staff['id']
return None
def _sort_key_employee(emp) -> tuple:
return (
(emp.last_name or '').casefold(),
(emp.first_name or '').casefold(),
emp.pk,
)
def department_roster(department_id, company: dict | None = None) -> list[dict]:
"""Состав отдела для таблицы Word: руководитель первым, остальные по фамилии."""
if not department_id:
return []
company = company or {}
staff_by_id = {s['id']: s for s in _staff_order(company)}
head = department_head(department_id)
emps = list(
Employee.objects.filter(
is_active=True,
department_id=department_id,
user_location_type='office',
)
)
ordered: list = []
if head and any(e.pk == head.pk for e in emps):
ordered.append(head)
rest = sorted(
[e for e in emps if head is None or e.pk != head.pk],
key=_sort_key_employee,
)
ordered.extend(rest)
rows = []
for emp in ordered:
staff_id = _match_staff_id(emp, company)
meta = staff_by_id.get(staff_id) if staff_id else None
zone = (getattr(emp, 'it_responsibility_zone', None) or '').strip()
if not zone and meta:
zone = (meta.get('zone') or '').strip()
role = ''
if meta:
role = meta.get('role') or ''
if not role and emp.post_id:
role = emp.post.name
rows.append({
'id': staff_id or f'user-{emp.pk}',
'employee_id': emp.pk,
'name': (meta or {}).get('name') or emp.get_full_name() or emp.username,
'short': (meta or {}).get('short') or emp.get_full_name() or emp.username,
'role': role,
'zone': zone,
'kind': (meta or {}).get('kind') or 'log',
'color': (meta or {}).get('color') or '1A2C5B',
'intro': (meta or {}).get('intro') or '',
})
return rows
def _order_tech_ids_by_roster(
by_tech: dict[int, list],
tech_to_staff: dict[int, str | None],
company: dict,
department_id,
) -> list[int]:
"""Порядок работ в отчёте: руководитель → по фамилии; без журнала — в конце."""
head = department_head(department_id) if department_id else None
tech_emps = {
tech_id: tech_logs[0].technician
for tech_id, tech_logs in by_tech.items()
}
ordered: list[int] = []
used: set[int] = set()
if head and head.pk in tech_emps:
ordered.append(head.pk)
used.add(head.pk)
rest = sorted(
[(tid, emp) for tid, emp in tech_emps.items() if tid not in used],
key=lambda pair: _sort_key_employee(pair[1]),
)
for tid, _emp in rest:
ordered.append(tid)
used.add(tid)
# safety: any leftover
for tech_id in by_tech:
if tech_id not in used:
ordered.append(tech_id)
return ordered
def build_monthly_report(year: int, month: int, *, company: dict | None = None) -> dict:
if company is None:
root = default_report_tool_root()
company = load_company(root) if (root / 'company.json').exists() else {'staff': [], 'directions': []}
directions = company.get('directions') or []
start = date(year, month, 1)
end = date(year, month, calendar.monthrange(year, month)[1])
detail_labels = detail_labels_from_db()
logs = list(
WorkLog.objects.filter(date__gte=start, date__lte=end)
.select_related(
'technician', 'location', 'work_type', 'ticket',
'catalog_work', 'catalog_work__parent',
)
.prefetch_related('location__pharmacy__organization')
.order_by('date', 'id')
)
by_tech: dict[int, list] = defaultdict(list)
for log in logs:
by_tech[log.technician_id].append(log)
direction_counts: Counter[str] = Counter()
direction_people: dict[str, set[str]] = defaultdict(set)
objects: list[str] = []
seen_obj: set[str] = set()
staff_by_id = {s['id']: s for s in _staff_order(company)}
tech_to_staff: dict[int, str | None] = {}
dept_id = None
for tech_id, tech_logs in by_tech.items():
tech = tech_logs[0].technician
tech_to_staff[tech_id] = _match_staff_id(tech, company)
if dept_id is None and getattr(tech, 'department_id', None):
dept_id = tech.department_id
# Prefer IT department of the report head from company match / first tech dept.
if dept_id is None:
# fallback: department of any matched staff employee is unknown — leave None
pass
ordered_tech_ids = _order_tech_ids_by_roster(by_tech, tech_to_staff, company, dept_id)
employees_out = []
people_active = 0
for tech_id in ordered_tech_ids:
tech_logs = by_tech[tech_id]
tech = tech_logs[0].technician
staff_id = tech_to_staff.get(tech_id)
staff_meta = staff_by_id.get(staff_id) if staff_id else None
name = (staff_meta or {}).get('name') or tech.get_full_name() or tech.username
short = (staff_meta or {}).get('short') or name
role = (staff_meta or {}).get('role') or ''
it_work_channel = getattr(tech, 'it_work_channel', '') or ''
zone = (getattr(tech, 'it_responsibility_zone', None) or '').strip()
if not zone and staff_meta:
zone = (staff_meta.get('zone') or '').strip()
tasks = []
systems = []
for log in tech_logs:
obj = location_object_code(log.location)
if log.catalog_work_id:
work = worklog_display_title(log, detail_labels)
if log.description:
work = f'{work}. {log.description}'.strip()
elif not obj:
# префикс «А12 — …» из импорта без локации
m = re.match(r'^([АAПPТTГGЛL]\d+|ОФИС|Склад)\s*[—\-]\s*(.+)$', log.description or '', re.I | re.S)
if m:
obj = m.group(1).upper().replace('A', 'А')
work = m.group(2).strip()
else:
work = log.description or ''
else:
work = log.description or ''
sys_m = SYSTEM_DESC_RE.match(work)
if sys_m and (log.result or '').startswith('import:report-tool:'):
systems.append({'name': sys_m.group(1).strip(), 'work': sys_m.group(2).strip()})
direction_counts[INFRA_DIRECTION] += 1
direction_people[INFRA_DIRECTION].add(short)
else:
work_type_label = ''
if log.catalog_work_id:
parent = log.catalog_work.parent
work_type_label = parent.name if parent else log.catalog_work.name
elif log.work_type_id:
work_type_label = log.work_type.name
tasks.append({
'date': log.date.strftime('%d.%m.%Y'),
'object': obj,
'work': work,
'work_items': split_work_items(work),
'work_type': work_type_label,
'ticket': log.ticket.number if log.ticket_id else '',
'hours': float(log.hours_spent) if log.hours_spent is not None else None,
})
label = classify_direction(f'{obj} {work}', directions)
direction_counts[label] += 1
direction_people[label].add(short)
for code in filter(None, [obj]):
key = code.upper()
if key not in seen_obj:
seen_obj.add(key)
objects.append(code)
if tasks or systems:
people_active += 1
employees_out.append({
'id': staff_id or f'user-{tech_id}',
'employee_id': tech.pk,
'name': name,
'short': short,
'role': role,
'zone': zone,
'it_work_channel': it_work_channel,
'tasks': tasks,
'systems': systems,
'tasks_count': len(tasks),
'systems_count': len(systems),
})
# Full department roster for composition table (head first, then by last name).
roster_dept = dept_id
if roster_dept is None and employees_out:
# try from first employee with employee_id
for e in employees_out:
eid = e.get('employee_id')
if eid:
emp = Employee.objects.filter(pk=eid).only('department_id').first()
if emp and emp.department_id:
roster_dept = emp.department_id
break
staff_roster = department_roster(roster_dept, company) if roster_dept else []
# Merge zones/roles from employees_out into roster when missing
by_emp_id = {e.get('employee_id'): e for e in employees_out if e.get('employee_id')}
for row in staff_roster:
src = by_emp_id.get(row.get('employee_id')) or {}
if not row.get('zone') and src.get('zone'):
row['zone'] = src['zone']
if not row.get('role') and src.get('role'):
row['role'] = src['role']
summary = [
{
'name': name,
'count': count,
'people': ', '.join(sorted(direction_people.get(name, []))),
}
for name, count in direction_counts.most_common()
]
pharmacies = [o for o in objects if _is_pharmacy_object_code(o)]
# Офис, склад и прочие точки без кода аптеки — в «Другие объекты»
others = [o for o in objects if not _is_pharmacy_object_code(o)]
report = {
'id': month_id(year, month),
'year': year,
'month': month,
'title': f'{MONTHS_RU[month]} {year} года',
'period_from': start.strftime('%d.%m.%Y'),
'period_to': end.strftime('%d.%m.%Y'),
'total_tasks': sum(e['tasks_count'] for e in employees_out),
'people_active': people_active,
'objects_count': len(objects),
'objects': objects,
'pharmacies': pharmacies,
'other_points': others,
'extra_points': [],
'summary': summary,
'employees': employees_out,
'staff_roster': staff_roster,
'department_id': roster_dept,
'available_months': available_report_months(),
}
report['conclusions'] = build_conclusions(report, company)
return report
def report_to_markdown(report: dict) -> str:
lines = [
f"# IT-отчёт — {report['title']}",
'',
f"**Период:** {report['period_from']} – {report['period_to']}",
f"**Всего работ:** {report['total_tasks']}",
f"**Активных сотрудников:** {report['people_active']}",
f"**Объектов:** {report['objects_count']}",
'',
'## Сводка по направлениям',
'',
]
if report['summary']:
for row in report['summary']:
people = f" ({row['people']})" if row['people'] else ''
lines.append(f"- **{row['name']}** — {row['count']}{people}")
else:
lines.append('_Нет записей за месяц._')
if report['pharmacies'] or report['other_points']:
lines.extend(['', '## Объекты', ''])
if report['pharmacies']:
lines.append(f"- Аптеки: {', '.join(report['pharmacies'])}.")
if report['other_points']:
lines.append(f"- Другие объекты: {', '.join(report['other_points'])}.")
for emp in report['employees']:
if not emp['tasks'] and not emp['systems']:
continue
lines.extend(['', f"## {emp['name']}", ''])
if emp.get('role'):
lines.append(f"*{emp['role']}*")
lines.append('')
for sys in emp['systems']:
lines.append(f"### {sys['name']}")
lines.append(sys['work'])
lines.append('')
for task in emp['tasks']:
obj = f" {task['object']}" if task['object'] else ''
items = task.get('work_items') or [task['work']]
if len(items) == 1:
lines.append(f"- {task['date']}{obj} — {items[0]}")
else:
lines.append(f"- {task['date']}{obj}:")
for item in items:
lines.append(f" - {item}")
if report.get('conclusions'):
lines.extend(['', '## Основные выводы и рекомендации', ''])
for item in report['conclusions']:
lines.append(item.get('text') or '')
for bullet in item.get('bullets') or []:
lines.append(f' - {bullet}')
lines.append('')
lines.append('')
return '\n'.join(lines)