diff --git a/apps/employees/admin.py b/apps/employees/admin.py index 978f885..28808ac 100644 --- a/apps/employees/admin.py +++ b/apps/employees/admin.py @@ -208,7 +208,10 @@ class EmployeeAdmin(ModelAdmin): 'user_location_type', 'user_pharmacy', 'department_display', 'post', 'is_active', 'twofa_status', 'hire_date' ] - list_filter = ['groups', 'is_active', 'is_staff', 'user_location_type', 'department', 'totp_enabled'] + list_filter = [ + 'groups', 'is_active', 'is_staff', 'user_location_type', + 'department', 'totp_enabled', 'it_work_channel', + ] search_fields = ['username', 'email', 'last_name', 'first_name', 'middle_name'] list_select_related = ['user_pharmacy', 'department', 'post'] readonly_fields = ('photo_preview',) @@ -245,6 +248,7 @@ class EmployeeAdmin(ModelAdmin): 'user_location_type', 'user_organization', 'user_pharmacy', 'user_location', 'department', 'post', 'manager', + 'it_work_channel', ), 'classes': ('tab',), }), diff --git a/apps/employees/migrations/0021_employee_it_work_channel.py b/apps/employees/migrations/0021_employee_it_work_channel.py new file mode 100644 index 0000000..da0ac9f --- /dev/null +++ b/apps/employees/migrations/0021_employee_it_work_channel.py @@ -0,0 +1,58 @@ +# Generated manually for Employee.it_work_channel + +from django.db import migrations, models + + +def seed_it_channels(apps, schema_editor): + Employee = apps.get_model('employees', 'Employee') + # last_name (normalized lower) → channel + mapping = { + 'апродов': 'remote', + 'когутенко': 'field', + 'хижняк': 'office', + 'бабич': 'infra', + } + for emp in Employee.objects.all().only('id', 'last_name', 'it_work_channel'): + key = (emp.last_name or '').strip().lower() + channel = mapping.get(key) + if channel and not emp.it_work_channel: + emp.it_work_channel = channel + emp.save(update_fields=['it_work_channel']) + + +def unseed_it_channels(apps, schema_editor): + Employee = apps.get_model('employees', 'Employee') + Employee.objects.exclude(it_work_channel='').update(it_work_channel='') + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0020_alter_employeehrrequest_substitute'), + ] + + operations = [ + migrations.AddField( + model_name='employee', + name='it_work_channel', + field=models.CharField( + blank=True, + choices=[ + ('', '— не задано (авто из роли IT-отчёта)'), + ('remote', 'Удалённо (без выезда)'), + ('field', 'С выездом'), + ('office', 'Офис'), + ('infra', 'Инфраструктура / руководство'), + ], + default='', + help_text=( + 'Для месячного IT-отчёта: как учитывать задачи сотрудника ' + '(удалённо / с выездом / офис / инфра). ' + 'Объект заявки (аптека А35…) сам по себе выездом не считается.' + ), + max_length=16, + verbose_name='Канал работ (IT-отчёт)', + ), + ), + migrations.RunPython(seed_it_channels, unseed_it_channels), + ] diff --git a/apps/employees/models/base.py b/apps/employees/models/base.py index e62abfb..0e9e54e 100644 --- a/apps/employees/models/base.py +++ b/apps/employees/models/base.py @@ -167,6 +167,26 @@ class Employee(AbstractBaseUser, PermissionsMixin, BaseModel): ad_sync_enabled = models.BooleanField(_('Синхронизация AD'), default=False, help_text=_('Только для офисных сотрудников')) + IT_WORK_CHANNEL_CHOICES = [ + ('', _('— не задано (авто из роли IT-отчёта)')), + ('remote', _('Удалённо (без выезда)')), + ('field', _('С выездом')), + ('office', _('Офис')), + ('infra', _('Инфраструктура / руководство')), + ] + it_work_channel = models.CharField( + _('Канал работ (IT-отчёт)'), + max_length=16, + choices=IT_WORK_CHANNEL_CHOICES, + blank=True, + default='', + help_text=_( + 'Для месячного IT-отчёта: как учитывать задачи сотрудника ' + '(удалённо / с выездом / офис / инфра). ' + 'Объект заявки (аптека А35…) сам по себе выездом не считается.' + ), + ) + default_page_size = models.PositiveSmallIntegerField( _('Записей на странице'), choices=[(n, str(n)) for n in PAGE_SIZE_CHOICES], default=DEFAULT_PAGE_SIZE, diff --git a/apps/reports/it_monthly/builder.py b/apps/reports/it_monthly/builder.py index 94e5a24..17131c8 100644 --- a/apps/reports/it_monthly/builder.py +++ b/apps/reports/it_monthly/builder.py @@ -224,6 +224,7 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = 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: @@ -271,6 +272,7 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = None) 'name': name, 'short': short, 'role': role, + 'it_work_channel': it_work_channel, 'tasks': tasks, 'systems': systems, 'tasks_count': len(tasks), diff --git a/apps/reports/it_monthly/conclusions.py b/apps/reports/it_monthly/conclusions.py index d93487d..359604f 100644 --- a/apps/reports/it_monthly/conclusions.py +++ b/apps/reports/it_monthly/conclusions.py @@ -27,6 +27,71 @@ STAFF_PHRASES = { }, } +# Явные 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'принтер|мфу|печат', 'установка и обслуживание принтеров/МФУ'), @@ -123,7 +188,7 @@ def build_conclusions(report: dict, company: dict | None = None) -> list[dict]: counts = {'remote': 0, 'field': 0, 'office': 0, 'infra': 0} for staff_id, emp in by_id.items(): - bucket = (STAFF_PHRASES.get(staff_id) or {}).get('bucket') + 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 @@ -134,14 +199,17 @@ def build_conclusions(report: dict, company: dict | None = None) -> list[dict]: elif bucket == 'office': counts['office'] += n else: - # неизвестный сотрудник — по объектам - office_n = sum( - 1 for t in (emp.get('tasks') or []) - if (t.get('object') or '').upper() in ('ОФИС', 'ОФІС', '') - ) - field_n = len(emp.get('tasks') or []) - office_n + # 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['field'] += max(0, field_n) + counts['remote'] += remote_n operational = { 'remote': counts['remote'], diff --git a/apps/reports/tests.py b/apps/reports/tests.py index 3c2bcef..ac0a6fd 100644 --- a/apps/reports/tests.py +++ b/apps/reports/tests.py @@ -207,6 +207,86 @@ class MonthlyReportTests(TestCase): self.assertTrue(last.get('bullets')) self.assertTrue(any('удаленно' in b for b in last['bullets'])) + def test_pharmacy_object_is_not_field_visit_for_remote_or_unknown(self): + """Удалёнка и неизвестные сотрудники с объектом «А35» не должны попадать в «с выездом».""" + from apps.reports.it_monthly.conclusions import resolve_work_bucket + + self.assertEqual( + resolve_work_bucket( + 'aprodov', + {'role': 'Специалист удалённой поддержки', 'tasks': [{'object': 'А35'}]}, + {}, + ), + 'remote', + ) + # Поле карточки сотрудника важнее STAFF_PHRASES / объекта аптеки + self.assertEqual( + resolve_work_bucket( + 'kogutenko', + {'it_work_channel': 'remote', 'tasks': [{'object': 'А12'}]}, + {}, + ), + 'remote', + ) + company = { + 'staff': [ + { + 'id': 'new_remote', + 'name': 'Тестов Удалённый', + 'role': 'Специалист удалённой поддержки', + 'bucket': 'remote', + }, + ], + } + self.assertEqual( + resolve_work_bucket('new_remote', {'role': '', 'tasks': []}, company), + 'remote', + ) + # unmatched: аптека → remote, не field + self.assertEqual( + resolve_work_bucket( + 'user-99', + {'role': '', 'tasks': [{'object': 'А35', 'work': 'ПРРО'}], 'tasks_count': 1}, + company, + ), + 'unknown', + ) + + report = { + 'month': 8, + 'total_tasks': 40, + 'summary': [{'name': 'ПРРО', 'count': 40, 'people': 'X'}], + 'employees': [ + { + 'id': 'aprodov', + 'name': 'Апродов', + 'role': 'Специалист удалённой поддержки', + 'it_work_channel': 'remote', + 'tasks_count': 20, + 'systems_count': 0, + 'tasks': [{'object': 'А35', 'work': 'ПРРО'}, {'object': 'Т2', 'work': 'Терминал'}], + 'systems': [], + }, + { + 'id': 'user-42', + 'name': 'Удалённый сотрудник', + 'role': '', + 'it_work_channel': 'remote', + 'tasks_count': 20, + 'systems_count': 0, + 'tasks': [{'object': 'А10', 'work': 'Helpdesk'}], + 'systems': [], + }, + ], + } + conclusions = build_conclusions(report, company) + bullets = conclusions[-1].get('bullets') or [] + field_line = next(b for b in bullets if 'выезд' in b) + remote_line = next(b for b in bullets if 'удален' in b) + # все 40 задач — remote; field должен быть ~0% + self.assertIn('~0%', field_line) + self.assertIn('~100%', remote_line) + def test_split_semicolon_work_items(self): items = split_work_items('восстановление печати на МФУ; добавление баз; настройка сканера') self.assertEqual(len(items), 3)