"""Месячный 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.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 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]) logs = list( WorkLog.objects.filter(date__gte=start, date__lte=end) .select_related('technician', 'location', 'work_type', 'ticket') .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 из company, потом остальные staff_by_id = {s['id']: s for s in _staff_order(company)} tech_to_staff: dict[int, str | None] = {} for tech_id, tech_logs in by_tech.items(): tech_to_staff[tech_id] = _match_staff_id(tech_logs[0].technician, company) ordered_tech_ids: list[int] = [] used: set[int] = set() for staff in _staff_order(company): for tech_id, sid in tech_to_staff.items(): if sid == staff['id'] and tech_id not in used: ordered_tech_ids.append(tech_id) used.add(tech_id) break for tech_id in by_tech: if tech_id not in used: ordered_tech_ids.append(tech_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 '' tasks = [] systems = [] for log in tech_logs: obj = location_object_code(log.location) if not obj: # префикс «А12 — …» из импорта без локации m = re.match(r'^([АAПPТTГGЛL]\d+|ОФИС|Склад)\s*[—\-]\s*(.+)$', log.description, re.I | re.S) if m: obj = m.group(1).upper().replace('A', 'А') work = m.group(2).strip() else: work = log.description else: work = log.description 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: tasks.append({ 'date': log.date.strftime('%d.%m.%Y'), 'object': obj, 'work': work, 'work_items': split_work_items(work), 'work_type': log.work_type.name if log.work_type_id else '', '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}', 'name': name, 'short': short, 'role': role, 'it_work_channel': it_work_channel, 'tasks': tasks, 'systems': systems, 'tasks_count': len(tasks), 'systems_count': len(systems), }) 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, '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)