Dodaje uredski unos pocetka i kraja rada u sidebaru mjesecnog izvjestaja servisera i automatski racuna redovan rad iz unesenog raspona. Zadrzava postojece predpopunjavanje za rad u uredu i validira neispravan vremenski raspon. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
800 lines
30 KiB
Python
800 lines
30 KiB
Python
import uuid
|
|
import re
|
|
from datetime import time
|
|
from django.db import models
|
|
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 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}"
|
|
|
|
|
|
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'})"
|
|
|
|
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 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 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) |