From bf8e25efe705f65521c656e20a227839eb377cf0 Mon Sep 17 00:00:00 2001 From: mariomitte Date: Fri, 17 Jul 2026 23:53:47 +0200 Subject: [PATCH] feat: dodaj display_code polje za putne naloge (MT070626 format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/core/users/serializers.py | 2 +- backend/modules/fleet/admin.py | 6 +- .../migrations/0025_workorder_display_code.py | 24 +++++ .../0026_backfill_workorder_display_code.py | 30 ++++++ backend/modules/fleet/models.py | 13 +++ backend/modules/fleet/serializers.py | 28 +++++- backend/modules/fleet/services.py | 2 +- backend/modules/fleet/tasks.py | 20 ++-- backend/modules/fleet/tests/test_services.py | 58 +++++++++++- backend/modules/fleet/views.py | 50 ++++++---- .../modules/task_management/serializers.py | 5 +- .../dashboard/FleetDashboardShell.jsx | 94 +++++++++++++++++-- .../components/dashboard/TaskCreateModal.jsx | 12 ++- .../dashboard/TaskServiceRecordsModal.jsx | 6 +- .../dashboard/WorkOrderDetailModal.jsx | 30 +++++- .../dashboard/WorkOrderInvoicesPdfPage.jsx | 4 +- .../components/dashboard/WorkOrderModal.jsx | 27 ++++++ frontend/src/layouts/Layout.astro | 2 +- frontend/src/lib/displayIds.js | 18 ++++ 19 files changed, 377 insertions(+), 54 deletions(-) create mode 100644 backend/modules/fleet/migrations/0025_workorder_display_code.py create mode 100644 backend/modules/fleet/migrations/0026_backfill_workorder_display_code.py 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): - Računi putnog naloga WO-{work_order.pk} + Računi putnog naloga {_work_order_display_code(work_order)}