Files
ACORE/apps/reports/it_monthly/conclusions.py
T
lokses 31eecb5001 Count IT infra work as remote in monthly report stats.
Infra/leadership channel no longer looks like on-site visits; labels clarify remote-only infrastructure.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 14:17:08 +03:00

347 lines
14 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-отчёта."""
from __future__ import annotations
import re
MONTHS_PREP = [
'', 'январе', 'феврале', 'марте', 'апреле', 'мае', 'июне',
'июле', 'августе', 'сентябре', 'октябре', 'ноябре', 'декабре',
]
# Склонения для шаблонных фраз (как в июльском отчёте report-tool).
STAFF_PHRASES = {
'aprodov': {
'genitive': 'Артура Апродова',
'bucket': 'remote',
},
'kogutenko': {
'genitive': 'Сергея Когутенко',
'bucket': 'field',
},
'khizhniak': {
'instrumental': 'Николаем Хижняком',
'bucket': 'office',
},
'babich': {
'bucket': 'infra',
},
}
# Явные bucket в company.json staff[].bucket имеют приоритет после STAFF_PHRASES.
_ROLE_BUCKET_HINTS = (
(r'выезд', 'field'),
(r'удал[её]н', 'remote'),
(r'remote|help\s*desk|l1\b', 'remote'),
(r'офис', 'office'),
(r'руководител|архитектор|infra|инфраструктур', 'infra'),
)
_OFFICE_OBJECTS = frozenset({'ОФИС', 'ОФІС', ''})
def _bucket_from_role_text(role: str) -> str | None:
low = (role or '').lower()
if not low:
return None
for pat, bucket in _ROLE_BUCKET_HINTS:
if re.search(pat, low, re.I):
return bucket
return None
def _staff_meta_by_id(company: dict | None) -> dict[str, dict]:
return {
s['id']: s
for s in (company or {}).get('staff') or []
if s.get('id')
}
def resolve_work_bucket(
staff_id: str,
emp: dict,
company: dict | None = None,
) -> str:
"""
Канал работ для итогов: remote | field | office | infra.
Приоритет:
1) Employee.it_work_channel (поле в карточке сотрудника)
2) STAFF_PHRASES / company.json bucket / роль
3) unknown → в подсчёте аптека ≠ выезд
Объект «А35» в WorkLog — это *о чём* заявка, а не факт выезда.
"""
channel = (emp.get('it_work_channel') or emp.get('work_channel') or '').strip().lower()
if channel in ('remote', 'field', 'office', 'infra'):
return channel
phrase = STAFF_PHRASES.get(staff_id) or {}
if phrase.get('bucket'):
return phrase['bucket']
meta = _staff_meta_by_id(company).get(staff_id) or {}
explicit = (meta.get('bucket') or '').strip().lower()
if explicit in ('remote', 'field', 'office', 'infra'):
return explicit
for role in (meta.get('role') or '', emp.get('role') or ''):
inferred = _bucket_from_role_text(role)
if inferred:
return inferred
return 'unknown'
FIELD_HINTS = [
(r'mikro.?tik|роутер', 'замена роутеров на MikroTik'),
(r'принтер|мфу|печат', 'установка и обслуживание принтеров/МФУ'),
(r'демонтаж|монтаж|транспортир', 'демонтаж/монтаж оборудования на точках'),
(r'терминал', 'настройка платёжных терминалов'),
(r'сет|свитч|свич|коммутатор', 'модернизация локальной сети'),
]
OFFICE_HINTS = [
(r'пк|ноутбук|windows|сотрудник', 'подготовка ПК для новых сотрудников'),
(r'мфу|принтер|картридж|печат|оргтехник', 'обслуживание МФУ и оргтехники'),
(r'почт|1с|доступ', 'настройка доступов и почтовых клиентов'),
]
def _emp_by_id(report: dict) -> dict[str, dict]:
return {e.get('id'): e for e in (report.get('employees') or []) if e.get('id')}
def _task_blob(emp: dict) -> str:
parts = []
for t in emp.get('tasks') or []:
parts.append(f"{t.get('object', '')} {t.get('work', '')}")
for s in emp.get('systems') or []:
parts.append(f"{s.get('name', '')} {s.get('work', '')}")
return ' '.join(parts).lower()
def _unique_objects(emp: dict) -> list[str]:
seen = set()
out = []
for t in emp.get('tasks') or []:
obj = (t.get('object') or '').strip()
if not obj or obj.upper() in ('ОФИС', 'ОФІС'):
continue
key = obj.upper()
if key not in seen:
seen.add(key)
out.append(obj)
return out
def _match_hints(text: str, hints: list[tuple[str, str]]) -> list[str]:
found = []
for pat, label in hints:
if re.search(pat, text, re.I) and label not in found:
found.append(label)
return found
def _approx_pct(part: int, total: int) -> int:
if total <= 0 or part <= 0:
return 0
return max(5, int(round(100 * part / total / 5.0) * 5))
def _normalize_pcts(parts: dict[str, int]) -> dict[str, int]:
"""Округляет доли до кратных 5% и подгоняет сумму к 100."""
total = sum(parts.values())
if total <= 0:
return {k: 0 for k in parts}
raw = {k: _approx_pct(v, total) for k, v in parts.items()}
# если все нули из-за tiny parts — пропорционально
if sum(raw.values()) == 0:
return {k: _approx_pct(v, total) for k, v in parts.items()}
drift = 100 - sum(raw.values())
if drift and raw:
# корректируем наибольшую долю
key = max(raw, key=raw.get)
raw[key] = max(0, raw[key] + drift)
return raw
def _top_direction_name(summary: list[dict], prefer_ids_text: str = '') -> str:
if not summary:
return 'обслуживанием инфраструктуры'
# предпочитаем кассовое ПО, если оно в топе
for row in summary:
name = row.get('name') or ''
low = name.lower()
if 'прро' in low or 'касс' in low or 'экселлио' in low:
return name
return summary[0].get('name') or 'оперативной поддержкой'
def build_conclusions(report: dict, company: dict | None = None) -> list[dict]:
"""Список пунктов выводов: {text, bullets?}."""
company = company or {}
by_id = _emp_by_id(report)
month = int(report.get('month') or 1)
in_month = MONTHS_PREP[month] if 1 <= month <= 12 else 'отчётном периоде'
total_tasks = int(report.get('total_tasks') or 0)
summary = list(report.get('summary') or [])
counts = {'remote': 0, 'field': 0, 'office': 0, 'infra': 0}
for staff_id, emp in by_id.items():
bucket = resolve_work_bucket(staff_id, emp, company)
n = int(emp.get('tasks_count') or 0) + int(emp.get('systems_count') or 0)
if bucket == 'infra':
# Инфраструктура у нас ведётся удалённо — в % «с выездом» не попадает,
# но входит в «удаленно решено» + отдельный абзац про инфра.
counts['infra'] += n
counts['remote'] += n
elif bucket == 'remote':
counts['remote'] += n
elif bucket == 'field':
counts['field'] += n
elif bucket == 'office':
counts['office'] += n
else:
# unknown: объект аптеки в заявке ≠ выезд (часто удалённая поддержка точки)
office_n = 0
remote_n = 0
for t in emp.get('tasks') or []:
if (t.get('object') or '').upper() in _OFFICE_OBJECTS:
office_n += 1
else:
remote_n += 1
remote_n += int(emp.get('systems_count') or 0)
counts['office'] += office_n
counts['remote'] += remote_n
operational = {
'remote': counts['remote'],
'field': counts['field'],
'office': counts['office'],
}
pcts = _normalize_pcts(operational)
items: list[dict] = []
# 1. Удалённая поддержка / общая сеть
aprodov = by_id.get('aprodov')
top_dir = _top_direction_name(summary)
if aprodov and aprodov.get('tasks_count'):
name = STAFF_PHRASES['aprodov']['genitive']
remote_pct = pcts.get('remote') or _approx_pct(counts['remote'], sum(operational.values()) or 1)
items.append({
'text': (
f'Поддержка аптечной сети осуществлялась в ежедневном режиме. '
f'Наибольшее количество обращений связано с работой по направлению «{top_dir}» — '
f'удаленная поддержка {name} обеспечила оперативное решение '
f'~{remote_pct}% задач без выезда.'
),
})
elif total_tasks:
items.append({
'text': (
'Поддержка аптечной сети осуществлялась в ежедневном режиме. '
f'Наибольшее количество обращений связано с направлением «{top_dir}».'
),
})
# 2. Выезды
kogutenko = by_id.get('kogutenko')
if kogutenko and kogutenko.get('tasks_count'):
name = STAFF_PHRASES['kogutenko']['genitive']
hints = _match_hints(_task_blob(kogutenko), FIELD_HINTS)
objs = _unique_objects(kogutenko)
focus = ', '.join(hints[:3]) if hints else 'модернизации и обслуживании оборудования на объектах'
obj_note = ''
if objs:
obj_note = f' ({len(objs)} объектов в {in_month})'
items.append({
'text': (
f'Выездные работы {name} были сфокусированы на {focus}{obj_note}.'
),
})
# 3. Офис
khizhniak = by_id.get('khizhniak')
if khizhniak and khizhniak.get('tasks_count'):
name = STAFF_PHRASES['khizhniak']['instrumental']
hints = _match_hints(_task_blob(khizhniak), OFFICE_HINTS)
detail = ''
if hints:
detail = ' Выполнены: ' + '; '.join(hints[:3]) + '.'
else:
detail = ' Выполнены текущие работы по поддержке пользователей и оргтехники.'
items.append({
'text': (
f'Офисная инфраструктура поддерживалась {name} в штатном режиме.{detail}'
),
})
# 4–6. Инфраструктура / безопасность / бэкапы
babich = by_id.get('babich')
has_infra = bool(
(babich and ((babich.get('systems_count') or 0) > 0 or (babich.get('tasks_count') or 0) > 0))
or any('сервер' in (s.get('name') or '').lower() or 'proxmox' in (s.get('name') or '').lower()
or 'безопас' in (s.get('name') or '').lower() or 'резерв' in (s.get('name') or '').lower()
for s in (summary or []))
or any('администрирован' in (s.get('name') or '').lower() for s in summary)
)
if has_infra or babich:
items.append({
'text': (
'Инфраструктурные системы (Proxmox, Zabbix, PBS, Carbonio, базы данных) '
'находятся в стабильном состоянии благодаря постоянному мониторингу '
'и профилактике со стороны руководителя отдела.'
),
})
items.append({
'text': (
'Безопасность обеспечена на должном уровне: Traefik, CrowdSec, '
'межсетевые экраны MikroTik, регулярные обновления правил и анализ логов.'
),
})
items.append({
'text': (
'Резервное копирование выполняется в полном объеме: локальные бэкапы на PBS '
'+ offsite-копии на серверах в Германии.'
),
})
# 7. Итоги по каналам
if total_tasks:
approx = f'порядка {total_tasks}' if total_tasks < 20 else f'порядка {total_tasks}+'
# «порядка 100+» стиль для крупных месяцев
if total_tasks >= 80:
approx = f'порядка {round(total_tasks, -1)}+'
bullets = [
f'удаленно решено – ~{pcts.get("remote", 0)}%',
f'с выездом – ~{pcts.get("field", 0)}%',
f'офисные работы – ~{pcts.get("office", 0)}%',
'инфраструктурные и стратегические задачи – выполняются руководителем отдела на постоянной основе',
]
items.append({
'text': f'Общее количество заявок/задач за месяц – {approx}, из них:',
'bullets': bullets,
})
# Нумерация в тексте
numbered = []
for i, item in enumerate(items, 1):
numbered.append({
'text': f'{i}. {item["text"]}',
'bullets': list(item.get('bullets') or []),
})
return numbered
def conclusions_as_plain_lines(conclusions: list[dict]) -> list[str]:
"""Плоский список строк для DOCX (как в июльском JSON)."""
lines = []
for item in conclusions or []:
text = (item.get('text') or '').strip()
if text:
lines.append(text)
for bullet in item.get('bullets') or []:
lines.append(f' ◦ {bullet}')
return lines