31eecb5001
Infra/leadership channel no longer looks like on-site visits; labels clarify remote-only infrastructure. Co-authored-by: Cursor <cursoragent@cursor.com>
347 lines
14 KiB
Python
347 lines
14 KiB
Python
"""Автогенерация блока «Основные выводы и рекомендации» для месячного 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
|