Files
004.erp.generic/backend/modules/fleet/models.py
mariomitte 2826ee9bab fix: invalidate stale service records PDF cache on service record changes
Ensure admin/API deletes and updates of service records, photos, and
attachments invalidate the cached service-records PDF. Also harden cached
PDF lookup/download paths so a missing file is treated as stale cache
instead of a 500.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 07:32:05 +02:00

983 lines
37 KiB
Python

import uuid
import re
from datetime import time
from django.db import models
from django.db.models.signals import post_save, pre_delete
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
from django.dispatch import receiver
from core.base_models import BaseModel
from decimal import Decimal
def _service_photo_upload_to(instance, filename):
"""
Putanja za upload:
fleet/{registration_number}/service_photos/{uuid4}_{original_filename}
Koristimo uuid jer instance.service_record.pk možda nije dostupna prije prvog save-a.
"""
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.service_record.vehicle.registration_number
except Exception:
# fallback
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/service_photos/{unique_name}"
def _service_attachment_upload_to(instance, filename):
"""
Putanja za upload:
fleet/{registration_number}/service_attachments/{uuid4}_{original_filename}
"""
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.service_record.vehicle.registration_number
except Exception:
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/service_attachments/{unique_name}"
def _work_order_invoice_upload_to(instance, filename):
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.work_order.vehicle.registration_number
except Exception:
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/work_order_invoices/{unique_name}"
def _work_order_photo_upload_to(instance, filename):
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.work_order.vehicle.registration_number
except Exception:
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/work_order_photos/{unique_name}"
def _generated_work_order_pdf_upload_to(instance, filename):
vehicle_reg = 'unknown'
try:
vehicle_reg = instance.work_order.vehicle.registration_number
except Exception:
vehicle_reg = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/{vehicle_reg}/generated_pdfs/{unique_name}"
def _generated_fleet_archive_upload_to(instance, filename):
requester = 'unknown'
try:
requester = str(instance.requested_by_id or 'unknown')
except Exception:
requester = 'unknown'
unique_name = f"{uuid.uuid4().hex}_{filename}"
return f"fleet/generated_archives/{requester}/{unique_name}"
class VehicleServicePhoto(BaseModel):
"""
Fotografije kvarova / radova povezane s VehicleServiceRecord.
Pohranjuju se slike i opcionalni opis te tko ih je upload-ao.
"""
service_record = models.ForeignKey(
'fleet.VehicleServiceRecord',
on_delete=models.CASCADE,
related_name='photos',
verbose_name=_("Servisni zapis")
)
image = models.ImageField(
upload_to=_service_photo_upload_to,
verbose_name=_("Fotografija")
)
description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis"))
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='uploaded_service_photos',
verbose_name=_("Postavio")
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
class Meta:
verbose_name = _("Fotografija servisa")
verbose_name_plural = _("Fotografije servisa")
ordering = ['-created_at']
def __str__(self):
return f"Photo {self.pk} for {self.service_record} ({self.image.name if self.image else 'no-image'})"
class VehicleServiceAttachment(BaseModel):
"""
Datoteke (PDF, DOC, XLS...) povezane sa servisnim zapisom.
"""
service_record = models.ForeignKey(
'fleet.VehicleServiceRecord',
on_delete=models.CASCADE,
related_name='attachments',
verbose_name=_("Servisni zapis")
)
file = models.FileField(
upload_to=_service_attachment_upload_to,
verbose_name=_("Datoteka")
)
description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis"))
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='uploaded_service_attachments',
verbose_name=_("Postavio")
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
class Meta:
verbose_name = _("Prilog servisa")
verbose_name_plural = _("Prilozi servisa")
ordering = ['-created_at']
def __str__(self):
return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
def _service_record_work_order_id(instance):
if isinstance(instance, VehicleServiceRecord):
task = getattr(instance, 'task', None)
if task and getattr(task, 'work_order_id', None):
return task.work_order_id
if getattr(instance, 'task_id', None):
return VehicleServiceRecord.objects.filter(pk=instance.pk).values_list('task__work_order_id', flat=True).first()
return None
service_record = getattr(instance, 'service_record', None)
if service_record and getattr(service_record, 'task', None) and getattr(service_record.task, 'work_order_id', None):
return service_record.task.work_order_id
service_record_id = getattr(instance, 'service_record_id', None)
if not service_record_id:
return None
return VehicleServiceRecord.objects.filter(pk=service_record_id).values_list('task__work_order_id', flat=True).first()
def _invalidate_service_records_pdf_cache(work_order_id):
if not work_order_id:
return
cached_pdfs = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order_id=work_order_id,
pdf_type='service_records',
)
for cached in cached_pdfs:
file_name = getattr(cached.file, 'name', '')
if file_name:
try:
if cached.file.storage.exists(file_name):
cached.file.delete(save=False)
except (FileNotFoundError, OSError, ValueError):
pass
cached.file = None
cached.is_active = False
cached.status = 'failed'
cached.error_message = 'PDF cache invalidiran zbog promjene servisnih zapisa.'
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
@receiver(post_save, sender='fleet.VehicleServiceRecord')
@receiver(pre_delete, sender='fleet.VehicleServiceRecord')
def _invalidate_service_records_pdf_for_service_record(sender, instance, **kwargs):
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
@receiver(post_save, sender='fleet.VehicleServicePhoto')
@receiver(pre_delete, sender='fleet.VehicleServicePhoto')
def _invalidate_service_records_pdf_for_service_photo(sender, instance, **kwargs):
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
@receiver(post_save, sender='fleet.VehicleServiceAttachment')
@receiver(pre_delete, sender='fleet.VehicleServiceAttachment')
def _invalidate_service_records_pdf_for_service_attachment(sender, instance, **kwargs):
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
class Vehicle(BaseModel):
ASSET_TYPE_CHOICES = [
('vehicle', _("Vozilo")),
('crane', _("Dizalica")),
]
asset_type = models.CharField(
max_length=20,
choices=ASSET_TYPE_CHOICES,
default='crane',
db_index=True,
verbose_name=_("Tip sredstva"),
)
registration_number = models.CharField(max_length=20, unique=True, verbose_name=_("Registracija"))
make = models.CharField(max_length=100, blank=True, verbose_name=_("Proizvođač"))
model = models.CharField(max_length=100, blank=True, verbose_name=_("Model"))
year = models.PositiveSmallIntegerField(null=True, blank=True, verbose_name=_("Godina"))
vin = models.CharField(max_length=50, blank=True, null=True, unique=True, verbose_name=_("VIN"))
crane_serial_number = models.CharField(
max_length=6,
null=True,
blank=True,
db_index=True,
verbose_name=_("Serijski broj dizalice"),
help_text=_("Serijski broj dizalice mora imati točno 6 znamenki."),
)
superstructure_working_hours = models.PositiveIntegerField(
default=0,
verbose_name=_("Radni sati nadogradnje"),
)
chassis_working_hours = models.PositiveIntegerField(
default=0,
verbose_name=_("Radni sati podvozja"),
)
current_mileage = models.PositiveIntegerField(default=0, verbose_name=_("Trenutna kilometraža (km)"))
service_interval_km = models.PositiveIntegerField(default=15000, verbose_name=_("Interval servisa (km)"))
assigned_servicer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name='assigned_vehicles',
null=True,
blank=True,
verbose_name=_("Serviser")
)
# Označava je li vozilo u vlasništvu naše tvrtke (fleet) ili kupca
is_company_vehicle = models.BooleanField(
default=True,
verbose_name=_("Vozilo tvrtke"),
help_text=_("Označite ako vozilo pripada našoj tvrtki. Poništite za vozila kupaca.")
)
# Kupac kojemu pripada vozilo (samo za vozila kupaca)
client = models.ForeignKey(
'crm.Client',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='vehicles',
verbose_name=_("Kupac"),
help_text=_("Popunite samo za vozila kupaca (is_company_vehicle = Ne).")
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivno"))
class Meta:
verbose_name = _("Vozilo")
verbose_name_plural = _("Vozila")
ordering = ['registration_number']
def __str__(self):
return f"{self.registration_number} - {self.make} {self.model}"
def clean(self):
super().clean()
if self.asset_type == 'crane':
if not self.crane_serial_number and self._state.adding:
raise ValidationError({'crane_serial_number': _("Serijski broj dizalice je obavezan za dizalice.")})
if self.crane_serial_number and not re.fullmatch(r"[0-9]{6}", str(self.crane_serial_number or '')):
raise ValidationError({'crane_serial_number': _("Serijski broj dizalice mora sadržavati točno 6 znamenki.")})
elif self.crane_serial_number:
if not re.fullmatch(r"[0-9]{6}", str(self.crane_serial_number or '')):
raise ValidationError({'crane_serial_number': _("Serijski broj dizalice mora sadržavati točno 6 znamenki.")})
def next_service_at(self) -> int:
"""
Vrati kilometražu pri kojoj sljedeći servis pada, bazirano na trenutnoj kilometraži i intervalu.
Npr. current=12000, interval=15000 -> next_service_at = 15000
Npr. current=32000, interval=15000 -> next_service_at = 45000
"""
if self.service_interval_km <= 0:
return 0
blocks = (self.current_mileage // self.service_interval_km) + 1
return blocks * self.service_interval_km
class Crane(Vehicle):
class Meta:
proxy = True
verbose_name = _("Dizalica")
verbose_name_plural = _("Dizalice")
ordering = ['registration_number']
def __str__(self):
details = " ".join(part for part in [self.make, self.model] if part)
if details:
return f"{self.registration_number} - {details}"
return self.registration_number
class WorkOrder(BaseModel):
"""
Putni nalog / radni nalog koji prati kilometražu vozila tijekom zadatka na terenu.
Kad se postavi end_mileage, model ažurira vehicle.current_mileage ako je end_mileage veći.
"""
STATUS_CHOICES = [
('open', 'Otvoren'),
('closed', 'Zatvoren'),
]
PURPOSE_CHOICES = [
('defektaza', _("Defektaža")),
('kontrola', _("Kontrola")),
('redovni_pregled', _("Redovni pregled")),
('popravak', _("Popravak")),
]
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='work_orders', verbose_name=_("Vozilo"))
creator = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='created_work_orders', verbose_name=_("Kreirao"))
date = models.DateField(auto_now_add=True, verbose_name=_("Datum"))
start_mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Početna kilometraža"))
end_mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Završna kilometraža"))
servicer_vehicle_registration = models.CharField(
max_length=20,
blank=True,
verbose_name=_("Vozilo servisera - registracija"),
)
servicer_vehicle_make_model = models.CharField(
max_length=120,
blank=True,
verbose_name=_("Vozilo servisera - marka/model"),
)
servicer_vehicle_start_mileage = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name=_("Vozilo servisera - početna kilometraža"),
)
servicer_vehicle_end_mileage = models.PositiveIntegerField(
null=True,
blank=True,
verbose_name=_("Vozilo servisera - završna kilometraža"),
)
servicer_vehicle_fuel_cost = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name=_("Vozilo servisera - trošak goriva (EUR)"),
)
origin_location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija ishodišta"))
location = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija odredišta"))
travel_start_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Početak puta"))
travel_end_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Kraj puta"))
display_code = models.CharField(
max_length=8,
blank=True,
db_index=True,
verbose_name=_("Prikaz putnog naloga"),
help_text=_("Format: MT070626 (2 slova + 6 znamenki)."),
)
purpose = models.CharField(max_length=50, choices=PURPOSE_CHOICES, blank=True, verbose_name=_("Svrha"))
notes = models.TextField(blank=True, verbose_name=_("Bilješke"))
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open', verbose_name=_("Status"))
class Meta:
verbose_name = _("Putni nalog")
verbose_name_plural = _("Putni nalozi")
ordering = ['-created_at']
def __str__(self):
return f"WO-{self.pk} {self.vehicle.registration_number} ({self.status})"
@property
def distance(self):
if self.end_mileage is None or self.start_mileage is None:
return None
return max(0, self.end_mileage - self.start_mileage)
def save(self, *args, **kwargs):
self.full_clean() # Provjera validnosti prije svega
updating_vehicle = False
if self.end_mileage is not None:
# Ako je end_mileage veći od trenutne vehicle.current_mileage, ažuriraj vozilo.
if self.end_mileage > (self.vehicle.current_mileage or 0):
self.vehicle.current_mileage = self.end_mileage
updating_vehicle = True
super().save(*args, **kwargs)
if updating_vehicle:
# Spremi vozilo (nakon što nalog ima PK)
self.vehicle.save()
def clean(self):
super().clean()
if self.display_code:
self.display_code = str(self.display_code).strip().upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", self.display_code):
raise ValidationError({
"display_code": _("Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).")
})
if self.travel_start_at and self.travel_end_at and self.travel_end_at < self.travel_start_at:
raise ValidationError({"travel_end_at": _("Kraj puta ne može biti prije početka puta.")})
class WorkOrderInvoice(BaseModel):
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.CASCADE,
related_name='invoices',
verbose_name=_("Putni nalog"),
)
naziv_racuna = models.CharField(max_length=255, verbose_name=_("Naziv računa"))
lokacija = models.CharField(max_length=255, blank=True, verbose_name=_("Lokacija"))
datum = models.DateField(default=timezone.now, verbose_name=_("Datum"))
opis = models.TextField(blank=True, verbose_name=_("Opis"))
image = models.FileField(
upload_to=_work_order_invoice_upload_to,
null=True,
blank=True,
verbose_name=_("Datoteka računa"),
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='created_work_order_invoices',
verbose_name=_("Kreirao"),
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
class Meta:
verbose_name = _("Račun putnog naloga")
verbose_name_plural = _("Računi putnog naloga")
ordering = ['-datum', '-created_at']
def __str__(self):
return f"{self.naziv_racuna} ({self.work_order_id})"
class WorkOrderAdditionalCostsTable(BaseModel):
work_order = models.OneToOneField(
WorkOrder,
on_delete=models.CASCADE,
related_name='additional_costs_table',
verbose_name=_("Putni nalog"),
)
data = models.JSONField(
null=True,
blank=True,
default=None,
verbose_name=_("Podaci tablice dodatnih troškova"),
)
total_for_payout = models.DecimalField(
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Ukupno za isplatu"),
)
class Meta:
ordering = ['-updated_at']
verbose_name = _("Tablica dodatnih troškova putnog naloga")
verbose_name_plural = _("Tablice dodatnih troškova putnog naloga")
def __str__(self):
return f"Additional costs table for work order {self.work_order_id}"
class WorkOrderTravelExpensesTable(BaseModel):
RATE_CHOICES = [
('HR', _("Hrvatska")),
('BIH', _("BiH")),
('SI', _("Slovenija")),
('CG', _("Crna Gora")),
]
DEFAULT_RATE_COUNTRY = 'HR'
DEFAULT_RATE_AMOUNT = Decimal('30.00')
work_order = models.OneToOneField(
WorkOrder,
on_delete=models.CASCADE,
related_name='travel_expenses_table',
verbose_name=_("Putni nalog"),
)
broj_sati = models.DecimalField(
max_digits=10,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Broj sati"),
)
kolicina_dnevnica = models.DecimalField(
max_digits=10,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Količina dnevnica"),
)
iznos_dnevnica = models.DecimalField(
max_digits=10,
decimal_places=2,
default=DEFAULT_RATE_AMOUNT,
verbose_name=_("Iznos dnevnice"),
)
daily_rate_country = models.CharField(
max_length=8,
choices=RATE_CHOICES,
default=DEFAULT_RATE_COUNTRY,
verbose_name=_("Država dnevnice"),
)
total_for_payout = models.DecimalField(
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Ukupan iznos"),
)
class Meta:
ordering = ['-updated_at']
verbose_name = _("Tablica obračuna putnih troškova")
verbose_name_plural = _("Tablice obračuna putnih troškova")
def __str__(self):
return f"Travel expenses table for work order {self.work_order_id}"
class WorkOrderPhoto(BaseModel):
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.CASCADE,
related_name='photos',
verbose_name=_("Putni nalog"),
)
image = models.ImageField(
upload_to=_work_order_photo_upload_to,
verbose_name=_("Fotografija putnog naloga"),
)
description = models.CharField(max_length=255, blank=True, verbose_name=_("Opis"))
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='uploaded_work_order_photos',
verbose_name=_("Postavio"),
)
is_active = models.BooleanField(default=True, verbose_name=_("Aktivan"))
class Meta:
verbose_name = _("Fotografija putnog naloga")
verbose_name_plural = _("Fotografije putnog naloga")
ordering = ['-created_at']
def __str__(self):
return f"WorkOrderPhoto {self.pk} ({self.work_order_id})"
class GeneratedWorkOrderPdf(BaseModel):
PDF_TYPE_CHOICES = [
('work_order', _("Putni nalog")),
('service_records', _("Servisni zapisi")),
('invoices', _("Računi")),
]
STATUS_CHOICES = [
('pending', _("U obradi")),
('ready', _("Spremno")),
('failed', _("Neuspješno")),
]
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.CASCADE,
related_name='generated_pdfs',
verbose_name=_("Putni nalog"),
)
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='requested_generated_work_order_pdfs',
verbose_name=_("Zatražio"),
)
pdf_type = models.CharField(max_length=32, choices=PDF_TYPE_CHOICES, db_index=True, verbose_name=_("Tip PDF-a"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='pending', db_index=True, verbose_name=_("Status"))
file = models.FileField(upload_to=_generated_work_order_pdf_upload_to, null=True, blank=True, verbose_name=_("PDF datoteka"))
filename = models.CharField(max_length=255, blank=True, verbose_name=_("Naziv datoteke"))
expires_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name=_("Vrijedi do"))
error_message = models.TextField(blank=True, verbose_name=_("Poruka greške"))
class Meta:
verbose_name = _("Generirani PDF putnog naloga")
verbose_name_plural = _("Generirani PDF-ovi putnih naloga")
ordering = ['-created_at']
def __str__(self):
return f"{self.pdf_type}:{self.work_order_id}:{self.status}"
class GeneratedFleetArchive(BaseModel):
ARCHIVE_TYPE_CHOICES = [
('service_tasks', _("Pojedinačni servisni taskovi")),
('work_orders', _("Putni nalozi i računi")),
]
STATUS_CHOICES = [
('pending', _("U obradi")),
('ready', _("Spremno")),
('failed', _("Neuspješno")),
]
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='requested_generated_fleet_archives',
verbose_name=_("Zatražio"),
)
archive_type = models.CharField(
max_length=32,
choices=ARCHIVE_TYPE_CHOICES,
db_index=True,
verbose_name=_("Tip ZIP arhive"),
)
year = models.PositiveSmallIntegerField(db_index=True, verbose_name=_("Godina"))
month = models.PositiveSmallIntegerField(db_index=True, verbose_name=_("Mjesec"))
status = models.CharField(
max_length=16,
choices=STATUS_CHOICES,
default='pending',
db_index=True,
verbose_name=_("Status"),
)
file = models.FileField(
upload_to=_generated_fleet_archive_upload_to,
null=True,
blank=True,
verbose_name=_("ZIP datoteka"),
)
filename = models.CharField(max_length=255, blank=True, verbose_name=_("Naziv datoteke"))
expires_at = models.DateTimeField(null=True, blank=True, db_index=True, verbose_name=_("Vrijedi do"))
error_message = models.TextField(blank=True, verbose_name=_("Poruka greške"))
class Meta:
verbose_name = _("Generirana ZIP arhiva")
verbose_name_plural = _("Generirane ZIP arhive")
ordering = ['-created_at']
def __str__(self):
return f"{self.archive_type}:{self.year}-{self.month:02d}:{self.status}"
class VehicleServiceRecord(BaseModel):
"""
Zapisi o servisima/popravcima na vozilu.
"""
vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT, related_name='service_records', verbose_name=_("Vozilo"))
task = models.ForeignKey(
'task_management.Task',
on_delete=models.SET_NULL,
related_name='service_records',
null=True,
blank=True,
verbose_name=_("Radni zadatak"),
)
performed_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='performed_vehicle_services', verbose_name=_("Izvršio"))
service_title = models.CharField(max_length=255, blank=True, default='', verbose_name=_("Naziv servisnog zapisa"))
description = models.TextField(verbose_name=_("Opis posla"))
parts = models.TextField(blank=True, verbose_name=_("Korišteni dijelovi"))
cost = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'), verbose_name=_("Trošak"))
service_date = models.DateField(auto_now_add=True, verbose_name=_("Datum servisa"))
mileage = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Kilometraža pri servisu"))
next_service_due_at = models.PositiveIntegerField(null=True, blank=True, verbose_name=_("Kilometraža sljedećeg servisa"))
class Meta:
verbose_name = _("Servisni zapis")
verbose_name_plural = _("Servisni zapisi")
ordering = ['-service_date']
def __str__(self):
mileage_display = self.mileage if self.mileage is not None else "-"
title = (self.service_title or '').strip()
if title:
return f"{title} ({self.vehicle.registration_number} @ {mileage_display})"
return f"Service {self.vehicle.registration_number} @ {mileage_display}"
def clean(self):
super().clean()
if self.task_id and self.vehicle_id:
task = self.task
if task.vehicle_id and task.vehicle_id != self.vehicle_id:
raise ValidationError({'task': _("Radni zadatak mora biti povezan s istom dizalicom kao servisni zapis.")})
def save(self, *args, **kwargs):
self.full_clean() # Provjera validnosti prije svega
# Ako nije zadan next_service_due_at, postavi prema vehicle.service_interval_km
if self.next_service_due_at is None and self.mileage is not None and self.vehicle and self.vehicle.service_interval_km:
self.next_service_due_at = self.mileage + self.vehicle.service_interval_km
# VAŽNO: ažuriraj vehicle.current_mileage PRIJE save-a kako bi refresh-om nakon toga korisnik vidio svježe podatke
if self.mileage is not None and self.mileage > (self.vehicle.current_mileage or 0):
self.vehicle.current_mileage = self.mileage
self.vehicle.save(update_fields=['current_mileage'])
super().save(*args, **kwargs)
class VehicleNotification(BaseModel):
"""
Jednostavni zapis notifikacije vezane uz fleet objekte.
Može se koristiti za in-app obavijesti ili kao log koji je poslan.
"""
LEVEL_CHOICES = [
('info', 'Info'),
('warning', 'Upozorenje'),
('critical', 'Kritično'),
]
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='vehicle_notifications',
verbose_name=_("Primatelj")
)
title = models.CharField(max_length=255, verbose_name=_("Naslov"))
message = models.TextField(verbose_name=_("Poruka"))
metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci"))
level = models.CharField(max_length=20, choices=LEVEL_CHOICES, default='info', verbose_name=_("Razina"))
is_sent = models.BooleanField(default=False, verbose_name=_("Poslano (e-mail)"))
is_read = models.BooleanField(default=False, verbose_name=_("Pročitano"))
class Meta:
verbose_name = _("Notifikacija za vozilo")
verbose_name_plural = _("Notifikacije za vozila")
ordering = ['-created_at']
def __str__(self):
return f"{self.title} -> {self.recipient} ({self.level})"
class EmailDispatchLog(BaseModel):
DISPATCH_TYPE_CHOICES = [
('work_order', _("Putni nalog")),
('service_record', _("Servisni zapis")),
]
STATUS_CHOICES = [
('queued', _("U obradi")),
('sent', _("Poslano")),
('failed', _("Neuspješno")),
]
dispatch_type = models.CharField(max_length=32, choices=DISPATCH_TYPE_CHOICES, db_index=True, verbose_name=_("Tip slanja"))
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='requested_email_dispatch_logs',
verbose_name=_("Zatražio"),
)
work_order = models.ForeignKey(
'fleet.WorkOrder',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='email_dispatch_logs',
verbose_name=_("Putni nalog"),
)
service_record = models.ForeignKey(
'fleet.VehicleServiceRecord',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='email_dispatch_logs',
verbose_name=_("Servisni zapis"),
)
recipients = models.JSONField(default=list, blank=True, verbose_name=_("Primatelji"))
subject = models.CharField(max_length=255, blank=True, verbose_name=_("Naslov"))
message = models.TextField(blank=True, verbose_name=_("Poruka"))
attachments = models.JSONField(default=list, blank=True, verbose_name=_("Privitci"))
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default='queued', db_index=True, verbose_name=_("Status"))
error_message = models.TextField(blank=True, verbose_name=_("Greška"))
metadata = models.JSONField(default=dict, blank=True, verbose_name=_("Metapodaci"))
sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Poslano u"))
class Meta:
verbose_name = _("Log slanja emaila")
verbose_name_plural = _("Logovi slanja emaila")
ordering = ['-created_at']
def __str__(self):
return f"{self.dispatch_type} -> {len(self.recipients or [])} ({self.status})"
def clean(self):
super().clean()
if self.dispatch_type == 'work_order' and not self.work_order_id:
raise ValidationError({'work_order': _("Putni nalog je obavezan za ovaj tip slanja.")})
if self.dispatch_type == 'service_record' and not self.service_record_id:
raise ValidationError({'service_record': _("Servisni zapis je obavezan za ovaj tip slanja.")})
class ServiceContextNote(BaseModel):
AUDIENCE_SOURCE_CHOICES = [
('self', 'Samo autor'),
('member', 'Odabrani član tima'),
('team', 'Svi članovi tima'),
]
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='created_service_context_notes',
verbose_name=_("Kreirao"),
)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='service_context_notes',
verbose_name=_("Primatelj"),
)
audience_source = models.CharField(
max_length=16,
choices=AUDIENCE_SOURCE_CHOICES,
default='self',
verbose_name=_("Publika"),
)
work_order = models.ForeignKey(
WorkOrder,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='service_context_notes',
verbose_name=_("Putni nalog"),
)
task = models.ForeignKey(
'task_management.Task',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='service_context_notes',
verbose_name=_("Radni zadatak"),
)
note = models.TextField(verbose_name=_("Bilješka"))
note_date = models.DateField(
null=True,
blank=True,
db_index=True,
verbose_name=_("Datum podsjetnika"),
)
is_closed = models.BooleanField(default=False, verbose_name=_("Ručno zatvoreno"))
closed_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Vrijeme zatvaranja"))
class Meta:
verbose_name = _("Bilješka servisnog konteksta")
verbose_name_plural = _("Bilješke servisnog konteksta")
ordering = ['is_closed', 'note_date', '-created_at']
def __str__(self):
return f"Note {self.pk} -> {self.recipient_id}"
def clean(self):
super().clean()
if self.task_id and self.work_order_id and self.task and self.task.work_order_id:
if self.task.work_order_id != self.work_order_id:
raise ValidationError({
'task': _("Radni zadatak mora biti povezan s istim putnim nalogom kao bilješka."),
})
class MonthlyServicerDayEntry(BaseModel):
ENTRY_TYPE_CHOICES = [
('office', _('Rad u uredu')),
('sick_leave', _('Bolovanje')),
('vacation', _('Godišnji odmor')),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='monthly_servicer_day_entries',
verbose_name=_('Serviser'),
)
entry_date = models.DateField(db_index=True, verbose_name=_('Datum'))
entry_type = models.CharField(
max_length=24,
choices=ENTRY_TYPE_CHOICES,
verbose_name=_('Vrsta unosa'),
)
description = models.CharField(max_length=255, blank=True, verbose_name=_('Opis posla'))
location = models.CharField(max_length=255, blank=True, verbose_name=_('Mjesto rada'))
start_time = models.TimeField(null=True, blank=True, verbose_name=_('Početak rada'))
end_time = models.TimeField(null=True, blank=True, verbose_name=_('Kraj rada'))
regular_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('8.00'),
verbose_name=_('Redovan rad'),
)
overtime_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_('Prekovremeni rad'),
)
class Meta:
verbose_name = _('Dnevni unos mjesečnog izvještaja servisera')
verbose_name_plural = _('Dnevni unosi mjesečnog izvještaja servisera')
ordering = ['entry_date']
constraints = [
models.UniqueConstraint(
fields=['user', 'entry_date', 'is_active'],
name='uniq_monthly_servicer_day_entry_user_date_active',
),
]
def __str__(self):
return f"{self.user_id} @ {self.entry_date} ({self.entry_type})"
def _calculate_regular_hours(self, start_time, end_time):
if start_time is None or end_time is None:
return Decimal('8.00')
start_minutes = (start_time.hour * 60) + start_time.minute + (start_time.second / 60)
end_minutes = (end_time.hour * 60) + end_time.minute + (end_time.second / 60)
if end_minutes <= start_minutes:
raise ValidationError({
'end_time': _('Kraj rada mora biti nakon početka rada.'),
})
hours = Decimal(str((end_minutes - start_minutes) / 60)).quantize(Decimal('0.01'))
return hours
def apply_defaults(self):
start_time = self.start_time
end_time = self.end_time
if self.entry_type == 'office':
if not self.description:
self.description = 'Rad u uredu'
if not self.location:
self.location = 'Zagreb (ured)'
if start_time is None:
start_time = time(8, 0)
if end_time is None:
end_time = time(16, 0)
elif self.entry_type == 'vacation':
if not self.description:
self.description = 'Godišnji odmor'
self.location = self.location or ''
elif self.entry_type == 'sick_leave':
if not self.description:
self.description = 'Bolovanje'
self.location = self.location or ''
else:
self.location = self.location or ''
self.start_time = start_time
self.end_time = end_time
self.regular_hours = self._calculate_regular_hours(start_time, end_time)
self.overtime_hours = Decimal('0.00')
def clean(self):
super().clean()
self.apply_defaults()
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)