it_manager: справочник типовых работ в журнале WorkLog.

Категория/работа/required_details вместо свободного work_type; seed Directory, отчёт и тесты.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 13:38:07 +03:00
parent be7e5853cb
commit 31bd4c8fa7
11 changed files with 822 additions and 112 deletions
+24 -27
View File
@@ -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(
'<span style="color:{};font-weight:bold;">{}</span>',
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
@@ -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})'
))
@@ -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='Уточнения'),
),
]
+22 -4
View File
@@ -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):
+139 -7
View File
@@ -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-редактирование для руководителя."""
+67 -10
View File
@@ -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,14 +907,16 @@ 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:
details_err = validate_worklog_details(catalog_work, details)
if details_err:
worklog_error = details_err
else:
try:
log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date()
@@ -915,7 +937,9 @@ def worklog_create_view(request):
WorkLog.objects.create(
date=log_date,
technician=request.user,
work_type_id=work_type_id,
catalog_work=catalog_work,
details=details,
work_type_id=None,
location_id=location_id,
equipment_id=equipment_id,
ticket_id=ticket_id,
@@ -953,9 +977,37 @@ 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
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'<span style="color:var(--color-destructive); font-size:0.75rem;">{details_err}</span>',
status=400,
)
elif log.catalog_work_id:
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите типовую работу</span>',
status=400,
)
else:
# Историческая запись без справочника
if not description:
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Нужно описание</span>',
@@ -991,6 +1043,11 @@ def worklog_update_view(request, pk):
return HttpResponseForbidden('Укажите доступную локацию.')
log.date = log_date
log.technician_id = technician_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()
+258
View File
@@ -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_<code>)."""
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()
)
+21 -6
View File
@@ -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,
})
+11
View File
@@ -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 (тіло поста тощо): знімає теги, розкодовує
+80 -2
View File
@@ -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() {
+79 -17
View File
@@ -574,10 +574,13 @@
{% for log in ticket.work_logs.all %}
<div style="font-size:0.8125rem; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.5rem; padding:0.5rem 0.75rem;">
<div style="display:flex; justify-content:space-between; gap:0.5rem; color:var(--color-muted-foreground); font-size:0.75rem;">
<span>{{ log.date|date:"d.m.Y" }} · {{ log.technician.get_full_name|default:log.technician.username }} · {{ log.work_type.name }}</span>
<span>
{{ 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 %}
</span>
<span>{% if log.hours_spent %}{{ log.hours_spent }} {% trans "ч" %}{% endif %}</span>
</div>
<div style="color:var(--color-foreground); margin-top:0.2rem;">{{ log.description|truncatechars:160 }}</div>
<div style="color:var(--color-foreground); margin-top:0.2rem;">{{ log.display_title|truncatechars:160 }}</div>
</div>
{% endfor %}
</div>
@@ -645,6 +648,15 @@
{% endfor %}
</select>
</div>
<div>
<label for="it-worklog-filter-cat" style="display:block; font-size:0.75rem; font-weight:500; color:var(--color-muted-foreground); margin-bottom:0.25rem;">{% trans "Категория" %}</label>
<select id="it-worklog-filter-cat" name="worklog_category" class="input" style="min-width:14rem; width:auto;">
<option value="">{% trans "Все категории" %}</option>
{% for cat in it_work_catalog.categories %}
<option value="{{ cat.id }}" {% if worklog_category_id == cat.id %}selected{% endif %}>{{ cat.name }}</option>
{% endfor %}
</select>
</div>
{% if it_can_edit_worklogs %}
<p style="font-size:0.75rem; color:var(--color-muted-foreground); margin:0 0 0.5rem; max-width:18rem;">
{% trans "Как руководитель вы можете править записи прямо в таблице — изменения сохраняются при смене поля." %}
@@ -681,6 +693,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
{{ it_work_catalog|json_script:"it-work-catalog-data" }}
<div style="padding:1.25rem; display:flex; flex-direction:column; gap:1rem;">
<div id="it-worklog-ticket-hint" style="display:none; padding:0.625rem 0.75rem; border-radius:0.5rem; background:color-mix(in srgb, var(--color-primary) 8%, var(--color-card)); border:1px solid color-mix(in srgb, var(--color-primary) 25%, var(--color-border)); font-size:0.8125rem; color:var(--color-foreground);"></div>
<div>
@@ -688,14 +701,22 @@
<input type="date" name="date" id="it-worklog-date" value="{% now 'Y-m-d' %}" class="input" style="width:100%;">
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Вид работ" %} <span style="color:var(--color-destructive);">*</span></label>
<select name="work_type" id="it-worklog-type" required class="input" style="width:100%;" onchange="dqaHandleChange(this, 'worklog_type', 'code')">
{% for value, label in worklog_types %}
<option value="{{ value }}">{{ label }}</option>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Категория" %} <span style="color:var(--color-destructive);">*</span></label>
<select id="it-worklog-category" class="input" style="width:100%;" onchange="itWorklogCategoryChanged()">
<option value="">{% trans "— Выберите категорию —" %}</option>
{% for cat in it_work_catalog.categories %}
<option value="{{ cat.id }}">{{ cat.name }}</option>
{% endfor %}
<option value="__new__">{% trans "+ Добавить новый вид работ..." %}</option>
</select>
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Типовая работа" %} <span style="color:var(--color-destructive);">*</span></label>
<select name="catalog_work_id" id="it-worklog-work" required class="input" style="width:100%;" onchange="itWorklogWorkChanged()">
<option value="">{% trans "— Сначала выберите категорию —" %}</option>
</select>
<div id="it-worklog-work-notes" style="display:none; font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.375rem;"></div>
</div>
<div id="it-worklog-details" style="display:none; flex-direction:column; gap:0.75rem;"></div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Заявка" %}</label>
<div class="searchable-select" data-placeholder="{% trans 'Поиск заявки по номеру или теме...' %}">
@@ -737,8 +758,8 @@
</select>
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Описание работ" %} <span style="color:var(--color-destructive);">*</span></label>
<textarea name="description" id="it-worklog-description" required style="width:100%; min-height:4rem; resize:vertical; background:var(--color-card); border:1px solid var(--color-input); border-radius:var(--radius); padding:0.4375rem 0.75rem; font-size:0.875rem; color:var(--color-foreground); font-family:var(--font-sans); outline:none; box-sizing:border-box;"></textarea>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Комментарий" %} <span style="font-weight:400; color:var(--color-muted-foreground);">{% trans "(необязательно)" %}</span></label>
<textarea name="description" id="it-worklog-description" style="width:100%; min-height:3rem; resize:vertical; background:var(--color-card); border:1px solid var(--color-input); border-radius:var(--radius); padding:0.4375rem 0.75rem; font-size:0.875rem; color:var(--color-foreground); font-family:var(--font-sans); outline:none; box-sizing:border-box;"></textarea>
</div>
<div>
<label style="display:block; font-size:0.8125rem; font-weight:500; color:var(--color-foreground); margin-bottom:0.375rem;">{% trans "Результат" %}</label>
@@ -772,8 +793,8 @@
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Дата" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Исполнитель" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Заявка" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Вид работ" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Описание" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Типовая работа" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Комментарий" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Локация" %}</th>
<th style="padding:0.75rem 1rem;text-align:left;color:var(--color-muted-foreground);font-weight:600;font-size:0.8125rem;">{% trans "Часов" %}</th>
{% if it_can_edit_worklogs %}
@@ -810,17 +831,39 @@
title="{{ log.ticket.title }}">{{ log.ticket.number }}</button>
{% else %}—{% endif %}
</td>
<td style="padding:0.5rem 0.75rem;">
<td style="padding:0.5rem 0.75rem; min-width:12rem; max-width:16rem;">
{% if log.catalog_work %}
<input type="hidden" form="worklog-form-{{ log.pk }}" name="catalog_work_id" value="{{ log.catalog_work_id }}">
<div style="font-size:0.8125rem; color:var(--color-foreground); font-weight:500;">{{ log.catalog_work.name }}</div>
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{{ log.catalog_work.parent.name|default:"" }}</div>
{% with req=log.catalog_work.extra_data.required_details %}
{% if req %}
<div style="display:flex; flex-direction:column; gap:0.25rem; margin-top:0.375rem;">
{% for key in req %}
<label style="font-size:0.6875rem; color:var(--color-muted-foreground);">
{{ worklog_detail_labels|get_item:key|default:key }}
<input form="worklog-form-{{ log.pk }}" type="text" name="detail_{{ key }}"
value="{{ log.details|get_item:key }}"
class="input" style="width:100%; font-size:0.75rem; padding:0.2rem 0.35rem; margin-top:0.1rem;"
onchange="this.form.requestSubmit()">
</label>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% else %}
<span class="badge badge-muted" style="margin-bottom:0.25rem;">{% trans "без справочника" %}</span>
<select form="worklog-form-{{ log.pk }}" name="work_type" class="input"
style="min-width:8rem; max-width:11rem; font-size:0.8125rem; padding:0.3rem 0.4rem;"
style="min-width:8rem; max-width:11rem; font-size:0.8125rem; padding:0.3rem 0.4rem; display:block;"
onchange="this.form.requestSubmit()">
{% for value, label in worklog_types %}
<option value="{{ value }}" {% if log.work_type.code == value %}selected{% endif %}>{{ label }}</option>
<option value="{{ value }}" {% if log.work_type_id and log.work_type.code == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
{% endif %}
</td>
<td style="padding:0.5rem 0.75rem; min-width:14rem;">
<textarea form="worklog-form-{{ log.pk }}" name="description" rows="2" required
<textarea form="worklog-form-{{ log.pk }}" name="description" rows="2"
class="input" style="width:100%; min-width:12rem; font-size:0.8125rem; padding:0.3rem 0.4rem; resize:vertical;"
onchange="this.form.requestSubmit()">{{ log.description }}</textarea>
</td>
@@ -860,8 +903,27 @@
title="{{ log.ticket.title }}">{{ log.ticket.number }}</button>
{% else %}—{% endif %}
</td>
<td style="padding:0.625rem 1rem; color:var(--color-muted-foreground);">{{ log.work_type.name }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground); max-width:22rem;">{{ log.description|truncatechars:120 }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground); max-width:16rem;">
{% if log.catalog_work %}
<div>{{ log.catalog_work.name }}</div>
<div style="font-size:0.75rem; color:var(--color-muted-foreground);">{{ log.catalog_work.parent.name|default:"" }}</div>
{% if log.details %}
<div style="font-size:0.75rem; color:var(--color-muted-foreground); margin-top:0.2rem;">
{% 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 %}
</div>
{% endif %}
{% else %}
<span class="badge badge-muted">{% trans "без справочника" %}</span>
<div style="font-size:0.8125rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{% if log.work_type %}{{ log.work_type.name }}{% endif %}</div>
{% endif %}
</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground); max-width:22rem;">
{% if log.catalog_work %}
{{ log.description|truncatechars:120|default:"—" }}
{% else %}
{{ log.description|truncatechars:120 }}
{% endif %}
</td>
<td style="padding:0.625rem 1rem; color:var(--color-muted-foreground); font-size:0.8125rem;">{{ log.location|default:"—" }}</td>
<td style="padding:0.625rem 1rem; color:var(--color-foreground);">{{ log.hours_spent|default:"—" }}</td>
</tr>