diff --git a/backend/core/users/serializers.py b/backend/core/users/serializers.py
index b187cbb..0a22fae 100644
--- a/backend/core/users/serializers.py
+++ b/backend/core/users/serializers.py
@@ -66,7 +66,7 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class Meta:
model = CustomUser
- fields = ['id', 'email', 'full_name', 'is_team_member']
+ fields = ['id', 'email', 'full_name', 'is_team_member', 'is_serviser']
read_only_fields = fields
def get_full_name(self, obj):
diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py
index b3f7773..7dfa64d 100644
--- a/backend/modules/fleet/admin.py
+++ b/backend/modules/fleet/admin.py
@@ -44,7 +44,7 @@ class ServiceAttachmentInline(admin.TabularInline):
class WorkOrderInline(admin.TabularInline):
model = WorkOrder
extra = 0
- fields = ('creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
+ fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
readonly_fields = ('date',)
@@ -176,9 +176,9 @@ class CraneAdmin(VehicleAdmin):
@admin.register(WorkOrder)
class WorkOrderAdmin(admin.ModelAdmin):
- list_display = ('id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
+ list_display = ('display_code', 'id', 'vehicle', 'creator', 'date', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
list_filter = ('status', 'date', 'vehicle')
- search_fields = ('vehicle__registration_number', 'creator__username', 'purpose')
+ search_fields = ('display_code', 'vehicle__registration_number', 'creator__username', 'purpose')
readonly_fields = ('date',)
ordering = ('-date',)
inlines = (WorkOrderInvoiceInline, WorkOrderPhotoInline)
diff --git a/backend/modules/fleet/migrations/0025_workorder_display_code.py b/backend/modules/fleet/migrations/0025_workorder_display_code.py
new file mode 100644
index 0000000..0ba48c9
--- /dev/null
+++ b/backend/modules/fleet/migrations/0025_workorder_display_code.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.2.15 on 2026-07-17 21:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0024_alter_workorderphoto_created_at_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='workorder',
+ name='display_code',
+ field=models.CharField(
+ blank=True,
+ db_index=True,
+ help_text='Format: MT070626 (2 slova + 6 znamenki).',
+ max_length=8,
+ verbose_name='Prikaz putnog naloga',
+ ),
+ ),
+ ]
diff --git a/backend/modules/fleet/migrations/0026_backfill_workorder_display_code.py b/backend/modules/fleet/migrations/0026_backfill_workorder_display_code.py
new file mode 100644
index 0000000..02ee123
--- /dev/null
+++ b/backend/modules/fleet/migrations/0026_backfill_workorder_display_code.py
@@ -0,0 +1,30 @@
+from django.db import migrations
+
+
+def backfill_workorder_display_code(apps, schema_editor):
+ WorkOrder = apps.get_model('fleet', 'WorkOrder')
+ for work_order in WorkOrder.objects.select_related('creator').all():
+ current_code = str(getattr(work_order, 'display_code', '') or '').strip().upper()
+ if current_code:
+ continue
+ creator = getattr(work_order, 'creator', None)
+ first_name = str(getattr(creator, 'first_name', '') or '').strip()
+ last_name = str(getattr(creator, 'last_name', '') or '').strip()
+ if not first_name or not last_name:
+ continue
+ order_date = getattr(work_order, 'date', None)
+ if not order_date:
+ continue
+ display_code = f"{first_name[0].upper()}{last_name[0].upper()}{order_date.strftime('%d%m%y')}"
+ WorkOrder.objects.filter(pk=work_order.pk).update(display_code=display_code)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fleet', '0025_workorder_display_code'),
+ ]
+
+ operations = [
+ migrations.RunPython(backfill_workorder_display_code, migrations.RunPython.noop),
+ ]
diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py
index 920ce0e..0651725 100644
--- a/backend/modules/fleet/models.py
+++ b/backend/modules/fleet/models.py
@@ -287,6 +287,13 @@ class WorkOrder(BaseModel):
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"))
@@ -322,6 +329,12 @@ class WorkOrder(BaseModel):
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.")})
diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py
index fc53b90..a6dc986 100644
--- a/backend/modules/fleet/serializers.py
+++ b/backend/modules/fleet/serializers.py
@@ -1,6 +1,7 @@
from rest_framework import serializers
import re
from django.urls import reverse
+from django.utils import timezone
from .models import (
Crane,
Vehicle,
@@ -98,7 +99,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrder
fields = [
- 'id', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
+ 'id', 'display_code', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
'start_mileage', 'end_mileage',
'servicer_vehicle_registration', 'servicer_vehicle_make_model',
'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
@@ -112,6 +113,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
'creator': {'required': False},
'start_mileage': {'required': False, 'allow_null': True},
'end_mileage': {'required': False, 'allow_null': True},
+ 'display_code': {'required': False, 'allow_blank': False},
'servicer_vehicle_registration': {'required': False, 'allow_blank': True},
'servicer_vehicle_make_model': {'required': False, 'allow_blank': True},
'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
@@ -146,6 +148,14 @@ class WorkOrderSerializer(serializers.ModelSerializer):
if user and 'creator' not in validated_data:
validated_data['creator'] = user
+ if not validated_data.get('display_code'):
+ creator = validated_data.get('creator')
+ generated_display_code = self._generate_display_code(creator, timezone.localdate())
+ if not generated_display_code:
+ raise serializers.ValidationError({
+ 'display_code': 'Serviser mora imati ime i prezime za automatsko generiranje oznake. Unesite oznaku ručno u formatu MT070626.'
+ })
+ validated_data['display_code'] = generated_display_code
return services.VehicleService.create_work_order(validated_data)
@@ -182,6 +192,22 @@ class WorkOrderSerializer(serializers.ModelSerializer):
raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'})
return attrs
+ def validate_display_code(self, value):
+ normalized = str(value or '').strip().upper()
+ if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", normalized):
+ raise serializers.ValidationError('Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).')
+ return normalized
+
+ @staticmethod
+ def _generate_display_code(user, target_date):
+ if user is None:
+ return None
+ first_name = str(getattr(user, 'first_name', '') or '').strip()
+ last_name = str(getattr(user, 'last_name', '') or '').strip()
+ if not first_name or not last_name:
+ return None
+ return f"{first_name[0].upper()}{last_name[0].upper()}{target_date.strftime('%d%m%y')}"
+
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
diff --git a/backend/modules/fleet/services.py b/backend/modules/fleet/services.py
index 9c6e578..404f848 100644
--- a/backend/modules/fleet/services.py
+++ b/backend/modules/fleet/services.py
@@ -141,7 +141,7 @@ class VehicleService:
Task.objects.filter(
is_active=True,
work_order=instance,
- status='servis',
+ status__in=('aktivan', 'servis'),
).update(status='spreman_za_zavrsetak')
# Ako je nalog upravo zatvoren, obavijestimo kreatora/servisera
diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py
index f9b8ad8..235b86c 100644
--- a/backend/modules/fleet/tasks.py
+++ b/backend/modules/fleet/tasks.py
@@ -18,6 +18,13 @@ logger = logging.getLogger(__name__)
register_unicode_fonts()
+def _work_order_display_code(work_order):
+ normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper()
+ if normalized:
+ return normalized
+ return 'NALOG'
+
+
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def send_notification_email(self, notification_id):
"""
@@ -95,6 +102,7 @@ def _build_work_order_invoices_pdf(work_order):
chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-")
mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-")
generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y")
+ display_code = _work_order_display_code(work_order)
def draw_header_footer(page_num):
draw_standard_header_footer(
@@ -107,7 +115,7 @@ def _build_work_order_invoices_pdf(work_order):
upgrade_hours=upgrade_hours,
chassis_hours=chassis_hours,
mileage=mileage,
- work_order_number=str(work_order.pk),
+ work_order_number=display_code,
generated_date=generated_date,
report_title="Računi putnog naloga",
page_size=A4,
@@ -122,7 +130,7 @@ def _build_work_order_invoices_pdf(work_order):
y -= 8
pdf.setFont("Vera-Bold", 13)
- pdf.drawString(margin, y, f"Računi putnog naloga WO-{work_order.pk}")
+ pdf.drawString(margin, y, f"Računi putnog naloga {display_code}")
y -= 20
pdf.setFont("Vera", 10)
pdf.drawString(margin, y, f"Datum naloga: {work_order.date}")
@@ -202,7 +210,7 @@ def build_work_order_invoices_pdf_task(work_order_id):
pdf_bytes = _build_work_order_invoices_pdf(work_order)
pdf_b64 = base64.b64encode(pdf_bytes).decode('ascii')
- filename = f"{work_order.pk}.work-order-invoices.pdf"
+ filename = f"{_work_order_display_code(work_order)}.work-order-invoices.pdf"
return {
"filename": filename,
"pdf_base64": pdf_b64,
@@ -259,7 +267,7 @@ def build_work_order_pdf_cached_task(generated_pdf_id):
else:
pdf_bytes = _build_work_order_pdf(work_order)
- filename = generated.filename or f"{work_order.pk}.{generated.pdf_type}.pdf"
+ filename = generated.filename or f"{_work_order_display_code(work_order)}.{generated.pdf_type}.pdf"
generated.file.save(filename, ContentFile(pdf_bytes), save=False)
generated.status = 'ready'
generated.error_message = ''
@@ -269,7 +277,7 @@ def build_work_order_pdf_cached_task(generated_pdf_id):
NotificationService.create_notification(
recipient=generated.requested_by,
title="PDF spreman",
- message=f"PDF dokument je uspješno generiran za WO-{work_order.pk}.",
+ message=f"PDF dokument je uspješno generiran za putni nalog {_work_order_display_code(work_order)}.",
level="success",
send_email=False,
metadata={
@@ -294,7 +302,7 @@ def build_work_order_pdf_cached_task(generated_pdf_id):
NotificationService.create_notification(
recipient=generated.requested_by,
title="Greška kod PDF-a",
- message=f"Generiranje PDF dokumenta nije uspjelo za WO-{work_order.pk}.",
+ message=f"Generiranje PDF dokumenta nije uspjelo za putni nalog {_work_order_display_code(work_order)}.",
level="warning",
send_email=False,
metadata={
diff --git a/backend/modules/fleet/tests/test_services.py b/backend/modules/fleet/tests/test_services.py
index 3a573c5..b4e5ab5 100644
--- a/backend/modules/fleet/tests/test_services.py
+++ b/backend/modules/fleet/tests/test_services.py
@@ -3,7 +3,9 @@ from django.test import TestCase
from django.contrib.auth import get_user_model
from modules.fleet.services import VehicleService
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord
+from modules.fleet.serializers import WorkOrderSerializer
from modules.task_management.models import Task
+from rest_framework.test import APIRequestFactory
class FleetServiceTests(TestCase):
@@ -72,4 +74,58 @@ class FleetServiceTests(TestCase):
VehicleService.update_work_order(work_order, {"status": "closed"})
task.refresh_from_db()
- self.assertEqual(task.status, "spreman_za_zavrsetak")
\ No newline at end of file
+ self.assertEqual(task.status, "spreman_za_zavrsetak")
+
+ def test_closing_work_order_moves_active_task_to_ready_for_completion(self):
+ work_order = WorkOrder.objects.create(
+ vehicle=self.vehicle,
+ creator=self.user,
+ purpose="kontrola",
+ status="open",
+ start_mileage=5000,
+ end_mileage=5200,
+ )
+ task = Task.objects.create(
+ title="Preventiva",
+ description="",
+ status="aktivan",
+ assigned_to=self.user,
+ vehicle=self.vehicle,
+ work_order=work_order,
+ )
+
+ VehicleService.update_work_order(work_order, {"status": "closed"})
+ task.refresh_from_db()
+ self.assertEqual(task.status, "spreman_za_zavrsetak")
+
+ def test_work_order_serializer_autogenerates_display_code(self):
+ self.user.first_name = "Mario"
+ self.user.last_name = "Tkalac"
+ self.user.save(update_fields=["first_name", "last_name"])
+
+ request = APIRequestFactory().post('/api/fleet/work-orders/', {})
+ request.user = self.user
+ serializer = WorkOrderSerializer(
+ data={
+ "vehicle": str(self.vehicle.id),
+ "purpose": "kontrola",
+ },
+ context={"request": request},
+ )
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+ work_order = serializer.save()
+ self.assertRegex(work_order.display_code, r'^MT[0-9]{6}$')
+
+ def test_work_order_serializer_rejects_invalid_display_code(self):
+ request = APIRequestFactory().post('/api/fleet/work-orders/', {})
+ request.user = self.user
+ serializer = WorkOrderSerializer(
+ data={
+ "vehicle": str(self.vehicle.id),
+ "purpose": "kontrola",
+ "display_code": "BAD-CODE",
+ },
+ context={"request": request},
+ )
+ self.assertFalse(serializer.is_valid())
+ self.assertIn("display_code", serializer.errors)
\ No newline at end of file
diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py
index 85a9a7f..e706837 100644
--- a/backend/modules/fleet/views.py
+++ b/backend/modules/fleet/views.py
@@ -142,17 +142,26 @@ def _parse_format(value):
GENERATED_PDF_TTL_HOURS = 24
+def _work_order_display_code(work_order):
+ normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper()
+ if normalized:
+ return normalized
+ return 'NALOG'
+
+
def _pdf_filename(work_order, pdf_type):
+ display_code = _work_order_display_code(work_order)
if pdf_type == 'invoices':
- return f"{work_order.pk}.work-order-invoices.pdf"
+ return f"{display_code}.work-order-invoices.pdf"
if pdf_type == 'service_records':
- return f"{work_order.pk}.work-order-service-records.pdf"
- return f"{work_order.pk}.work-order.pdf"
+ return f"{display_code}.work-order-service-records.pdf"
+ return f"{display_code}.work-order.pdf"
def _get_cached_pdf(work_order, pdf_type):
now = timezone.now()
- return (
+ expected_filename = _pdf_filename(work_order, pdf_type)
+ candidates = list(
GeneratedWorkOrderPdf.objects
.filter(
is_active=True,
@@ -164,8 +173,11 @@ def _get_cached_pdf(work_order, pdf_type):
.exclude(file='')
.exclude(file__isnull=True)
.order_by('-created_at')
- .first()
)
+ for candidate in candidates:
+ if str(candidate.filename or '').strip() == expected_filename:
+ return candidate
+ return None
def _cleanup_expired_generated_pdfs():
@@ -203,17 +215,18 @@ def _notify_pdf_request(*, user, work_order, doc_type, stage, generated_pdf=None
else:
doc_label = "računa putnog naloga"
+ display_code = _work_order_display_code(work_order)
if stage == 'requested':
title = f"Zahtjev za PDF {doc_label}"
- message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}."
+ message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za putni nalog {display_code}."
level = "info"
elif stage == 'failed':
title = f"Greška kod PDF-a ({doc_label})"
- message = f"Generiranje PDF dokumenta nije uspjelo za WO-{work_order.pk}."
+ message = f"Generiranje PDF dokumenta nije uspjelo za putni nalog {display_code}."
level = "warning"
else:
title = f"PDF spreman ({doc_label})"
- message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}."
+ message = f"PDF dokument je uspješno generiran za putni nalog {display_code}."
level = "success"
metadata = {
@@ -337,6 +350,7 @@ def _build_work_order_pdf(work_order):
invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at'))
now_local = timezone.localtime(timezone.now())
tz = timezone.get_current_timezone()
+ display_code = _work_order_display_code(work_order)
def _fmt_date(value):
if not value:
@@ -437,7 +451,7 @@ def _build_work_order_pdf(work_order):
_paragraph("N A L O G
ZA SLUŽBENO PUTOVANJE", bold=True, size=11, align=1),
_paragraph(
f"Mjesto i datum:
{place_label} {_fmt_date(now_local.date())}
"
- f"Broj naloga:
{work_order.pk}",
+ f"Broj naloga:
{display_code}",
size=8.8,
),
]]
@@ -686,7 +700,7 @@ def _build_work_order_service_records_pdf(work_order):
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
mileage_str = str(vehicle.current_mileage or '-')
- nalog_str = str(work_order.pk)
+ nalog_str = _work_order_display_code(work_order)
generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
HEADER_H = 86
@@ -1064,7 +1078,7 @@ def _build_service_record_pdf(service_record):
upgrade_hours_str = str(getattr(vehicle, 'superstructure_working_hours', '-') or '-')
chassis_hours_str = str(getattr(vehicle, 'chassis_working_hours', '-') or '-')
mileage_str = str(vehicle.current_mileage or '-')
- nalog_str = str(getattr(work_order, 'pk', '-') or '-')
+ nalog_str = _work_order_display_code(work_order) if work_order else '-'
generated_date_str = timezone.localtime(timezone.now()).strftime('%d.%m.%Y')
HEADER_H = 86
@@ -1487,7 +1501,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
pdf_bytes = _build_work_order_pdf(work_order)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
- response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order.pdf"'
+ response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "work_order")}"'
return response
@action(detail=True, methods=['post'], url_path='pdf-request')
@@ -1508,7 +1522,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
pdf_bytes = _build_work_order_service_records_pdf(work_order)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
- response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order-service-records.pdf"'
+ response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"'
return response
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')
@@ -1545,7 +1559,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
except (ValueError, TypeError):
raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."})
- filename = payload.get('filename') or f"{work_order.pk}.work-order-invoices.pdf"
+ filename = payload.get('filename') or _pdf_filename(work_order, 'invoices')
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
@@ -1600,7 +1614,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):