Uveden je novi Notes drawer s kalendar integracijom, zajednički DRY left-slide mehanizam te međusobno isključivo prikazivanje s Kalendar widgetom. Backend i frontend su prošireni za servisne bilješke (uključujući slanje na više servisera), dodan je users/servicers endpoint, te su uvedene warning notifikacije kada serviser ima više od 2 zadatka isti dan. U admin su dodane stavke za Bilješke i zaseban Kalendar zadataka (proxy TaskCalendar) radi lakšeg operativnog pregleda. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
210 lines
8.8 KiB
Python
210 lines
8.8 KiB
Python
# backend/modules/task_management/services.py
|
|
|
|
from django.db import transaction, models
|
|
from rest_framework.exceptions import ValidationError
|
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
|
from django.contrib.auth import get_user_model
|
|
import logging
|
|
|
|
from .models import Task, TaskTemplate, TaskWorkEntry
|
|
from .exceptions import OpenWorkOrderBlockingTaskClose
|
|
from modules.fleet.models import VehicleServiceRecord
|
|
|
|
logger = logging.getLogger(__name__)
|
|
User = get_user_model()
|
|
|
|
|
|
def _task_overload_recipients(task):
|
|
recipients = []
|
|
if task.assigned_to_id:
|
|
recipients.append(task.assigned_to)
|
|
supervisors = User.objects.filter(is_active=True).filter(
|
|
models.Q(is_staff=True) | (models.Q(is_team_member=True) & models.Q(is_serviser=False))
|
|
)
|
|
recipients.extend(supervisors)
|
|
unique = {}
|
|
for recipient in recipients:
|
|
unique[str(recipient.id)] = recipient
|
|
return list(unique.values())
|
|
|
|
|
|
class TaskService:
|
|
@staticmethod
|
|
def create_task_entry(data: dict, user=None) -> Task:
|
|
"""
|
|
Univerzalna metoda: prihvaća podatke i opcionalnog korisnika.
|
|
"""
|
|
payload = dict(data)
|
|
template_id = payload.pop('template_id', None)
|
|
|
|
if payload.get('status') == 'DONE' and (user and not user.is_staff):
|
|
raise ValidationError({"status": "Samo admini mogu odmah označiti zadatak kao gotov."})
|
|
|
|
template = None
|
|
if template_id:
|
|
template = TaskTemplate.objects.filter(id=template_id, is_active=True).prefetch_related('entries').first()
|
|
if not template:
|
|
raise ValidationError({"template_id": "Odabrani template nije pronađen."})
|
|
|
|
payload['title'] = template.title
|
|
payload['description'] = template.description
|
|
payload['status'] = 'aktivan'
|
|
payload.setdefault('work_order', None)
|
|
|
|
title = (payload.get('title') or '').strip()
|
|
if title and len(title) < 3:
|
|
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
|
|
if not title:
|
|
raise ValidationError({"title": "Naslov zadatka je obavezan."})
|
|
|
|
if user and 'assigned_to' not in payload:
|
|
payload['assigned_to'] = user
|
|
|
|
entry = Task(**payload)
|
|
try:
|
|
entry.full_clean()
|
|
except DjangoValidationError as exc:
|
|
logger.error(f"Neuspješna validacija zadatka: {exc}")
|
|
raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
|
|
|
|
with transaction.atomic():
|
|
entry.save()
|
|
TaskService._notify_if_servicer_overloaded(entry)
|
|
if template:
|
|
TaskService._create_work_entries_from_template(task=entry, template=template)
|
|
TaskService._create_service_records_from_template(task=entry, template=template, user=user)
|
|
|
|
logger.info(f"Zadatak uspješno kreiran: {entry.id}")
|
|
return entry
|
|
|
|
@staticmethod
|
|
def _notify_if_servicer_overloaded(entry: Task):
|
|
if not entry.assigned_to_id or not entry.scheduled_date:
|
|
return
|
|
if not getattr(entry.assigned_to, 'is_serviser', False):
|
|
return
|
|
|
|
task_count = Task.objects.filter(
|
|
is_active=True,
|
|
assigned_to=entry.assigned_to,
|
|
scheduled_date=entry.scheduled_date,
|
|
status__in=('aktivan', 'servis', 'spreman_za_zavrsetak'),
|
|
).count()
|
|
if task_count <= 2:
|
|
return
|
|
|
|
from modules.fleet.services import NotificationService
|
|
|
|
schedule_label = entry.scheduled_date.strftime('%d.%m.%Y')
|
|
title = f"Preopterećenje rasporeda: {entry.assigned_to.get_full_name() or entry.assigned_to.email}"
|
|
message = (
|
|
f"Serviseru {entry.assigned_to.get_full_name() or entry.assigned_to.email} "
|
|
f"je dodijeljeno {task_count} zadatka za {schedule_label}. "
|
|
f"Sustav je dopustio i dodatni zadatak."
|
|
)
|
|
metadata = {
|
|
"entity_type": "task_overload",
|
|
"assigned_to_id": str(entry.assigned_to_id),
|
|
"task_id": str(entry.id),
|
|
"scheduled_date": entry.scheduled_date.isoformat(),
|
|
"task_count": task_count,
|
|
"section": "dashboard",
|
|
}
|
|
for recipient in _task_overload_recipients(entry):
|
|
NotificationService.create_notification(
|
|
recipient=recipient,
|
|
title=title,
|
|
message=message,
|
|
level='warning',
|
|
send_email=False,
|
|
metadata=metadata,
|
|
)
|
|
|
|
@staticmethod
|
|
def _create_work_entries_from_template(*, task: Task, template: TaskTemplate):
|
|
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
|
|
for idx, source in enumerate(template_entries, start=1):
|
|
TaskWorkEntry.objects.create(
|
|
task=task,
|
|
template_entry=source,
|
|
sort_order=idx,
|
|
title=source.title,
|
|
work_description=source.work_description,
|
|
active_faults=source.active_faults,
|
|
explanation_hr=source.explanation_hr,
|
|
)
|
|
|
|
@staticmethod
|
|
def _create_service_records_from_template(*, task: Task, template: TaskTemplate, user):
|
|
if not user:
|
|
raise ValidationError({
|
|
"template_id": "Kreiranje taska iz templatea zahtijeva prijavljenog korisnika za servisne zapise."
|
|
})
|
|
if not task.vehicle_id:
|
|
raise ValidationError({
|
|
"vehicle": "Task iz templatea mora imati dodijeljenu dizalicu za generiranje servisnih zapisa."
|
|
})
|
|
|
|
template_entries = list(template.entries.filter(is_active=True).order_by('sort_order', 'created_at')[:10])
|
|
for source in template_entries:
|
|
description_parts = []
|
|
if source.work_description:
|
|
description_parts.append(source.work_description.strip())
|
|
if source.explanation_hr:
|
|
description_parts.append(f"Obrazloženje (HR): {source.explanation_hr.strip()}")
|
|
if source.active_faults:
|
|
description_parts.append(f"Aktivne greške: {source.active_faults.strip()}")
|
|
description = "\n\n".join([part for part in description_parts if part]).strip() or source.title
|
|
|
|
VehicleServiceRecord.objects.create(
|
|
vehicle=task.vehicle,
|
|
task=task,
|
|
performed_by=user,
|
|
service_title=source.title,
|
|
description=description,
|
|
parts=(source.active_faults or '').strip(),
|
|
cost=0,
|
|
)
|
|
|
|
@staticmethod
|
|
def update_task_entry(instance, data, user, auto_close_work_order=False):
|
|
if not user.is_staff and instance.assigned_to != user:
|
|
raise ValidationError("Nemate dozvolu za izmjenu ovog zadatka.")
|
|
|
|
target_status = data.get('status', instance.status)
|
|
target_work_order = data.get('work_order', instance.work_order)
|
|
closing_statuses = {'neaktivan', 'zavrsen'}
|
|
|
|
if target_status in closing_statuses:
|
|
if not target_work_order:
|
|
raise ValidationError({"work_order": "Putni nalog je obavezan prije zatvaranja zadatka."})
|
|
if target_work_order.status == 'open':
|
|
if not auto_close_work_order:
|
|
work_order_label = f"WO-{str(target_work_order.id).split('-')[0].upper()}"
|
|
raise OpenWorkOrderBlockingTaskClose(detail={
|
|
"code": "OPEN_WORK_ORDER_BLOCKING_TASK_CLOSE",
|
|
"message": f"Putni nalog {work_order_label} je još otvoren. Želite li ga automatski zatvoriti?",
|
|
"work_order_id": str(target_work_order.id),
|
|
"work_order_label": work_order_label,
|
|
})
|
|
if target_work_order.end_mileage is None:
|
|
raise ValidationError({
|
|
"work_order": "Automatsko zatvaranje putnog naloga nije moguće bez završne kilometraže."
|
|
})
|
|
from modules.fleet.services import VehicleService
|
|
target_work_order = VehicleService.update_work_order(target_work_order, {"status": "closed"})
|
|
data['work_order'] = target_work_order
|
|
|
|
for attr, value in data.items():
|
|
if attr == 'template_id':
|
|
continue
|
|
setattr(instance, attr, value)
|
|
if 'title' in data and len(str(data.get('title') or '').strip()) < 3:
|
|
raise ValidationError({"title": "Naslov zadatka mora imati barem 3 znaka."})
|
|
try:
|
|
instance.full_clean()
|
|
except DjangoValidationError as exc:
|
|
raise ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
|
|
instance.save()
|
|
return instance
|