Files
mariomitte bf8e25efe7
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
feat: dodaj display_code polje za putne naloge (MT070626 format)
- Dodano polje 'display_code' na WorkOrder model (format: 2 inicijala + ddmmyy)
- Migracije za kreiranje polja i backfill postojećih naloga
- Ažuriran WorkOrderSerializer za validaciju i serijalizaciju display_code
- Backend tasks za auto-generiranje display_code pri kreiranju
- Frontend komponente ažurirane za prikaz display_code umjesto UUID:
  * FleetDashboardShell tablica putnih naloga
  * WorkOrderDetailModal
  * TaskCreateModal
  * Sve PDF stranice
- displayIds.js utility za formatiranjeDisplay ID-eva
- Admin interface prikazuje display_code
2026-07-17 23:53:47 +02:00

422 lines
18 KiB
Python

from django.db import transaction
from django.conf import settings
from django.contrib.auth import get_user_model
from rest_framework.exceptions import ValidationError
from modules.fleet.tasks import send_email_task
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleNotification, VehicleServicePhoto
from infrastructure.pusher_service import PusherService
import logging
logger = logging.getLogger(__name__)
User = get_user_model()
class NotificationService:
@staticmethod
def create_notification(recipient=None, title="", message="", level="info", send_email=False, metadata=None):
# 1. Kreiraj notifikaciju u DB-u
notif = VehicleNotification.objects.create(
recipient=recipient,
title=title[:255],
message=message,
metadata=metadata or {},
level=level,
is_sent=False
)
# 2. Broadcast preko WebSocket (Pusher) tek nakon uspješnog commit-a
if recipient:
transaction.on_commit(lambda: PusherService.broadcast_notification(
user_id=recipient.id,
title=title,
message=message,
level=level,
notification_id=notif.id,
metadata=metadata
))
# 3. Slanje emaila ako je traženo
if send_email and recipient and getattr(recipient, 'email', None):
email_args = {
'recipient_email': recipient.email,
'subject': title,
'message': message,
'notif_id': notif.id
}
if settings.DEBUG or getattr(settings, 'CELERY_TASK_ALWAYS_EAGER', False):
send_email_task(**email_args)
else:
transaction.on_commit(lambda: send_email_task.delay(**email_args))
return notif
class VehicleService:
@staticmethod
def create_vehicle(data: dict) -> Vehicle:
vehicle = Vehicle(**data)
try:
vehicle.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
vehicle.save()
return vehicle
@staticmethod
def update_vehicle(instance: Vehicle, data: dict) -> Vehicle:
for k, v in data.items():
setattr(instance, k, v)
try:
instance.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
instance.save()
return instance
@staticmethod
def create_work_order(data: dict) -> WorkOrder:
# expecting creator in data
wo = WorkOrder(**data)
try:
wo.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
with transaction.atomic():
wo.save()
# obavijesti creatora i serviser-a vozila
try:
# Notify the creator
NotificationService.create_notification(
recipient=wo.creator,
title=f"Putni nalog kreiran: WO-{wo.pk}",
message=f"Putni nalog za dizalicu {wo.vehicle.registration_number} je kreiran.",
metadata={
"entity_type": "work_order",
"work_order_id": str(wo.pk),
"vehicle_id": str(wo.vehicle_id),
"crane_id": str(wo.vehicle_id),
"section": "work-orders",
},
level="info",
send_email=False
)
# Notify assigned servicer if present
servicer = wo.vehicle.assigned_servicer
if servicer:
NotificationService.create_notification(
recipient=servicer,
title=f"Novi putni nalog za {wo.vehicle.registration_number}",
message=f"Imate novi putni nalog (WO-{wo.pk}) za dizalicu {wo.vehicle.registration_number}.",
metadata={
"entity_type": "work_order",
"work_order_id": str(wo.pk),
"vehicle_id": str(wo.vehicle_id),
"crane_id": str(wo.vehicle_id),
"section": "work-orders",
},
level="info",
send_email=False
)
except Exception:
logger.exception("Greška pri slanju notifikacija za create_work_order")
return wo
@staticmethod
def update_work_order(instance: WorkOrder, data: dict) -> WorkOrder:
previous_status = instance.status
for k, v in data.items():
setattr(instance, k, v)
try:
instance.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
with transaction.atomic():
instance.save()
transitioned_to_closed = previous_status != 'closed' and instance.status == 'closed'
if transitioned_to_closed:
from modules.task_management.models import Task
Task.objects.filter(
is_active=True,
work_order=instance,
status__in=('aktivan', 'servis'),
).update(status='spreman_za_zavrsetak')
# Ako je nalog upravo zatvoren, obavijestimo kreatora/servisera
if transitioned_to_closed:
try:
NotificationService.create_notification(
recipient=instance.creator,
title=f"Putni nalog zatvoren: WO-{instance.pk}",
message=f"Putni nalog za dizalicu {instance.vehicle.registration_number} je zatvoren.",
metadata={
"entity_type": "work_order",
"work_order_id": str(instance.pk),
"vehicle_id": str(instance.vehicle_id),
"crane_id": str(instance.vehicle_id),
"section": "work-orders",
},
level="info",
send_email=False
)
servicer = instance.vehicle.assigned_servicer
if servicer:
NotificationService.create_notification(
recipient=servicer,
title=f"Putni nalog zatvoren za {instance.vehicle.registration_number}",
message=f"Putni nalog (WO-{instance.pk}) je zatvoren.",
metadata={
"entity_type": "work_order",
"work_order_id": str(instance.pk),
"vehicle_id": str(instance.vehicle_id),
"crane_id": str(instance.vehicle_id),
"section": "work-orders",
},
level="info",
send_email=False
)
except Exception:
logger.exception("Greška pri slanju notifikacija za update_work_order")
return instance
@staticmethod
def delete_work_order(instance: WorkOrder, user=None) -> WorkOrder:
"""
Soft-delete putnog naloga. Samo creator ili staff može obrisati.
Vraća deaktivirani instance.
"""
# Provjera prava: samo creator ili staff
if not user:
raise ValidationError("Korisnik nije naveden za operaciju brisanja.")
if not (user.is_staff or instance.creator == user):
raise ValidationError("Nemate dozvolu za brisanje ovog putnog naloga.")
instance.is_active = False
instance.save(update_fields=['is_active'])
# Notifikacija o brisanju
try:
NotificationService.create_notification(
recipient=instance.creator,
title=f"Putni nalog obrisan: WO-{instance.pk}",
message=f"Putni nalog za dizalicu {instance.vehicle.registration_number} je obrisan od strane {user}.",
metadata={
"entity_type": "work_order",
"work_order_id": str(instance.pk),
"vehicle_id": str(instance.vehicle_id),
"crane_id": str(instance.vehicle_id),
"section": "work-orders",
"deleted": True,
},
level="warning",
send_email=False
)
servicer = instance.vehicle.assigned_servicer
if servicer:
NotificationService.create_notification(
recipient=servicer,
title=f"Putni nalog obrisan za {instance.vehicle.registration_number}",
message=f"Putni nalog (WO-{instance.pk}) je obrisan.",
metadata={
"entity_type": "work_order",
"work_order_id": str(instance.pk),
"vehicle_id": str(instance.vehicle_id),
"crane_id": str(instance.vehicle_id),
"section": "work-orders",
"deleted": True,
},
level="warning",
send_email=False
)
except Exception:
logger.exception("Greška pri slanju notifikacija za delete_work_order")
logger.info(f"WorkOrder {instance.pk} soft-deleted by {user}")
return instance
@staticmethod
def create_service_record(data: dict) -> VehicleServiceRecord:
rec = VehicleServiceRecord(**data)
try:
rec.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
with transaction.atomic():
rec.save()
# update vehicle mileage if needed
v = rec.vehicle
if rec.mileage and rec.mileage > (v.current_mileage or 0):
v.current_mileage = rec.mileage
v.save()
# notify assigned servicer / creator
try:
# Notify performed_by user
NotificationService.create_notification(
recipient=rec.performed_by,
title=f"Servis zabilježen: {rec.vehicle.registration_number}",
message=(
f"Zabilježen servis na dizalici {rec.vehicle.registration_number}"
+ (f" pri kilometraži {rec.mileage}." if rec.mileage is not None else ".")
),
metadata={
"entity_type": "service_record",
"service_record_id": str(rec.pk),
"vehicle_id": str(rec.vehicle_id),
"crane_id": str(rec.vehicle_id),
"section": "service-records",
},
level="info",
send_email=False
)
# Notify vehicle assigned servicer if different
servicer = rec.vehicle.assigned_servicer
if servicer and servicer != rec.performed_by:
NotificationService.create_notification(
recipient=servicer,
title=f"Servis na vašem vozilu {rec.vehicle.registration_number}",
message=f"Servis je izvršen na dizalici {rec.vehicle.registration_number}.",
metadata={
"entity_type": "service_record",
"service_record_id": str(rec.pk),
"vehicle_id": str(rec.vehicle_id),
"crane_id": str(rec.vehicle_id),
"section": "service-records",
},
level="info",
send_email=False
)
# If next service is soon (e.g., within 1000 km), create a warning notification
next_due = rec.next_service_due_at
if next_due is None and rec.mileage is not None:
next_due = rec.mileage + rec.vehicle.service_interval_km
if next_due is not None and rec.mileage is not None and (next_due - rec.mileage) <= 1000:
if servicer:
NotificationService.create_notification(
recipient=servicer,
title=f"Uskoro servis za {rec.vehicle.registration_number}",
message=f"Sljedeći servis za dizalicu {rec.vehicle.registration_number} očekuje se na ~{next_due} km.",
metadata={
"entity_type": "service_record",
"service_record_id": str(rec.pk),
"vehicle_id": str(rec.vehicle_id),
"crane_id": str(rec.vehicle_id),
"section": "service-records",
"next_service_due_at": next_due,
},
level="warning",
send_email=False
)
except Exception:
logger.exception("Greška pri slanju notifikacija za create_service_record")
return rec
class VehicleServicePhotoService:
"""
Servisna klasa za upravljanje VehicleServicePhoto zapisima.
Delegira upload, ažuriranje i brisanje s validacijom i sigurnosnim provjerama.
"""
@staticmethod
def upload_photo(data: dict, uploaded_by=None) -> VehicleServicePhoto:
"""
Kreiraj VehicleServicePhoto s file upload-om.
data treba sadržavati: service_record (ID ili instancu), image (UploadedFile), description (opcionalno).
"""
if not uploaded_by:
raise ValidationError("Korisnik je obavezan za upload fotografije.")
# Ako je service_record ID ili UUID, dohvati instancu
service_record = data.get('service_record')
# Provjeri je li već VehicleServiceRecord instanca
if not isinstance(service_record, VehicleServiceRecord):
# Pokušaj dohvatiti po ID-u (int, str, UUID)
try:
service_record = VehicleServiceRecord.objects.get(pk=service_record)
except (VehicleServiceRecord.DoesNotExist, ValueError, TypeError):
raise ValidationError("Servisni zapis ne postoji ili ID nije valjan.")
# Kreiraj novi dict s istim podacima ali sigurno postavljenim service_record
photo_data = {
'service_record': service_record,
'image': data.get('image'),
'description': data.get('description', ''),
'uploaded_by': uploaded_by
}
photo = VehicleServicePhoto(**photo_data)
try:
photo.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
photo.save()
logger.info(f"Fotografija uploadana od strane {uploaded_by} za servisni zapis {service_record.id}")
return photo
@staticmethod
def update_photo(instance: VehicleServicePhoto, data: dict, user=None) -> VehicleServicePhoto:
"""
Ažuriraj VehicleServicePhoto. Samo uploader ili staff mogu ažurirati.
"""
# Sigurnosna provjera: samo uploader ili staff mogu ažurirati
if not (user and (user.is_staff or instance.uploaded_by == user)):
raise ValidationError("Nemate dozvolu za ažuriranje ove fotografije.")
for attr, value in data.items():
if attr != 'image': # ne dozvoli ponovno upload slike (opciono)
setattr(instance, attr, value)
try:
instance.full_clean()
except Exception as e:
raise ValidationError({"detail": str(e)})
instance.save()
logger.info(f"Fotografija {instance.id} ažurirana od strane {user}")
return instance
@staticmethod
def delete_photo(instance: VehicleServicePhoto, user=None) -> VehicleServicePhoto:
"""
Soft-delete VehicleServicePhoto. Samo uploader ili staff mogu obrisati.
"""
if not (user and (user.is_staff or instance.uploaded_by == user)):
raise ValidationError("Nemate dozvolu za brisanje ove fotografije.")
instance.is_active = False
instance.save(update_fields=['is_active'])
logger.info(f"Fotografija {instance.id} soft-deleted od strane {user}")
return instance
class FleetService:
@staticmethod
def bulk_close_work_orders(work_orders_qs):
updated_vehicles = {}
with transaction.atomic():
for wo in work_orders_qs:
# 1. Preskoči ako je već zatvoren
if wo.status == 'closed':
continue
# 2. NOVO: Preskoči ako nalog nije kompletan (nema kilometražu)
if wo.end_mileage is None:
continue
# 3. Zatvori nalog
wo.status = 'closed'
wo.save()
# 4. Ažuriraj vozilo
vehicle = wo.vehicle
current_max = updated_vehicles.get(vehicle.pk, vehicle.current_mileage)
if wo.end_mileage > current_max:
updated_vehicles[vehicle.pk] = wo.end_mileage
# Masovno ažuriranje vozila
for v_id, new_mileage in updated_vehicles.items():
Vehicle.objects.filter(pk=v_id).update(current_mileage=new_mileage)