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
+106 -49
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,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'<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,
)
if not work_type_id or work_type_raw == '__new__':
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите вид работ</span>',
'<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>',
status=400,
)
if not work_type_id or work_type_raw == '__new__':
return HttpResponse(
'<span style="color:var(--color-destructive); font-size:0.75rem;">Укажите вид работ</span>',
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