it_manager+reports: иерархия договоров, биллинг и месячные отчёты
Иерархия рамочных/дочерних договоров и платежей, интернет-биллинг, месячные IT-отчёты (импорт, DOCX/PDF, шаблоны выводов). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+459
-79
@@ -1,10 +1,12 @@
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
import calendar
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.db.models import Q, Sum
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.http import HttpResponse, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
|
||||
@@ -13,14 +15,89 @@ from apps.it_manager.models import (
|
||||
InternetContract, InternetProvider,
|
||||
ServiceCall, Ticket, TicketComment, WorkLog,
|
||||
)
|
||||
from apps.it_manager.monthly_report import (
|
||||
MONTHS_RU,
|
||||
user_can_view_it_monthly_report,
|
||||
)
|
||||
from apps.reports.it_monthly import parse_month_id
|
||||
from apps.locations.models.base import Location
|
||||
from apps.locations.models.pharmacies import Pharmacy
|
||||
from core.models.directories import Directory
|
||||
from core.portal_access import deny_section_access
|
||||
from core.utils.htmx import render_page
|
||||
from core.utils.pagination import paginate
|
||||
|
||||
Employee = get_user_model()
|
||||
|
||||
|
||||
def _it_locations_qs():
|
||||
"""Локации для селектов IT: аптеки с организацией (чтобы отличать №12 у разных юрлиц)."""
|
||||
return (
|
||||
Location.objects.filter(is_active=True)
|
||||
.select_related('location_region')
|
||||
.prefetch_related(
|
||||
models.Prefetch(
|
||||
'pharmacy',
|
||||
queryset=Pharmacy.objects.select_related('organization'),
|
||||
),
|
||||
)
|
||||
.order_by('location_type', 'address')
|
||||
)
|
||||
|
||||
|
||||
def _split_it_locations(locations):
|
||||
pharmacies, offices = [], []
|
||||
for loc in locations:
|
||||
if loc.location_type == 'pharmacy':
|
||||
pharmacies.append(loc)
|
||||
else:
|
||||
# office + warehouse → «Офис / отдел»
|
||||
offices.append(loc)
|
||||
return pharmacies, offices
|
||||
|
||||
|
||||
def _infer_assign_target(equipment) -> str:
|
||||
if equipment.assigned_to_id:
|
||||
return 'employee'
|
||||
if equipment.location_id:
|
||||
loc = getattr(equipment, 'location', None)
|
||||
if loc is not None and loc.location_type == 'pharmacy':
|
||||
return 'pharmacy'
|
||||
return 'office'
|
||||
return ''
|
||||
|
||||
|
||||
def _parse_assign_post(request) -> tuple[str, str | None, str | None, str | None]:
|
||||
"""assign_target, location_id, assigned_to_id, error_message."""
|
||||
target = (request.POST.get('assign_target') or '').strip()
|
||||
location_id = request.POST.get('location_id') or None
|
||||
assigned_to_id = request.POST.get('assigned_to_id') or None
|
||||
|
||||
if not target:
|
||||
return '', None, None, None
|
||||
|
||||
if target == 'employee':
|
||||
if not assigned_to_id:
|
||||
return target, None, None, 'Оберіть співробітника.'
|
||||
return target, None, assigned_to_id, None
|
||||
|
||||
if target in ('pharmacy', 'office'):
|
||||
if not location_id:
|
||||
return target, None, None, (
|
||||
'Оберіть аптеку.' if target == 'pharmacy' else 'Оберіть офіс / відділ.'
|
||||
)
|
||||
loc = Location.objects.filter(pk=location_id, is_active=True).first()
|
||||
if not loc:
|
||||
return target, None, None, 'Локацію не знайдено.'
|
||||
if target == 'pharmacy' and loc.location_type != 'pharmacy':
|
||||
return target, None, None, 'Оберіть аптеку зі списку аптек.'
|
||||
if target == 'office' and loc.location_type == 'pharmacy':
|
||||
return target, None, None, 'Оберіть офіс / відділ зі списку.'
|
||||
return target, str(loc.pk), None, None
|
||||
|
||||
return target, None, None, 'Невідомий тип видачі.'
|
||||
|
||||
|
||||
def _is_it_staff(user):
|
||||
from core.portal_access import user_can_access
|
||||
return user_can_access(user, 'it_manager')
|
||||
@@ -53,6 +130,55 @@ def _resolve_directory(type_code, code):
|
||||
return Directory.objects.filter(directory_type__code=type_code, code=code).values_list('id', flat=True).first()
|
||||
|
||||
|
||||
def _close_open_assignments(equipment):
|
||||
"""Закрити всі активні записи історії переміщень для одиниці."""
|
||||
EquipmentAssignment.objects.filter(
|
||||
equipment=equipment, returned_at__isnull=True,
|
||||
).update(returned_at=timezone.now())
|
||||
|
||||
|
||||
def _apply_equipment_issue(unit, *, location_id=None, assigned_to_id=None, user=None,
|
||||
set_location=False, set_assignee=False, force_assigned_status=True):
|
||||
"""Видача / переміщення одиниці з записом історії.
|
||||
|
||||
set_location / set_assignee — чи оновлювати відповідне поле
|
||||
(для bulk: міняємо лише те, що передали у формі).
|
||||
"""
|
||||
new_location = location_id if set_location else unit.location_id
|
||||
new_assignee = assigned_to_id if set_assignee else unit.assigned_to_id
|
||||
|
||||
loc_changed = str(unit.location_id or '') != str(new_location or '')
|
||||
emp_changed = str(unit.assigned_to_id or '') != str(new_assignee or '')
|
||||
if not loc_changed and not emp_changed:
|
||||
return False
|
||||
|
||||
_close_open_assignments(unit)
|
||||
if set_location:
|
||||
unit.location_id = location_id or None
|
||||
if set_assignee:
|
||||
unit.assigned_to_id = assigned_to_id or None
|
||||
|
||||
has_target = bool(unit.location_id or unit.assigned_to_id)
|
||||
if force_assigned_status and has_target:
|
||||
assigned_status_id = _resolve_directory('equipment_status', 'assigned')
|
||||
if assigned_status_id:
|
||||
unit.status_id = assigned_status_id
|
||||
elif not has_target:
|
||||
stock_id = _resolve_directory('equipment_status', 'stock')
|
||||
if stock_id:
|
||||
unit.status_id = stock_id
|
||||
|
||||
unit.save()
|
||||
if has_target:
|
||||
EquipmentAssignment.objects.create(
|
||||
equipment=unit,
|
||||
assigned_to_id=unit.assigned_to_id,
|
||||
location_id=unit.location_id,
|
||||
assigned_by=user,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _it_manager_context(request):
|
||||
equipment_qs = (
|
||||
Equipment.objects.select_related(
|
||||
@@ -73,10 +199,22 @@ def _it_manager_context(request):
|
||||
.annotate(units_total=models.Count('units'))
|
||||
.order_by('equipment_type__name', 'brand__name', 'name')
|
||||
)
|
||||
locations_all = list(_it_locations_qs())
|
||||
it_pharmacies, it_offices = _split_it_locations(locations_all)
|
||||
|
||||
tickets_qs = (
|
||||
Ticket.objects.select_related('requester', 'assignee', 'equipment', 'ticket_type', 'priority', 'status')
|
||||
.prefetch_related('comments__author')
|
||||
Ticket.objects.select_related(
|
||||
'requester', 'assignee', 'equipment', 'location',
|
||||
'ticket_type', 'priority', 'status',
|
||||
)
|
||||
.prefetch_related(
|
||||
'comments__author',
|
||||
models.Prefetch(
|
||||
'work_logs',
|
||||
queryset=WorkLog.objects.select_related('technician', 'work_type').order_by('-date', '-created_at'),
|
||||
),
|
||||
)
|
||||
.annotate(worklogs_count=models.Count('work_logs', distinct=True))
|
||||
.order_by('-created_at')
|
||||
)
|
||||
tickets = list(tickets_qs)
|
||||
@@ -87,14 +225,92 @@ def _it_manager_context(request):
|
||||
ticket_overdue_count = sum(1 for t in tickets if t.is_overdue)
|
||||
tickets_page = paginate(request, tickets_qs, page_param='tickets_page', size_param='tickets_per_page')
|
||||
|
||||
# Для формы журнала: сначала открытые заявки, потом остальные
|
||||
open_ticket_codes = ('new', 'in_progress', 'waiting')
|
||||
worklog_tickets = sorted(
|
||||
tickets,
|
||||
key=lambda t: (0 if t.status.code in open_ticket_codes else 1, -t.pk),
|
||||
)
|
||||
|
||||
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_month_raw = (request.GET.get('worklog_month') or '').strip()
|
||||
worklog_month_start = None
|
||||
worklog_month_end = None
|
||||
if worklog_month_raw and worklog_month_raw != 'all':
|
||||
y, m = parse_month_id(worklog_month_raw)
|
||||
last_day = calendar.monthrange(y, m)[1]
|
||||
worklog_month_start = date(y, m, 1)
|
||||
worklog_month_end = date(y, m, last_day)
|
||||
worklog_month_id = f'{y:04d}-{m:02d}'
|
||||
else:
|
||||
worklog_month_id = 'all'
|
||||
|
||||
worklogs_qs = (
|
||||
WorkLog.objects.select_related('technician', 'location', 'equipment', 'ticket', 'work_type')
|
||||
WorkLog.objects.select_related(
|
||||
'technician', 'location', 'equipment', 'ticket', 'work_type',
|
||||
)
|
||||
.prefetch_related(
|
||||
models.Prefetch(
|
||||
'location__pharmacy',
|
||||
queryset=Pharmacy.objects.select_related('organization'),
|
||||
),
|
||||
)
|
||||
.order_by('-date', '-created_at')
|
||||
)
|
||||
if worklog_technician_id:
|
||||
worklogs_qs = worklogs_qs.filter(technician_id=worklog_technician_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')
|
||||
month_hours = WorkLog.objects.filter(date__gte=month_start).aggregate(total=Sum('hours_spent'))['total'] or 0
|
||||
month_count = WorkLog.objects.filter(date__gte=month_start).count()
|
||||
|
||||
# Статистика: выбранный месяц или текущий календарный
|
||||
if worklog_month_start and worklog_month_end:
|
||||
stats_start, stats_end = worklog_month_start, worklog_month_end
|
||||
worklog_stats_label = (
|
||||
f'{MONTHS_RU[worklog_month_start.month]} {worklog_month_start.year}'
|
||||
)
|
||||
else:
|
||||
stats_start, stats_end = month_start, None
|
||||
worklog_stats_label = 'этом месяце'
|
||||
|
||||
month_logs = WorkLog.objects.filter(date__gte=stats_start)
|
||||
if stats_end is not None:
|
||||
month_logs = month_logs.filter(date__lte=stats_end)
|
||||
if worklog_technician_id:
|
||||
month_logs = month_logs.filter(technician_id=worklog_technician_id)
|
||||
month_hours = month_logs.aggregate(total=Sum('hours_spent'))['total'] or 0
|
||||
month_count = month_logs.count()
|
||||
|
||||
worklog_month_choices = []
|
||||
seen_months = set()
|
||||
for d in WorkLog.objects.dates('date', 'month', order='DESC'):
|
||||
mid = f'{d.year:04d}-{d.month:02d}'
|
||||
seen_months.add(mid)
|
||||
worklog_month_choices.append((mid, f'{MONTHS_RU[d.month]} {d.year}'))
|
||||
today = timezone.localdate()
|
||||
current_mid = f'{today.year:04d}-{today.month:02d}'
|
||||
if current_mid not in seen_months:
|
||||
worklog_month_choices.insert(
|
||||
0, (current_mid, f'{MONTHS_RU[today.month]} {today.year}'),
|
||||
)
|
||||
|
||||
worklog_technicians = (
|
||||
Employee.objects.filter(is_active=True)
|
||||
.filter(
|
||||
Q(pk__in=WorkLog.objects.values('technician_id'))
|
||||
| Q(groups__name='IT')
|
||||
| Q(is_staff=True)
|
||||
| Q(is_superuser=True)
|
||||
)
|
||||
.distinct()
|
||||
.order_by('last_name', 'first_name')
|
||||
)
|
||||
can_edit_worklogs = user_can_view_it_monthly_report(request.user)
|
||||
|
||||
consumables_qs = (
|
||||
Consumable.objects.select_related('consumable_type', 'color', 'status', 'equipment', 'location')
|
||||
@@ -108,8 +324,11 @@ def _it_manager_context(request):
|
||||
providers = list(InternetProvider.objects.order_by('name'))
|
||||
|
||||
contracts_qs = (
|
||||
InternetContract.objects.select_related('provider', 'location', 'service_type', 'connection_type', 'status')
|
||||
.order_by('location__address', 'provider__name')
|
||||
InternetContract.objects.select_related(
|
||||
'provider', 'location', 'service_type', 'connection_type', 'status', 'parent_contract',
|
||||
)
|
||||
.prefetch_related('child_contracts')
|
||||
.order_by('provider__name', 'contract_number')
|
||||
)
|
||||
contracts = list(contracts_qs)
|
||||
contracts_expiring_count = sum(1 for c in contracts if c.is_expiring_soon)
|
||||
@@ -132,16 +351,14 @@ def _it_manager_context(request):
|
||||
'equipment_statuses': equipment_statuses,
|
||||
'equipment_brands': Directory.objects.filter(directory_type__code='equipment_brand', is_active=True).order_by('name'),
|
||||
'equipment_models': equipment_models,
|
||||
'it_locations': (
|
||||
Location.objects.filter(is_active=True)
|
||||
.select_related('location_region')
|
||||
.prefetch_related('pharmacy')
|
||||
.order_by('location_type', 'address')
|
||||
),
|
||||
'it_locations': locations_all,
|
||||
'it_pharmacies': it_pharmacies,
|
||||
'it_offices': it_offices,
|
||||
'it_employees': Employee.objects.filter(is_active=True).order_by('last_name', 'first_name'),
|
||||
|
||||
'tickets': tickets,
|
||||
'tickets_page': tickets_page,
|
||||
'worklog_tickets': worklog_tickets,
|
||||
'ticket_counts': ticket_counts,
|
||||
'ticket_overdue_count': ticket_overdue_count,
|
||||
'ticket_statuses': ticket_statuses,
|
||||
@@ -151,6 +368,12 @@ def _it_manager_context(request):
|
||||
|
||||
'worklogs_page': worklogs_page,
|
||||
'worklog_types': _directory_choices('worklog_type'),
|
||||
'worklog_technicians': worklog_technicians,
|
||||
'worklog_technician_id': worklog_technician_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,
|
||||
'month_hours': month_hours,
|
||||
'month_count': month_count,
|
||||
|
||||
@@ -169,18 +392,38 @@ def _it_manager_context(request):
|
||||
'contract_service_types': _directory_choices('contract_service_type'),
|
||||
'contract_connection_types': _directory_choices('contract_connection_type'),
|
||||
'contract_statuses': _directory_choices('contract_status'),
|
||||
'framework_contracts': list(
|
||||
InternetContract.objects.filter(is_framework=True)
|
||||
.select_related('provider')
|
||||
.order_by('provider__name', 'contract_number')
|
||||
),
|
||||
|
||||
'service_calls_page': service_calls_page,
|
||||
'service_calls_count': service_calls_count,
|
||||
'servicecall_channels': _directory_choices('servicecall_channel'),
|
||||
|
||||
'it_can_view_monthly_report': user_can_view_it_monthly_report(request.user),
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def it_manager_view(request):
|
||||
if not _is_it_staff(request.user):
|
||||
return HttpResponseForbidden()
|
||||
return render_page(request, 'it-manager.html', _it_manager_context(request))
|
||||
return deny_section_access(request)
|
||||
context = _it_manager_context(request)
|
||||
tab = (request.GET.get('tab') or '').strip()
|
||||
if tab in {
|
||||
'equipment', 'tickets', 'worklog', 'consumables',
|
||||
'providers', 'servicecalls',
|
||||
}:
|
||||
context['it_active_tab'] = tab
|
||||
elif (
|
||||
'worklog_technician' in request.GET
|
||||
or 'worklog_month' in request.GET
|
||||
or request.GET.get('worklogs_page')
|
||||
):
|
||||
context['it_active_tab'] = 'worklog'
|
||||
return render_page(request, 'it-manager.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -253,9 +496,18 @@ def equipment_bulk_create_view(request):
|
||||
warranty_until = d.replace(year=year, month=month % 12 + 1, day=day)
|
||||
except ValueError:
|
||||
warranty_until = None
|
||||
location_id = request.POST.get('location_id') or None
|
||||
# якщо одразу видаємо на локацію — статус assigned, інакше stock
|
||||
status_code = 'assigned' if location_id else 'stock'
|
||||
location_id = None
|
||||
assigned_to_id = None
|
||||
target, location_id, assigned_to_id, assign_err = _parse_assign_post(request)
|
||||
if assign_err:
|
||||
context_extra['equipment_form_error'] = assign_err
|
||||
context = _it_manager_context(request)
|
||||
context['it_active_tab'] = 'equipment'
|
||||
context.update(context_extra)
|
||||
return render_page(request, 'it-manager.html', context)
|
||||
|
||||
# якщо одразу видаємо — статус assigned, інакше stock
|
||||
status_code = 'assigned' if (location_id or assigned_to_id) else 'stock'
|
||||
status_id = _resolve_directory('equipment_status', status_code) \
|
||||
or _resolve_directory('equipment_status', 'stock')
|
||||
|
||||
@@ -267,17 +519,27 @@ def equipment_bulk_create_view(request):
|
||||
for s in serials:
|
||||
if s in existing:
|
||||
continue
|
||||
Equipment.objects.create(
|
||||
unit = Equipment.objects.create(
|
||||
equipment_model=model,
|
||||
serial_number=s,
|
||||
status_id=status_id,
|
||||
location_id=location_id,
|
||||
assigned_to_id=assigned_to_id,
|
||||
purchase_date=purchase_date,
|
||||
warranty_until=warranty_until,
|
||||
)
|
||||
if location_id or assigned_to_id:
|
||||
EquipmentAssignment.objects.create(
|
||||
equipment=unit,
|
||||
location_id=location_id,
|
||||
assigned_to_id=assigned_to_id,
|
||||
assigned_by=request.user,
|
||||
)
|
||||
created += 1
|
||||
skipped = len(serials) - created
|
||||
msg = f'Додано {created} од. «{model}»'
|
||||
if (location_id or assigned_to_id) and created:
|
||||
msg += ' і видано'
|
||||
if skipped:
|
||||
msg += f'; пропущено {skipped} (серійник вже існує)'
|
||||
context_extra['equipment_form_message'] = msg
|
||||
@@ -290,45 +552,34 @@ def equipment_bulk_create_view(request):
|
||||
|
||||
@login_required
|
||||
def equipment_bulk_assign_view(request):
|
||||
"""Масова видача: відмічені екземпляри → локація (та/або співробітник)
|
||||
за один крок, з записом у історію переміщень."""
|
||||
"""Масова видача: тип (аптека / офіс / співробітник) → список."""
|
||||
if not _is_it_staff(request.user):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
context_extra = {}
|
||||
if request.method == 'POST':
|
||||
ids = request.POST.getlist('equipment_ids')
|
||||
location_id = request.POST.get('location_id') or None
|
||||
assigned_to_id = request.POST.get('assigned_to_id') or None
|
||||
target, location_id, assigned_to_id, assign_err = _parse_assign_post(request)
|
||||
|
||||
if not ids:
|
||||
context_extra['equipment_form_error'] = 'Відмітьте хоча б один екземпляр.'
|
||||
elif not location_id and not assigned_to_id:
|
||||
context_extra['equipment_form_error'] = 'Оберіть локацію або співробітника для видачі.'
|
||||
elif not target:
|
||||
context_extra['equipment_form_error'] = 'Оберіть тип видачі.'
|
||||
elif assign_err:
|
||||
context_extra['equipment_form_error'] = assign_err
|
||||
else:
|
||||
assigned_status_id = _resolve_directory('equipment_status', 'assigned')
|
||||
units = Equipment.objects.filter(pk__in=ids)
|
||||
count = 0
|
||||
for unit in units:
|
||||
# закриваємо попередню активну видачу, якщо була
|
||||
if unit.assigned_to_id:
|
||||
EquipmentAssignment.objects.filter(
|
||||
equipment=unit, assigned_to_id=unit.assigned_to_id, returned_at__isnull=True,
|
||||
).update(returned_at=timezone.now())
|
||||
if location_id:
|
||||
unit.location_id = location_id
|
||||
if assigned_to_id:
|
||||
unit.assigned_to_id = assigned_to_id
|
||||
if assigned_status_id:
|
||||
unit.status_id = assigned_status_id
|
||||
unit.save()
|
||||
EquipmentAssignment.objects.create(
|
||||
equipment=unit,
|
||||
assigned_to_id=assigned_to_id,
|
||||
if _apply_equipment_issue(
|
||||
unit,
|
||||
location_id=location_id,
|
||||
assigned_by=request.user,
|
||||
)
|
||||
count += 1
|
||||
assigned_to_id=assigned_to_id,
|
||||
user=request.user,
|
||||
set_location=True,
|
||||
set_assignee=True,
|
||||
):
|
||||
count += 1
|
||||
context_extra['equipment_form_message'] = f'Видано {count} од. обладнання'
|
||||
|
||||
context = _it_manager_context(request)
|
||||
@@ -345,24 +596,41 @@ def equipment_update_view(request, pk):
|
||||
if request.method == 'POST':
|
||||
equipment = get_object_or_404(Equipment, pk=pk)
|
||||
|
||||
new_status_id = _resolve_directory('equipment_status', request.POST.get('status'))
|
||||
if new_status_id:
|
||||
equipment.status_id = new_status_id
|
||||
new_status_code = request.POST.get('status') or ''
|
||||
new_status_id = _resolve_directory('equipment_status', new_status_code)
|
||||
target, location_id, assigned_to_id, assign_err = _parse_assign_post(request)
|
||||
|
||||
new_assigned_to = request.POST.get('assigned_to_id') or None
|
||||
if str(equipment.assigned_to_id or '') != str(new_assigned_to or ''):
|
||||
if equipment.assigned_to_id:
|
||||
EquipmentAssignment.objects.filter(
|
||||
equipment=equipment, assigned_to_id=equipment.assigned_to_id, returned_at__isnull=True,
|
||||
).update(returned_at=timezone.now())
|
||||
equipment.assigned_to_id = new_assigned_to
|
||||
if new_assigned_to:
|
||||
EquipmentAssignment.objects.create(
|
||||
equipment=equipment, assigned_to_id=new_assigned_to,
|
||||
location=equipment.location, assigned_by=request.user,
|
||||
)
|
||||
|
||||
equipment.save()
|
||||
# Повернення на склад: статус stock або порожній тип видачі
|
||||
if new_status_code == 'stock' or not target:
|
||||
_close_open_assignments(equipment)
|
||||
if new_status_code == 'stock' and new_status_id:
|
||||
equipment.status_id = new_status_id
|
||||
else:
|
||||
stock_id = _resolve_directory('equipment_status', 'stock')
|
||||
if stock_id:
|
||||
equipment.status_id = stock_id
|
||||
equipment.location_id = None
|
||||
equipment.assigned_to_id = None
|
||||
equipment.save()
|
||||
elif assign_err:
|
||||
context = _it_manager_context(request)
|
||||
context['it_active_tab'] = 'equipment'
|
||||
context['equipment_form_error'] = assign_err
|
||||
return render_page(request, 'it-manager.html', context)
|
||||
else:
|
||||
_apply_equipment_issue(
|
||||
equipment,
|
||||
location_id=location_id,
|
||||
assigned_to_id=assigned_to_id,
|
||||
user=request.user,
|
||||
set_location=True,
|
||||
set_assignee=True,
|
||||
force_assigned_status=True,
|
||||
)
|
||||
equipment.refresh_from_db()
|
||||
if new_status_id and new_status_code not in ('', 'stock') and equipment.status.code != new_status_code:
|
||||
equipment.status_id = new_status_id
|
||||
equipment.save(update_fields=['status_id', 'updated_at'])
|
||||
|
||||
context = _it_manager_context(request)
|
||||
context['it_active_tab'] = 'equipment'
|
||||
@@ -413,11 +681,18 @@ def worklog_create_view(request):
|
||||
if not _is_it_staff(request.user):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
worklog_error = ''
|
||||
worklog_message = ''
|
||||
if request.method == 'POST':
|
||||
description = request.POST.get('description', '').strip()
|
||||
work_type_id = _resolve_directory('worklog_type', request.POST.get('work_type', ''))
|
||||
work_type_raw = request.POST.get('work_type', '')
|
||||
work_type_id = _resolve_directory('worklog_type', work_type_raw)
|
||||
|
||||
if description and work_type_id:
|
||||
if not description:
|
||||
worklog_error = 'Укажите описание работ.'
|
||||
elif not work_type_id or work_type_raw == '__new__':
|
||||
worklog_error = 'Выберите вид работ.'
|
||||
else:
|
||||
try:
|
||||
log_date = datetime.strptime(request.POST.get('date', ''), '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
@@ -427,23 +702,104 @@ def worklog_create_view(request):
|
||||
except ValueError:
|
||||
hours_spent = None
|
||||
|
||||
WorkLog.objects.create(
|
||||
date=log_date,
|
||||
technician=request.user,
|
||||
work_type_id=work_type_id,
|
||||
location_id=request.POST.get('location_id') or None,
|
||||
equipment_id=request.POST.get('equipment_id') or None,
|
||||
ticket_id=request.POST.get('ticket_id') or None,
|
||||
description=description,
|
||||
result=request.POST.get('result', '').strip(),
|
||||
hours_spent=hours_spent,
|
||||
)
|
||||
location_id = request.POST.get('location_id') or None
|
||||
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 = 'Не удалось сохранить запись. Проверьте локацию, заявку и оборудование.'
|
||||
else:
|
||||
if ticket_id:
|
||||
worklog_message = 'Работа привязана к заявке и сохранена.'
|
||||
else:
|
||||
worklog_message = 'Запись о выполненной работе сохранена.'
|
||||
|
||||
context = _it_manager_context(request)
|
||||
context['it_active_tab'] = 'worklog'
|
||||
if worklog_message:
|
||||
context['it_worklog_message'] = worklog_message
|
||||
if worklog_error:
|
||||
context['it_worklog_error'] = worklog_error
|
||||
return render_page(request, 'it-manager.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def worklog_update_view(request, pk):
|
||||
"""Inline-редактирование записи журнала — только руководителю отдела."""
|
||||
if not _is_it_staff(request.user):
|
||||
return HttpResponseForbidden()
|
||||
if not user_can_view_it_monthly_report(request.user):
|
||||
return HttpResponseForbidden()
|
||||
if request.method != 'POST':
|
||||
return HttpResponseForbidden()
|
||||
|
||||
log = get_object_or_404(WorkLog, 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)
|
||||
|
||||
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()
|
||||
except ValueError:
|
||||
log_date = log.date
|
||||
try:
|
||||
hours_raw = (request.POST.get('hours_spent') or '').strip()
|
||||
hours_spent = float(hours_raw) if hours_raw else None
|
||||
if hours_spent == 0:
|
||||
hours_spent = None
|
||||
except ValueError:
|
||||
hours_spent = log.hours_spent
|
||||
|
||||
technician_raw = (request.POST.get('technician_id') or '').strip()
|
||||
technician_id = int(technician_raw) if technician_raw.isdigit() else log.technician_id
|
||||
if not Employee.objects.filter(pk=technician_id, is_active=True).exists():
|
||||
technician_id = log.technician_id
|
||||
|
||||
location_raw = (request.POST.get('location_id') or '').strip()
|
||||
location_id = int(location_raw) if location_raw.isdigit() else None
|
||||
|
||||
log.date = log_date
|
||||
log.technician_id = technician_id
|
||||
log.work_type_id = work_type_id
|
||||
log.description = description
|
||||
log.result = request.POST.get('result', '').strip()
|
||||
log.hours_spent = hours_spent
|
||||
log.location_id = location_id
|
||||
try:
|
||||
log.save()
|
||||
except Exception:
|
||||
return HttpResponse(
|
||||
'<span style="color:var(--color-destructive); font-size:0.75rem;">Ошибка сохранения</span>',
|
||||
status=400,
|
||||
)
|
||||
return HttpResponse(
|
||||
'<span style="color:hsl(152 45% 36%); font-size:0.75rem;">Сохранено</span>'
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def consumable_create_view(request):
|
||||
if not _is_it_staff(request.user):
|
||||
@@ -528,9 +884,12 @@ def contract_create_view(request):
|
||||
contract_number = request.POST.get('contract_number', '').strip()
|
||||
start_date = request.POST.get('start_date') or None
|
||||
service_type_id = _resolve_directory('contract_service_type', request.POST.get('service_type', ''))
|
||||
is_framework = bool(request.POST.get('is_framework'))
|
||||
location_id = None if is_framework else (request.POST.get('location_id') or None)
|
||||
|
||||
if provider_id and location_id and contract_number and start_date and service_type_id:
|
||||
InternetContract.objects.create(
|
||||
if provider_id and contract_number and start_date and service_type_id and (is_framework or location_id):
|
||||
parent_id = request.POST.get('parent_contract_id') or None
|
||||
contract = InternetContract(
|
||||
provider_id=provider_id,
|
||||
location_id=location_id,
|
||||
contract_number=contract_number,
|
||||
@@ -544,7 +903,14 @@ def contract_create_view(request):
|
||||
account_number=request.POST.get('account_number', '').strip(),
|
||||
static_ips=request.POST.get('static_ips', '').strip() if request.POST.get('dedicated_ip') else '',
|
||||
notes=request.POST.get('notes', '').strip(),
|
||||
is_framework=is_framework,
|
||||
parent_contract_id=parent_id if not is_framework else None,
|
||||
)
|
||||
try:
|
||||
contract.full_clean()
|
||||
contract.save()
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
context = _it_manager_context(request)
|
||||
context['it_active_tab'] = 'providers'
|
||||
@@ -561,7 +927,21 @@ def contract_update_view(request, pk):
|
||||
new_status_id = _resolve_directory('contract_status', request.POST.get('status'))
|
||||
if new_status_id:
|
||||
contract.status_id = new_status_id
|
||||
contract.save()
|
||||
parent_id = request.POST.get('parent_contract_id')
|
||||
if parent_id is not None:
|
||||
contract.parent_contract_id = parent_id or None
|
||||
if 'is_framework' in request.POST:
|
||||
contract.is_framework = bool(request.POST.get('is_framework'))
|
||||
if contract.is_framework:
|
||||
contract.parent_contract_id = None
|
||||
contract.location_id = None
|
||||
elif contract.is_framework:
|
||||
contract.location_id = None
|
||||
try:
|
||||
contract.full_clean()
|
||||
contract.save()
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
context = _it_manager_context(request)
|
||||
context['it_active_tab'] = 'providers'
|
||||
|
||||
Reference in New Issue
Block a user