diff --git a/apps/it_manager/admin.py b/apps/it_manager/admin.py index d60fa82..9ef8c24 100644 --- a/apps/it_manager/admin.py +++ b/apps/it_manager/admin.py @@ -611,23 +611,27 @@ class ServiceCallForm(forms.ModelForm): @admin.register(WorkLog) class WorkLogAdmin(ModelAdmin): form = WorkLogForm - list_display = ['date', 'technician', 'work_type_display', 'location', + list_display = ['date', 'technician', 'catalog_display', 'location', 'hours_spent', 'ticket', 'created_at'] - list_filter = ['work_type', 'technician', 'date'] - search_fields = ['description', 'result', 'technician__last_name', - 'technician__first_name'] - list_select_related = ['technician', 'location', 'ticket', 'equipment', 'work_type'] + list_filter = ['catalog_work__parent', 'work_type', 'technician', 'date'] + search_fields = ['description', 'result', 'catalog_work__name', + 'technician__last_name', 'technician__first_name'] + list_select_related = [ + 'technician', 'location', 'ticket', 'equipment', + 'work_type', 'catalog_work', 'catalog_work__parent', + ] ordering = ['-date', '-created_at'] date_hierarchy = 'date' + raw_id_fields = ['catalog_work'] readonly_fields = ['created_at', 'updated_at'] fieldsets = ( (_('Работа'), { - 'fields': ('date', 'technician', 'work_type', 'location'), + 'fields': ('date', 'technician', 'catalog_work', 'details', 'location'), }), - (_('Описание'), { - 'fields': ('description', 'result', 'hours_spent'), + (_('Устарело / комментарий'), { + 'fields': ('work_type', 'description', 'result', 'hours_spent'), }), (_('Связи'), { 'fields': ('equipment', 'ticket'), @@ -638,25 +642,18 @@ class WorkLogAdmin(ModelAdmin): }), ) - WORK_TYPE_COLORS = { - 'maintenance': '#2563eb', - 'installation': '#16a34a', - 'repair': '#ca8a04', - 'network': '#9333ea', - 'software': '#0891b2', - 'backup': '#6b7280', - 'other': '#6b7280', - } - - def work_type_display(self, obj): - color = self.WORK_TYPE_COLORS.get(obj.work_type.code, '#6b7280') - return format_html( - '{}', - color, obj.work_type.name - ) - work_type_display.short_description = _('Вид работ') - work_type_display.admin_order_field = 'work_type__name' - + def catalog_display(self, obj): + if obj.catalog_work_id: + cat = obj.catalog_work.parent.name if obj.catalog_work.parent_id else '' + label = obj.catalog_work.name + if cat: + return f'{cat}: {label}' + return label + if obj.work_type_id: + return obj.work_type.name + return (obj.description or '—')[:60] + catalog_display.short_description = _('Типовая работа') + catalog_display.admin_order_field = 'catalog_work__name' def save_model(self, request, obj, form, change): if not change and not obj.technician_id: obj.technician = request.user diff --git a/apps/it_manager/management/commands/seed_it_work_directory.py b/apps/it_manager/management/commands/seed_it_work_directory.py new file mode 100644 index 0000000..1472bce --- /dev/null +++ b/apps/it_manager/management/commands/seed_it_work_directory.py @@ -0,0 +1,31 @@ +"""Загрузить consolidated-справочник типовых IT-работ в Directory.""" +from pathlib import Path + +from django.core.management.base import BaseCommand, CommandError + +from apps.it_manager.work_directory import DEFAULT_JSON, seed_from_json + + +class Command(BaseCommand): + help = ( + 'Идемпотентный seed категорий (it_work_category) и типовых работ (it_work) ' + 'из work-directory-consolidated JSON.' + ) + + def add_arguments(self, parser): + parser.add_argument( + 'json_path', + nargs='?', + default=str(DEFAULT_JSON), + help=f'Путь к work-directory.json (по умолчанию: {DEFAULT_JSON})', + ) + + def handle(self, *args, **options): + path = Path(options['json_path']) + if not path.is_file(): + raise CommandError(f'Файл не найден: {path}') + stats = seed_from_json(path) + self.stdout.write(self.style.SUCCESS( + f'Справочник загружен: категорий={stats["categories"]}, ' + f'работ={stats["works"]} ({path})' + )) diff --git a/apps/it_manager/migrations/0022_worklog_catalog_work.py b/apps/it_manager/migrations/0022_worklog_catalog_work.py new file mode 100644 index 0000000..a8e6f41 --- /dev/null +++ b/apps/it_manager/migrations/0022_worklog_catalog_work.py @@ -0,0 +1,51 @@ +# WorkLog: catalog_work + details; work_type/description optional + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0024_seed_pvms_visit_permissions'), + ('it_manager', '0021_seed_gpon_connection_type'), + ] + + operations = [ + migrations.AlterField( + model_name='worklog', + name='work_type', + field=models.ForeignKey( + blank=True, + limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True}, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='worklogs_by_type', + to='core.directory', + verbose_name='Вид работ', + ), + ), + migrations.AlterField( + model_name='worklog', + name='description', + field=models.TextField(blank=True, verbose_name='Комментарий'), + ), + migrations.AddField( + model_name='worklog', + name='catalog_work', + field=models.ForeignKey( + blank=True, + limit_choices_to={'directory_type__code': 'it_work', 'is_active': True}, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='worklogs_by_catalog', + to='core.directory', + verbose_name='Типовая работа', + ), + ), + migrations.AddField( + model_name='worklog', + name='details', + field=models.JSONField(blank=True, default=dict, verbose_name='Уточнения'), + ), + ] diff --git a/apps/it_manager/models/worklogs.py b/apps/it_manager/models/worklogs.py index 0df28e8..86c6697 100644 --- a/apps/it_manager/models/worklogs.py +++ b/apps/it_manager/models/worklogs.py @@ -2,8 +2,6 @@ from django.db import models from django.conf import settings from django.utils import timezone -from core.models.directories import directory_default - class WorkLog(models.Model): date = models.DateField('Дата', default=timezone.localdate, db_index=True) @@ -16,12 +14,21 @@ class WorkLog(models.Model): null=True, blank=True, related_name='work_logs', verbose_name='Локация', ) + # Устаревший грубый вид (~7 типов); для новых записей не заполняется. work_type = models.ForeignKey( 'core.Directory', on_delete=models.PROTECT, limit_choices_to={'directory_type__code': 'worklog_type', 'is_active': True}, related_name='worklogs_by_type', verbose_name='Вид работ', + null=True, blank=True, ) - description = models.TextField('Описание работ') + catalog_work = models.ForeignKey( + 'core.Directory', on_delete=models.PROTECT, + limit_choices_to={'directory_type__code': 'it_work', 'is_active': True}, + related_name='worklogs_by_catalog', verbose_name='Типовая работа', + null=True, blank=True, + ) + details = models.JSONField('Уточнения', default=dict, blank=True) + description = models.TextField('Комментарий', blank=True) result = models.TextField('Результат', blank=True) hours_spent = models.DecimalField('Затрачено часов', max_digits=5, decimal_places=1, null=True, blank=True) @@ -49,7 +56,18 @@ class WorkLog(models.Model): ] def __str__(self): - return f'{self.work_type.name} — {self.technician} — {self.date:%d.%m.%Y}' + if self.catalog_work_id: + title = self.catalog_work.name + elif self.work_type_id: + title = self.work_type.name + else: + title = (self.description or '—')[:40] + return f'{title} — {self.technician} — {self.date:%d.%m.%Y}' + + @property + def display_title(self) -> str: + from apps.it_manager.work_directory import worklog_display_title + return worklog_display_title(self) class ServiceCall(models.Model): diff --git a/apps/it_manager/tests.py b/apps/it_manager/tests.py index 41d0a04..089bf27 100644 --- a/apps/it_manager/tests.py +++ b/apps/it_manager/tests.py @@ -1,6 +1,9 @@ """Тести IT-порталу: доступ через групу IT, інвентаризація обладнання (зі списанням історії видачі), черга заявок, журнал робіт.""" from datetime import date +import json +import tempfile +from pathlib import Path from django.contrib.auth import get_user_model from django.contrib.auth.models import Group @@ -9,6 +12,7 @@ from django.test import Client, TestCase from apps.it_manager.models import ( Equipment, EquipmentAssignment, EquipmentModel, Ticket, WorkLog, ) +from apps.it_manager.work_directory import seed_from_json from core.models.directories import Directory from core.test_utils import login_2fa, grant_all_locations @@ -58,6 +62,50 @@ def make_equipment(**overrides): return Equipment.objects.create(**defaults) +MINI_WORK_CATALOG = { + 'schema_version': 1, + 'status': 'test', + 'detail_fields': { + 'device_type': {'label': 'Вид устройства'}, + 'system': {'label': 'Система'}, + }, + 'categories': [ + { + 'id': 'helpdesk', + 'name': 'Поддержка', + 'works': [ + {'id': 'helpdesk.test1', 'name': 'Приём обращений'}, + ], + }, + { + 'id': 'printing', + 'name': 'Печать', + 'works': [ + { + 'id': 'printing.test1', + 'name': 'Восстановление печати', + 'required_details': ['device_type'], + }, + ], + }, + ], +} + + +def seed_mini_work_catalog(): + with tempfile.NamedTemporaryFile('w', suffix='.json', delete=False, encoding='utf-8') as fh: + json.dump(MINI_WORK_CATALOG, fh, ensure_ascii=False) + path = fh.name + try: + return seed_from_json(path) + finally: + Path(path).unlink(missing_ok=True) + + +def catalog_work_id(code): + return Directory.objects.get(directory_type__code='it_work', code=code).id + + class ItManagerAccessTests(TestCase): """Сторінка й усі мутації доступні лише групі IT (або суперюзеру).""" @@ -432,9 +480,30 @@ class ItManagerTicketTests(TestCase): self.assertTrue(ticket.comments.first().is_internal) +class ItWorkDirectorySeedTests(TestCase): + def test_seed_upserts_categories_and_works(self): + stats = seed_mini_work_catalog() + self.assertEqual(stats['categories'], 2) + self.assertEqual(stats['works'], 2) + self.assertEqual( + Directory.objects.filter(directory_type__code='it_work_category', is_active=True).count(), + 2, + ) + work = Directory.objects.get(directory_type__code='it_work', code='printing.test1') + self.assertEqual(work.extra_data.get('required_details'), ['device_type']) + # idempotent + stats2 = seed_mini_work_catalog() + self.assertEqual(stats2['works'], 2) + self.assertEqual( + Directory.objects.filter(directory_type__code='it_work').exclude(code='_meta').count(), + 2, + ) + + class ItManagerWorkLogTests(TestCase): def setUp(self): + seed_mini_work_catalog() self.it_user = make_employee(is_staff=False) self.it_user.groups.add(Group.objects.get(name='IT')) grant_all_locations(self.it_user) @@ -443,27 +512,65 @@ class ItManagerWorkLogTests(TestCase): def test_create_worklog(self): r = self.client.post('/it-manager/worklog/create/', { - 'work_type': 'maintenance', 'description': 'Профілактика серверної', + 'catalog_work_id': str(catalog_work_id('helpdesk.test1')), + 'description': 'Комментарий к обращению', 'hours_spent': '2.5', }) self.assertEqual(r.status_code, 200) - log = WorkLog.objects.get(description='Профілактика серверної') + log = WorkLog.objects.get(description='Комментарий к обращению') self.assertEqual(log.technician_id, self.it_user.id) + self.assertEqual(log.catalog_work.code, 'helpdesk.test1') + self.assertIsNone(log.work_type_id) self.assertEqual(float(log.hours_spent), 2.5) self.assertContains(r, 'Запись о выполненной работе сохранена') - def test_create_worklog_requires_description(self): + def test_create_worklog_requires_catalog_and_details(self): before = WorkLog.objects.count() r = self.client.post('/it-manager/worklog/create/', { - 'work_type': 'maintenance', 'description': ' ', + 'description': 'без работы', }) self.assertEqual(r.status_code, 200) self.assertEqual(WorkLog.objects.count(), before) - self.assertContains(r, 'Укажите описание работ') + self.assertContains(r, 'типовую работу') + + r = self.client.post('/it-manager/worklog/create/', { + 'catalog_work_id': str(catalog_work_id('printing.test1')), + 'description': '', + }) + self.assertEqual(r.status_code, 200) + self.assertEqual(WorkLog.objects.count(), before) + self.assertContains(r, 'обязательные уточнения') + + r = self.client.post('/it-manager/worklog/create/', { + 'catalog_work_id': str(catalog_work_id('printing.test1')), + 'detail_device_type': 'МФУ', + }) + self.assertEqual(r.status_code, 200) + self.assertEqual(WorkLog.objects.count(), before + 1) + log = WorkLog.objects.latest('pk') + self.assertEqual(log.details.get('device_type'), 'МФУ') + + def test_worklog_list_shows_catalog_title(self): + WorkLog.objects.create( + technician=self.it_user, + catalog_work_id=catalog_work_id('printing.test1'), + details={'device_type': 'Принтер'}, + description='на складе', + ) + WorkLog.objects.create( + technician=self.it_user, + work_type_id=_dir_id('worklog_type', 'maintenance'), + description='Старая запись без справочника', + ) + r = self.client.get('/it-manager/?tab=worklog') + self.assertEqual(r.status_code, 200) + self.assertContains(r, 'Восстановление печати') + self.assertContains(r, 'без справочника') + self.assertContains(r, 'Старая запись без справочника') def test_create_worklog_linked_to_ticket(self): from apps.it_manager.models import Ticket - from core.models.directories import Directory, DirectoryType + from core.models.directories import DirectoryType ttype, _ = DirectoryType.objects.get_or_create(code='ticket_type', defaults={'name': 'Тип заявки'}) ptype, _ = DirectoryType.objects.get_or_create(code='ticket_priority', defaults={'name': 'Приоритет'}) @@ -486,7 +593,7 @@ class ItManagerWorkLogTests(TestCase): description='Каса не відкривається', ) r = self.client.post('/it-manager/worklog/create/', { - 'work_type': 'repair', + 'catalog_work_id': str(catalog_work_id('helpdesk.test1')), 'description': 'Перезапуск касового ПЗ', 'ticket_id': ticket.pk, 'hours_spent': '1', @@ -497,6 +604,31 @@ class ItManagerWorkLogTests(TestCase): self.assertContains(r, 'Работа привязана к заявке') self.assertContains(r, ticket.number) + def test_monthly_builder_mix_catalog_and_legacy(self): + from apps.reports.it_monthly.builder import build_monthly_report + + WorkLog.objects.create( + date=date(2026, 5, 10), + technician=self.it_user, + catalog_work_id=catalog_work_id('printing.test1'), + details={'device_type': 'МФУ'}, + description='на А3', + ) + WorkLog.objects.create( + date=date(2026, 5, 11), + technician=self.it_user, + work_type_id=_dir_id('worklog_type', 'repair'), + description='Старый свободный текст', + ) + report = build_monthly_report(2026, 5, company={'staff': [], 'directions': []}) + works = [ + t['work'] + for emp in report['employees'] + for t in emp.get('tasks') or [] + ] + self.assertTrue(any('Восстановление печати' in w and 'МФУ' in w for w in works)) + self.assertTrue(any('Старый свободный текст' in w for w in works)) + class ItManagerWorkLogFilterEditTests(TestCase): """Фильтр по сотруднику и inline-редактирование для руководителя.""" diff --git a/apps/it_manager/views.py b/apps/it_manager/views.py index 9193747..e7b71d1 100644 --- a/apps/it_manager/views.py +++ b/apps/it_manager/views.py @@ -15,6 +15,13 @@ from apps.it_manager.models import ( InternetContract, InternetProvider, ServiceCall, Ticket, TicketComment, WorkLog, ) +from apps.it_manager.work_directory import ( + catalog_payload_for_ui, + detail_labels_from_db, + get_catalog_work, + parse_details_from_post, + validate_worklog_details, +) from apps.it_manager.monthly_report import ( MONTHS_RU, user_can_manage_it_monthly_report, @@ -306,7 +313,9 @@ def _it_manager_context(request): 'comments__author', models.Prefetch( 'work_logs', - queryset=scoped_queryset(request.user, WorkLog.objects.all()).select_related('technician', 'work_type').order_by('-date', '-created_at'), + queryset=scoped_queryset(request.user, WorkLog.objects.all()).select_related( + 'technician', 'work_type', 'catalog_work', 'catalog_work__parent', + ).order_by('-date', '-created_at'), ), ) .annotate(worklogs_count=models.Count('work_logs', distinct=True)) @@ -330,6 +339,8 @@ def _it_manager_context(request): month_start = timezone.localdate().replace(day=1) worklog_technician_raw = (request.GET.get('worklog_technician') or '').strip() worklog_technician_id = int(worklog_technician_raw) if worklog_technician_raw.isdigit() else None + worklog_category_raw = (request.GET.get('worklog_category') or '').strip() + worklog_category_id = int(worklog_category_raw) if worklog_category_raw.isdigit() else None worklog_month_raw = (request.GET.get('worklog_month') or '').strip() worklog_month_start = None @@ -346,6 +357,7 @@ def _it_manager_context(request): worklogs_qs = ( scoped_queryset(request.user, WorkLog.objects.all()).select_related( 'technician', 'location', 'equipment', 'ticket', 'work_type', + 'catalog_work', 'catalog_work__parent', ) .prefetch_related( models.Prefetch( @@ -357,11 +369,15 @@ def _it_manager_context(request): ) if worklog_technician_id: worklogs_qs = worklogs_qs.filter(technician_id=worklog_technician_id) + if worklog_category_id: + worklogs_qs = worklogs_qs.filter(catalog_work__parent_id=worklog_category_id) if worklog_month_start and worklog_month_end: worklogs_qs = worklogs_qs.filter( date__gte=worklog_month_start, date__lte=worklog_month_end, ) worklogs_page = paginate(request, worklogs_qs, page_param='worklogs_page', size_param='worklogs_per_page') + it_work_catalog = catalog_payload_for_ui() + worklog_detail_labels = it_work_catalog.get('detail_labels') or detail_labels_from_db() # Статистика: выбранный месяц или текущий календарный if worklog_month_start and worklog_month_end: @@ -468,10 +484,13 @@ def _it_manager_context(request): 'worklog_types': _directory_choices('worklog_type'), 'worklog_technicians': worklog_technicians, 'worklog_technician_id': worklog_technician_id, + 'worklog_category_id': worklog_category_id, 'worklog_month_id': worklog_month_id, 'worklog_month_choices': worklog_month_choices, 'worklog_stats_label': worklog_stats_label, 'it_can_edit_worklogs': can_edit_worklogs, + 'it_work_catalog': it_work_catalog, + 'worklog_detail_labels': worklog_detail_labels, 'month_hours': month_hours, 'month_count': month_count, @@ -519,6 +538,7 @@ def it_manager_view(request): elif ( 'worklog_technician' in request.GET or 'worklog_month' in request.GET + or 'worklog_category' in request.GET or request.GET.get('worklogs_page') ): context['it_active_tab'] = 'worklog' @@ -887,49 +907,53 @@ def worklog_create_view(request): worklog_error = '' worklog_message = '' if request.method == 'POST': + catalog_work = get_catalog_work(request.POST.get('catalog_work_id')) description = request.POST.get('description', '').strip() - work_type_raw = request.POST.get('work_type', '') - work_type_id = _resolve_directory('worklog_type', work_type_raw) + details = parse_details_from_post(request.POST, catalog_work) - if not description: - worklog_error = 'Укажите описание работ.' - elif not work_type_id or work_type_raw == '__new__': - worklog_error = 'Выберите вид работ.' + if not catalog_work: + worklog_error = 'Выберите типовую работу из справочника.' else: - try: - log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date() - except ValueError: - log_date = timezone.localdate() - try: - hours_spent = float(request.POST.get('hours_spent', '').strip() or 0) or None - except ValueError: - hours_spent = None - - location_id = request.POST.get('location_id') or None - if not location_id and not has_all_locations(request.user): - return HttpResponseForbidden('Укажите доступную локацию.') - equipment_id = request.POST.get('equipment_id') or None - ticket_id = request.POST.get('ticket_id') or None - - try: - WorkLog.objects.create( - date=log_date, - technician=request.user, - work_type_id=work_type_id, - location_id=location_id, - equipment_id=equipment_id, - ticket_id=ticket_id, - description=description, - result=request.POST.get('result', '').strip(), - hours_spent=hours_spent, - ) - except Exception: - worklog_error = 'Не удалось сохранить запись. Проверьте локацию, заявку и оборудование.' + details_err = validate_worklog_details(catalog_work, details) + if details_err: + worklog_error = details_err else: - if ticket_id: - worklog_message = 'Работа привязана к заявке и сохранена.' + try: + log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date() + except ValueError: + log_date = timezone.localdate() + try: + hours_spent = float(request.POST.get('hours_spent', '').strip() or 0) or None + except ValueError: + hours_spent = None + + location_id = request.POST.get('location_id') or None + if not location_id and not has_all_locations(request.user): + return HttpResponseForbidden('Укажите доступную локацию.') + equipment_id = request.POST.get('equipment_id') or None + ticket_id = request.POST.get('ticket_id') or None + + try: + WorkLog.objects.create( + date=log_date, + technician=request.user, + catalog_work=catalog_work, + details=details, + work_type_id=None, + location_id=location_id, + equipment_id=equipment_id, + ticket_id=ticket_id, + description=description, + result=request.POST.get('result', '').strip(), + hours_spent=hours_spent, + ) + except Exception: + worklog_error = 'Не удалось сохранить запись. Проверьте локацию, заявку и оборудование.' else: - worklog_message = 'Запись о выполненной работе сохранена.' + if ticket_id: + worklog_message = 'Работа привязана к заявке и сохранена.' + else: + worklog_message = 'Запись о выполненной работе сохранена.' context = _it_manager_context(request) context['it_active_tab'] = 'worklog' @@ -953,19 +977,47 @@ def worklog_update_view(request, pk): log = get_object_or_404(scoped_queryset(request.user, WorkLog.objects.all()), pk=pk) description = request.POST.get('description', '').strip() - work_type_raw = request.POST.get('work_type', '') - work_type_id = _resolve_directory('worklog_type', work_type_raw) + catalog_raw = (request.POST.get('catalog_work_id') or '').strip() + catalog_work = get_catalog_work(catalog_raw) if catalog_raw else None + # Если запись уже из справочника — сохраняем FK даже без смены в форме + if not catalog_work and log.catalog_work_id and not catalog_raw: + catalog_work = log.catalog_work - if not description: + work_type_raw = request.POST.get('work_type', '') + work_type_id = _resolve_directory('worklog_type', work_type_raw) if work_type_raw else None + + if catalog_work: + details = parse_details_from_post(request.POST, catalog_work) + # если detail_* не пришли — оставить прежние значения для ключей без POST + if log.catalog_work_id == catalog_work.pk and log.details: + merged = dict(log.details) + for key in details: + if f'detail_{key}' in request.POST: + merged[key] = details[key] + details = merged + details_err = validate_worklog_details(catalog_work, details) + if details_err: + return HttpResponse( + f'{details_err}', + status=400, + ) + elif log.catalog_work_id: return HttpResponse( - 'Нужно описание', - status=400, - ) - if not work_type_id or work_type_raw == '__new__': - return HttpResponse( - 'Укажите вид работ', + 'Укажите типовую работу', status=400, ) + else: + # Историческая запись без справочника + if not description: + return HttpResponse( + 'Нужно описание', + status=400, + ) + if not work_type_id or work_type_raw == '__new__': + return HttpResponse( + 'Укажите вид работ', + status=400, + ) try: log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date() @@ -991,7 +1043,12 @@ def worklog_update_view(request, pk): return HttpResponseForbidden('Укажите доступную локацию.') log.date = log_date log.technician_id = technician_id - log.work_type_id = work_type_id + if catalog_work: + log.catalog_work = catalog_work + log.details = details + log.work_type_id = None + else: + log.work_type_id = work_type_id log.description = description log.result = request.POST.get('result', '').strip() log.hours_spent = hours_spent diff --git a/apps/it_manager/work_directory.py b/apps/it_manager/work_directory.py new file mode 100644 index 0000000..efb84ad --- /dev/null +++ b/apps/it_manager/work_directory.py @@ -0,0 +1,258 @@ +"""Справочник типовых IT-работ (Directory it_work_category / it_work).""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from django.db import transaction + +from core.models.directories import Directory, DirectoryType + +CAT_TYPE = 'it_work_category' +WORK_TYPE = 'it_work' + + +def _default_json_path() -> Path: + acore_root = Path(__file__).resolve().parents[2] + sibling = ( + acore_root.parent + / 'report-tool' + / 'docs' + / 'work-directory-consolidated' + / 'work-directory.json' + ) + if sibling.is_file(): + return sibling + return Path( + '/home/lokses/Рабочий стол/apms/report-tool/docs/' + 'work-directory-consolidated/work-directory.json' + ) + + +DEFAULT_JSON = _default_json_path() + + +def load_catalog_json(path: str | Path | None = None) -> dict: + p = Path(path) if path else DEFAULT_JSON + return json.loads(p.read_text(encoding='utf-8')) + + +def detail_field_labels(catalog: dict | None = None) -> dict[str, str]: + """code → label для UI.""" + data = catalog if catalog is not None else {} + fields = data.get('detail_fields') or {} + out: dict[str, str] = {} + for code, meta in fields.items(): + if isinstance(meta, dict): + out[code] = meta.get('label') or code + else: + out[code] = str(meta) + return out + + +@transaction.atomic +def seed_from_json(path: str | Path | None = None) -> dict[str, int]: + """Идемпотентный upsert категорий и работ. Возвращает счётчики.""" + data = load_catalog_json(path) + cat_type, _ = DirectoryType.objects.get_or_create( + code=CAT_TYPE, + defaults={ + 'name': 'Категория IT-работ', + 'hierarchy_type': 'flexible', + 'description': 'Категории справочника типовых IT-работ', + }, + ) + if cat_type.hierarchy_type != 'flexible': + cat_type.hierarchy_type = 'flexible' + cat_type.save(update_fields=['hierarchy_type', 'updated_at']) + + work_type, _ = DirectoryType.objects.get_or_create( + code=WORK_TYPE, + defaults={ + 'name': 'Типовая IT-работа', + 'hierarchy_type': 'flexible', + 'description': 'Типовые работы IT-отдела', + }, + ) + if work_type.hierarchy_type != 'flexible': + work_type.hierarchy_type = 'flexible' + work_type.save(update_fields=['hierarchy_type', 'updated_at']) + + labels = detail_field_labels(data) + # храним лейблы на типе работ в description JSON-хвосте некрасиво — + # кладём в первую категорию-маркер? Лучше отдельный Directory code=_meta + meta, _ = Directory.objects.update_or_create( + directory_type=work_type, + code='_meta', + defaults={ + 'name': 'Метаданные справочника IT-работ', + 'is_active': False, + 'extra_data': { + 'detail_fields': data.get('detail_fields') or {}, + 'labels': labels, + 'source_status': data.get('status', ''), + }, + }, + ) + + cats_upserted = 0 + works_upserted = 0 + cat_by_code: dict[str, Directory] = {} + + for cat in data.get('categories') or []: + code = cat['id'] + obj, _ = Directory.objects.update_or_create( + directory_type=cat_type, + code=code, + defaults={ + 'name': cat['name'], + 'is_active': True, + 'parent': None, + 'extra_data': {}, + }, + ) + cat_by_code[code] = obj + cats_upserted += 1 + + for work in cat.get('works') or []: + wid = work['id'] + extra: dict[str, Any] = { + 'required_details': list(work.get('required_details') or []), + } + if work.get('notes'): + extra['notes'] = work['notes'] + if work.get('source_ids'): + extra['source_ids'] = work['source_ids'] + Directory.objects.update_or_create( + directory_type=work_type, + code=wid, + defaults={ + 'name': work['name'], + 'is_active': True, + 'parent': obj, + 'extra_data': extra, + }, + ) + works_upserted += 1 + + return { + 'categories': cats_upserted, + 'works': works_upserted, + 'meta_id': meta.pk, + } + + +def catalog_payload_for_ui() -> dict: + """JSON для формы: категории, работы, лейблы полей.""" + cats = list( + Directory.objects.filter(directory_type__code=CAT_TYPE, is_active=True) + .order_by('name') + ) + works = list( + Directory.objects.filter(directory_type__code=WORK_TYPE, is_active=True) + .exclude(code='_meta') + .select_related('parent') + .order_by('parent__name', 'name') + ) + meta = Directory.objects.filter( + directory_type__code=WORK_TYPE, code='_meta', + ).first() + labels = {} + if meta and isinstance(meta.extra_data, dict): + labels = meta.extra_data.get('labels') or {} + if not labels and meta.extra_data.get('detail_fields'): + labels = detail_field_labels({'detail_fields': meta.extra_data['detail_fields']}) + + return { + 'categories': [{'id': c.pk, 'code': c.code, 'name': c.name} for c in cats], + 'works': [ + { + 'id': w.pk, + 'code': w.code, + 'name': w.name, + 'category_id': w.parent_id, + 'required_details': list((w.extra_data or {}).get('required_details') or []), + 'notes': (w.extra_data or {}).get('notes') or '', + } + for w in works + ], + 'detail_labels': labels, + } + + +def required_details_for(work: Directory | None) -> list[str]: + if not work: + return [] + return list((work.extra_data or {}).get('required_details') or []) + + +def detail_labels_from_db() -> dict[str, str]: + meta = Directory.objects.filter( + directory_type__code=WORK_TYPE, code='_meta', + ).first() + if not meta or not isinstance(meta.extra_data, dict): + return {} + labels = meta.extra_data.get('labels') or {} + if labels: + return dict(labels) + if meta.extra_data.get('detail_fields'): + return detail_field_labels({'detail_fields': meta.extra_data['detail_fields']}) + return {} + + +def parse_details_from_post(post, work: Directory | None) -> dict[str, str]: + """Собрать details из POST (поля detail_).""" + details: dict[str, str] = {} + for key in required_details_for(work): + details[key] = (post.get(f'detail_{key}') or '').strip() + return details + + +def validate_worklog_details(work: Directory, details: dict | None) -> str: + """Пустая строка если ок, иначе текст ошибки.""" + details = details or {} + missing = [] + for key in required_details_for(work): + val = details.get(key) + if val is None or (isinstance(val, str) and not val.strip()): + missing.append(key) + if not missing: + return '' + labels = detail_labels_from_db() + named = [labels.get(k, k) for k in missing] + return 'Заполните обязательные уточнения: ' + ', '.join(named) + '.' + + +def format_details_short(details: dict | None, labels: dict[str, str] | None = None) -> str: + if not details: + return '' + labels = labels or {} + parts = [] + for k, v in details.items(): + if v is None or v == '': + continue + parts.append(f'{labels.get(k, k)}: {v}') + return '; '.join(parts) + + +def worklog_display_title(log, labels: dict[str, str] | None = None) -> str: + """Заголовок для списка/отчёта.""" + if getattr(log, 'catalog_work_id', None) and log.catalog_work_id: + name = log.catalog_work.name + short = format_details_short(log.details or {}, labels) + return f'{name} ({short})' if short else name + return (log.description or '').strip() or '—' + + +def get_catalog_work(pk) -> Directory | None: + if not pk: + return None + return ( + Directory.objects.filter( + pk=pk, directory_type__code=WORK_TYPE, is_active=True, + ) + .exclude(code='_meta') + .select_related('parent') + .first() + ) diff --git a/apps/reports/it_monthly/builder.py b/apps/reports/it_monthly/builder.py index 7a74571..1fd0e82 100644 --- a/apps/reports/it_monthly/builder.py +++ b/apps/reports/it_monthly/builder.py @@ -11,6 +11,7 @@ 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 @@ -272,9 +273,13 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = None) 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') + .select_related( + 'technician', 'location', 'work_type', 'ticket', + 'catalog_work', 'catalog_work__parent', + ) .prefetch_related('location__pharmacy__organization') .order_by('date', 'id') ) @@ -322,16 +327,20 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = None) systems = [] for log in tech_logs: obj = location_object_code(log.location) - if not obj: + 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, re.I | re.S) + 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 + work = log.description or '' else: - work = log.description + work = log.description or '' sys_m = SYSTEM_DESC_RE.match(work) if sys_m and (log.result or '').startswith('import:report-tool:'): @@ -339,12 +348,18 @@ def build_monthly_report(year: int, month: int, *, company: dict | None = None) 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': log.work_type.name if log.work_type_id else '', + '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, }) diff --git a/core/templatetags/common_tags.py b/core/templatetags/common_tags.py index deaf2bc..1a15f23 100644 --- a/core/templatetags/common_tags.py +++ b/core/templatetags/common_tags.py @@ -19,6 +19,17 @@ def initials(user): return f'{last}{first}' +@register.filter +def get_item(mapping, key): + """Доступ к dict по ключу: {{ labels|get_item:code }}.""" + if not mapping: + return '' + try: + return mapping.get(key, '') + except AttributeError: + return '' + + @register.filter def clean_excerpt(html_text, length=140): """Чистий текстовий уривок з HTML (тіло поста тощо): знімає теги, розкодовує diff --git a/static/assets/js/it-manager.js b/static/assets/js/it-manager.js index 0713e3a..08020f3 100644 --- a/static/assets/js/it-manager.js +++ b/static/assets/js/it-manager.js @@ -165,6 +165,84 @@ function itSetSearchableSelectValue(selectId, value) { } } +function itWorkCatalogData() { + var el = document.getElementById('it-work-catalog-data'); + if (!el) return { categories: [], works: [], detail_labels: {} }; + try { + return JSON.parse(el.textContent || '{}'); + } catch (e) { + return { categories: [], works: [], detail_labels: {} }; + } +} + +function itWorklogCategoryChanged() { + var cat = document.getElementById('it-worklog-category'); + var work = document.getElementById('it-worklog-work'); + if (!work) return; + var catId = cat ? cat.value : ''; + var data = itWorkCatalogData(); + var works = (data.works || []).filter(function (w) { + return !catId || String(w.category_id) === String(catId); + }); + work.innerHTML = ''; + var empty = document.createElement('option'); + empty.value = ''; + empty.textContent = catId ? '— Выберите работу —' : '— Сначала выберите категорию —'; + work.appendChild(empty); + works.forEach(function (w) { + var opt = document.createElement('option'); + opt.value = String(w.id); + opt.textContent = w.name; + work.appendChild(opt); + }); + itWorklogWorkChanged(); +} + +function itWorklogWorkChanged() { + var workSelect = document.getElementById('it-worklog-work'); + var detailsBox = document.getElementById('it-worklog-details'); + var notesEl = document.getElementById('it-worklog-work-notes'); + if (!detailsBox) return; + var data = itWorkCatalogData(); + var labels = data.detail_labels || {}; + var workId = workSelect ? workSelect.value : ''; + var work = (data.works || []).find(function (w) { return String(w.id) === String(workId); }); + detailsBox.innerHTML = ''; + if (!work || !(work.required_details || []).length) { + detailsBox.style.display = 'none'; + } else { + detailsBox.style.display = 'flex'; + (work.required_details || []).forEach(function (key) { + var wrap = document.createElement('div'); + var label = document.createElement('label'); + label.style.cssText = 'display:block;font-size:0.8125rem;font-weight:500;color:var(--color-foreground);margin-bottom:0.375rem;'; + label.textContent = labels[key] || key; + var star = document.createElement('span'); + star.style.color = 'var(--color-destructive)'; + star.textContent = ' *'; + label.appendChild(star); + var input = document.createElement('input'); + input.type = 'text'; + input.name = 'detail_' + key; + input.required = true; + input.className = 'input'; + input.style.width = '100%'; + wrap.appendChild(label); + wrap.appendChild(input); + detailsBox.appendChild(wrap); + }); + } + if (notesEl) { + if (work && work.notes) { + notesEl.style.display = ''; + notesEl.textContent = work.notes; + } else { + notesEl.style.display = 'none'; + notesEl.textContent = ''; + } + } +} + function itWorklogFromTicket(ticket) { if (!ticket || !ticket.id) return; itTabSwitch('worklog'); @@ -187,8 +265,8 @@ function itWorklogFromTicket(ticket) { hint.style.display = ''; hint.textContent = 'Заявка ' + ticket.number + (ticket.title ? ' — ' + ticket.title : ''); } - var typeSelect = document.getElementById('it-worklog-type'); - if (typeSelect) typeSelect.focus(); + var catSelect = document.getElementById('it-worklog-category'); + if (catSelect) catSelect.focus(); } function itWorklogTicketChanged() { diff --git a/templates/pages/it-manager.html b/templates/pages/it-manager.html index 45318e8..2551865 100644 --- a/templates/pages/it-manager.html +++ b/templates/pages/it-manager.html @@ -574,10 +574,13 @@ {% for log in ticket.work_logs.all %}
- {{ log.date|date:"d.m.Y" }} · {{ log.technician.get_full_name|default:log.technician.username }} · {{ log.work_type.name }} + + {{ log.date|date:"d.m.Y" }} · {{ log.technician.get_full_name|default:log.technician.username }} + {% if log.catalog_work %} · {{ log.catalog_work.parent.name|default:"" }}{% elif log.work_type %} · {{ log.work_type.name }}{% endif %} + {% if log.hours_spent %}{{ log.hours_spent }} {% trans "ч" %}{% endif %}
-
{{ log.description|truncatechars:160 }}
+
{{ log.display_title|truncatechars:160 }}
{% endfor %} @@ -645,6 +648,15 @@ {% endfor %} +
+ + +
{% if it_can_edit_worklogs %}

{% trans "Как руководитель вы можете править записи прямо в таблице — изменения сохраняются при смене поля." %} @@ -681,6 +693,7 @@ + {{ it_work_catalog|json_script:"it-work-catalog-data" }}

@@ -688,14 +701,22 @@
- - + + {% for cat in it_work_catalog.categories %} + {% endfor %} -
+
+ + + +
+
@@ -737,8 +758,8 @@
- - + +
@@ -772,8 +793,8 @@ {% trans "Дата" %} {% trans "Исполнитель" %} {% trans "Заявка" %} - {% trans "Вид работ" %} - {% trans "Описание" %} + {% trans "Типовая работа" %} + {% trans "Комментарий" %} {% trans "Локация" %} {% trans "Часов" %} {% if it_can_edit_worklogs %} @@ -810,17 +831,39 @@ title="{{ log.ticket.title }}">{{ log.ticket.number }} {% else %}—{% endif %} - + + {% if log.catalog_work %} + +
{{ log.catalog_work.name }}
+
{{ log.catalog_work.parent.name|default:"" }}
+ {% with req=log.catalog_work.extra_data.required_details %} + {% if req %} +
+ {% for key in req %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + {% else %} + {% trans "без справочника" %} + {% endif %} - @@ -860,8 +903,27 @@ title="{{ log.ticket.title }}">{{ log.ticket.number }} {% else %}—{% endif %} - {{ log.work_type.name }} - {{ log.description|truncatechars:120 }} + + {% if log.catalog_work %} +
{{ log.catalog_work.name }}
+
{{ log.catalog_work.parent.name|default:"" }}
+ {% if log.details %} +
+ {% for k, v in log.details.items %}{% if v %}{{ worklog_detail_labels|get_item:k|default:k }}: {{ v }}{% if not forloop.last %}; {% endif %}{% endif %}{% endfor %} +
+ {% endif %} + {% else %} + {% trans "без справочника" %} +
{% if log.work_type %}{{ log.work_type.name }}{% endif %}
+ {% endif %} + + + {% if log.catalog_work %} + {{ log.description|truncatechars:120|default:"—" }} + {% else %} + {{ log.description|truncatechars:120 }} + {% endif %} + {{ log.location|default:"—" }} {{ log.hours_spent|default:"—" }}