feat: dodaj ishodište i PDF račune za putni nalog
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled

Dodano je origin_location polje kroz backend model, serializer, admin i UI
modal za kreiranje/uređivanje putnog naloga (default: Zagreb).

Računi putnog naloga sada prihvaćaju slike i PDF datoteke, uz backend
validaciju formata, podršku za serving PDF-a i prilagodbu preview prikaza.

Za greške uploada računa dodan je error toast kako bi poruke validacije bile
vidljive korisniku odmah u sučelju.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-19 19:54:15 +02:00
parent cbcbf0b3af
commit d35eaeccf4
12 changed files with 175 additions and 14 deletions

View File

@@ -44,7 +44,7 @@ class ServiceAttachmentInline(admin.TabularInline):
class WorkOrderInline(admin.TabularInline):
model = WorkOrder
extra = 0
fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
fields = ('display_code', 'creator', 'date', 'start_mileage', 'end_mileage', 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'purpose', 'status')
readonly_fields = ('date',)
@@ -176,7 +176,7 @@ class CraneAdmin(VehicleAdmin):
@admin.register(WorkOrder)
class WorkOrderAdmin(admin.ModelAdmin):
list_display = ('display_code', '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', 'origin_location', 'location', 'travel_start_at', 'travel_end_at', 'start_mileage', 'end_mileage', 'distance_display', 'status')
list_filter = ('status', 'date', 'vehicle')
search_fields = ('display_code', 'vehicle__registration_number', 'creator__username', 'purpose')
readonly_fields = ('date',)

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0026_backfill_workorder_display_code'),
]
operations = [
migrations.AddField(
model_name='workorder',
name='origin_location',
field=models.CharField(blank=True, max_length=255, verbose_name='Lokacija ishodišta'),
),
]

View File

@@ -0,0 +1,18 @@
from django.db import migrations, models
import modules.fleet.models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0027_workorder_origin_location'),
]
operations = [
migrations.AlterField(
model_name='workorderinvoice',
name='image',
field=models.FileField(blank=True, null=True, upload_to=modules.fleet.models._work_order_invoice_upload_to, verbose_name='Datoteka računa'),
),
]

View File

@@ -285,6 +285,7 @@ class WorkOrder(BaseModel):
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"))
@@ -351,11 +352,11 @@ class WorkOrderInvoice(BaseModel):
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.ImageField(
image = models.FileField(
upload_to=_work_order_invoice_upload_to,
null=True,
blank=True,
verbose_name=_("Slika računa"),
verbose_name=_("Datoteka računa"),
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,

View File

@@ -1,5 +1,6 @@
from rest_framework import serializers
import re
from pathlib import Path
from django.urls import reverse
from django.utils import timezone
from .models import (
@@ -104,7 +105,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
'servicer_vehicle_registration', 'servicer_vehicle_make_model',
'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
'servicer_vehicle_fuel_cost',
'location', 'travel_start_at', 'travel_end_at',
'origin_location', 'location', 'travel_start_at', 'travel_end_at',
'purpose', 'notes', 'status'
]
read_only_fields = ['id', 'date']
@@ -119,6 +120,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True},
'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True},
'origin_location': {'required': False, 'allow_blank': True},
'location': {'required': False, 'allow_blank': True},
'travel_start_at': {'required': False, 'allow_null': True},
'travel_end_at': {'required': False, 'allow_null': True},
@@ -211,6 +213,7 @@ class WorkOrderSerializer(serializers.ModelSerializer):
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
image_content_type = serializers.SerializerMethodField()
class Meta:
model = WorkOrderInvoice
@@ -223,6 +226,7 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
'opis',
'image',
'image_url',
'image_content_type',
'created_by',
'created_at',
]
@@ -238,6 +242,31 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
)
def get_image_content_type(self, obj):
if not obj.image:
return None
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return 'application/pdf'
if suffix in {'.jpg', '.jpeg'}:
return 'image/jpeg'
if suffix == '.png':
return 'image/png'
if suffix == '.webp':
return 'image/webp'
if suffix == '.gif':
return 'image/gif'
return 'application/octet-stream'
def validate_image(self, value):
if not value:
return value
suffix = Path(getattr(value, 'name', '')).suffix.lower()
allowed_suffixes = {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf'}
if suffix not in allowed_suffixes:
raise serializers.ValidationError('Datoteka računa mora biti slika (.jpg, .jpeg, .png, .webp, .gif) ili PDF (.pdf).')
return value
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
class Meta:

View File

@@ -6,6 +6,7 @@ from django.utils import timezone
from django.core.files.base import ContentFile
import logging
from io import BytesIO
from pathlib import Path
import base64
from PIL import Image, UnidentifiedImageError
from reportlab.lib.pagesizes import A4
@@ -165,6 +166,11 @@ def _build_work_order_invoices_pdf(work_order):
pdf.drawString(margin, y, "slika: Nema slike.")
continue
suffix = Path(invoice.image.name or '').suffix.lower()
if suffix == '.pdf':
pdf.drawString(margin, y, "prilog: PDF račun (otvorite datoteku računa za pregled).")
continue
try:
invoice.image.open('rb')
with Image.open(invoice.image) as source:

View File

@@ -22,6 +22,15 @@ def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
)
def create_test_pdf(filename='test.pdf'):
content = b'%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF'
return SimpleUploadedFile(
name=filename,
content=content,
content_type='application/pdf',
)
class WorkOrderImagesEndpointTests(TestCase):
def setUp(self):
user_model = get_user_model()
@@ -138,3 +147,23 @@ class WorkOrderImagesEndpointTests(TestCase):
image_response = self.client.get(image_url)
self.assertEqual(image_response.status_code, 200)
self.assertTrue(image_response['Content-Type'].startswith('image/'))
def test_work_order_invoice_serializer_supports_pdf_file(self):
invoice = WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='PDF račun',
image=create_test_pdf('invoice.pdf'),
created_by=self.user,
)
list_url = f"{reverse('work-order-invoice-list')}?work_order_id={self.work_order.pk}"
list_response = self.client.get(list_url)
self.assertEqual(list_response.status_code, 200)
self.assertEqual(len(list_response.data), 1)
image_url = list_response.data[0].get('image_url')
self.assertIsNotNone(image_url)
self.assertIn(f'/api/fleet/work-order-invoices/{invoice.pk}/image/', image_url)
self.assertEqual(list_response.data[0].get('image_content_type'), 'application/pdf')
file_response = self.client.get(image_url)
self.assertEqual(file_response.status_code, 200)
self.assertEqual(file_response['Content-Type'], 'application/pdf')

View File

@@ -3,6 +3,7 @@
from io import BytesIO
import base64
from datetime import timedelta
from pathlib import Path
from PIL import Image, UnidentifiedImageError
from django.conf import settings
@@ -401,6 +402,7 @@ def _build_work_order_pdf(work_order):
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
grand_total = daily_total + transport_total
place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
origin_label = (work_order.origin_location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna]
attachments_text = ', '.join(invoice_names) if invoice_names else '-'
assigned_servicer_vehicle = (
@@ -598,7 +600,7 @@ def _build_work_order_pdf(work_order):
y,
[
["RELACIJA od", "RELACIJA do", "Vrsta prijevoznog sredstva", "Razred [km]", "Iznos za prijevoz", "Ukupan iznos"],
[place_label or '-', work_order.location or '-', f"{servicer_vehicle_label}, {servicer_vehicle_registration}", str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
[origin_label or '-', work_order.location or '-', f"{servicer_vehicle_label}, {servicer_vehicle_registration}", str(work_order.distance or 0), _fmt_eur(transport_total), _fmt_eur(transport_total)],
["", "", "", "", "", ""],
["", "", "", "", "", ""],
],
@@ -1601,7 +1603,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
<td>{escape(invoice.lokacija or '-')}</td>
<td>{escape(datum)}</td>
<td>{escape(invoice.opis or '-')}</td>
<td>{f'<a href="{escape(image_url)}" target="_blank" rel="noopener noreferrer">Otvori sliku</a>' if image_url else '-'}</td>
<td>{f'<a href="{escape(image_url)}" target="_blank" rel="noopener noreferrer">Otvori dokument</a>' if image_url else '-'}</td>
</tr>
"""
)
@@ -1791,6 +1793,17 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
if not invoice.image:
raise DRFValidationError({"detail": "Slika računa nije dostupna."})
suffix = Path(invoice.image.name or '').suffix.lower()
if suffix == '.pdf':
invoice.image.open('rb')
try:
response = HttpResponse(invoice.image.read(), content_type='application/pdf')
finally:
invoice.image.close()
response['Cache-Control'] = 'private, max-age=86400'
response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.pdf"'
return response
width = _parse_positive_int(
request.query_params.get('w'),
field_name='w',