feat: async PDF cache i API media putanje za putne naloge
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

Uvodi background request tok za generiranje PDF-a s 24h cache-om i cleanup taskom preko Celery beat-a. Dodaje notification metadata download_url za ponovno preuzimanje bez regeneracije, API image endpointe za putne naloge i račune, te frontend integraciju za async download i produkcijski prikaz slika.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-17 16:42:02 +02:00
parent 5e7ec72a4e
commit e8330473cd
17 changed files with 726 additions and 231 deletions

View File

@@ -1,5 +1,6 @@
import os
from pathlib import Path
from celery.schedules import crontab
from datetime import timedelta
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -169,6 +170,12 @@ CACHES = {
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_BEAT_SCHEDULE = {
'cleanup-expired-generated-pdfs-hourly': {
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
'schedule': crontab(minute=0),
},
}
# URL kojim će preglednik dohvaćati statiku
STATIC_URL = '/static/'

View File

@@ -39,4 +39,7 @@ CSRF_TRUSTED_ORIGINS = [
STATIC_URL = '/static/'
STATIC_ROOT = '/app/staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = '/app/media'
MEDIA_ROOT = '/app/media'
# Privremeni fallback dok se media ne servira preko reverse proxy-ja.
SERVE_MEDIA_FILES = os.environ.get('SERVE_MEDIA_FILES', '1').strip().lower() in ('1', 'true', 'yes', 'on')

View File

@@ -30,4 +30,6 @@ if settings.DEBUG:
urlpatterns += [
path('__debug__/', include(debug_toolbar.urls)),
]
if settings.DEBUG or getattr(settings, 'SERVE_MEDIA_FILES', False):
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -7,6 +7,7 @@ from .models import (
WorkOrder,
WorkOrderPhoto,
WorkOrderInvoice,
GeneratedWorkOrderPdf,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
@@ -194,6 +195,15 @@ class WorkOrderInvoiceAdmin(admin.ModelAdmin):
search_fields = ('naziv_racuna', 'lokacija', 'opis', 'work_order__id')
ordering = ('-datum', '-created_at')
@admin.register(GeneratedWorkOrderPdf)
class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin):
list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at')
list_filter = ('pdf_type', 'status', 'is_active', 'expires_at')
search_fields = ('work_order__id', 'requested_by__username', 'requested_by__email', 'filename')
readonly_fields = ('created_at', 'updated_at')
ordering = ('-created_at',)
@admin.register(VehicleServiceRecord)
class VehicleServiceRecordAdmin(admin.ModelAdmin):
list_display = ('id', 'service_title', 'task', 'vehicle', 'performed_by', 'service_date', 'mileage', 'next_service_due_at', 'cost', 'short_description')

View File

@@ -0,0 +1,39 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import modules.fleet.models
import uuid
class Migration(migrations.Migration):
dependencies = [
('fleet', '0022_workorderphoto'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='GeneratedWorkOrderPdf',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('pdf_type', models.CharField(choices=[('work_order', 'Putni nalog'), ('service_records', 'Servisni zapisi'), ('invoices', 'Računi')], db_index=True, max_length=32, verbose_name='Tip PDF-a')),
('status', models.CharField(choices=[('pending', 'U obradi'), ('ready', 'Spremno'), ('failed', 'Neuspješno')], db_index=True, default='pending', max_length=16, verbose_name='Status')),
('file', models.FileField(blank=True, null=True, upload_to=modules.fleet.models._generated_work_order_pdf_upload_to, verbose_name='PDF datoteka')),
('filename', models.CharField(blank=True, max_length=255, verbose_name='Naziv datoteke')),
('expires_at', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Vrijedi do')),
('error_message', models.TextField(blank=True, verbose_name='Poruka greške')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='requested_generated_work_order_pdfs', to=settings.AUTH_USER_MODEL, verbose_name='Zatražio')),
('work_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='generated_pdfs', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Generirani PDF putnog naloga',
'verbose_name_plural': 'Generirani PDF-ovi putnih naloga',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 5.2.15 on 2026-07-17 14:32
import modules.fleet.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0023_generatedworkorderpdf'),
]
operations = [
migrations.AlterField(
model_name='workorderphoto',
name='created_at',
field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'),
),
migrations.AlterField(
model_name='workorderphoto',
name='id',
field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='workorderphoto',
name='image',
field=models.ImageField(upload_to=modules.fleet.models._work_order_photo_upload_to, verbose_name='Fotografija putnog naloga'),
),
migrations.AlterField(
model_name='workorderphoto',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'),
),
]

View File

@@ -54,6 +54,15 @@ def _work_order_photo_upload_to(instance, filename):
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):
"""
@@ -384,6 +393,48 @@ class WorkOrderPhoto(BaseModel):
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.

View File

@@ -1,5 +1,6 @@
from rest_framework import serializers
import re
from django.urls import reverse
from .models import (
Crane,
Vehicle,
@@ -183,6 +184,8 @@ class WorkOrderSerializer(serializers.ModelSerializer):
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
class Meta:
model = WorkOrderInvoice
fields = [
@@ -193,11 +196,22 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
'datum',
'opis',
'image',
'image_url',
'created_by',
'created_at',
]
read_only_fields = ['id', 'created_by', 'created_at']
def get_image_url(self, obj):
request = self.context.get('request')
if not obj.image:
return None
if request is None:
return None
return request.build_absolute_uri(
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
)
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
class Meta:

View File

@@ -3,6 +3,7 @@ from celery import shared_task
from django.core.mail import send_mail
from django.conf import settings
from django.utils import timezone
from django.core.files.base import ContentFile
import logging
from io import BytesIO
import base64
@@ -10,7 +11,7 @@ from PIL import Image, UnidentifiedImageError
from reportlab.lib.pagesizes import A4
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from .models import VehicleNotification
from .models import VehicleNotification, GeneratedWorkOrderPdf
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
logger = logging.getLogger(__name__)
@@ -205,4 +206,106 @@ def build_work_order_invoices_pdf_task(work_order_id):
return {
"filename": filename,
"pdf_base64": pdf_b64,
}
}
@shared_task
def cleanup_expired_generated_pdfs_task():
now = timezone.now()
expired = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
expires_at__isnull=False,
expires_at__lte=now,
)
deleted = 0
for item in expired:
if item.file:
item.file.delete(save=False)
item.is_active = False
item.status = 'failed'
item.error_message = 'PDF cache istekao.'
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
deleted += 1
return {"deleted": deleted}
@shared_task
def build_work_order_pdf_cached_task(generated_pdf_id):
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
from .services import NotificationService
generated = (
GeneratedWorkOrderPdf.objects
.select_related('work_order', 'work_order__vehicle', 'requested_by')
.filter(pk=generated_pdf_id, is_active=True)
.first()
)
if generated is None:
return {"error": "Generated PDF zapis nije pronađen."}
work_order = generated.work_order
try:
if generated.pdf_type == 'invoices':
payload = build_work_order_invoices_pdf_task(str(work_order.pk))
if not isinstance(payload, dict) or payload.get('error'):
raise ValueError(payload.get('error') if isinstance(payload, dict) else 'Neuspješno generiranje PDF računa.')
pdf_b64 = payload.get('pdf_base64')
if not pdf_b64:
raise ValueError('PDF sadržaj nije dostupan.')
pdf_bytes = base64.b64decode(pdf_b64)
elif generated.pdf_type == 'service_records':
pdf_bytes = _build_work_order_service_records_pdf(work_order)
else:
pdf_bytes = _build_work_order_pdf(work_order)
filename = generated.filename or f"{work_order.pk}.{generated.pdf_type}.pdf"
generated.file.save(filename, ContentFile(pdf_bytes), save=False)
generated.status = 'ready'
generated.error_message = ''
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
if generated.requested_by:
NotificationService.create_notification(
recipient=generated.requested_by,
title="PDF spreman",
message=f"PDF dokument je uspješno generiran za WO-{work_order.pk}.",
level="success",
send_email=False,
metadata={
"entity_type": "work_order_pdf",
"work_order_id": str(work_order.pk),
"vehicle_id": str(work_order.vehicle_id),
"section": "work-orders",
"pdf_type": generated.pdf_type,
"stage": "completed",
"generated_pdf_id": str(generated.pk),
"download_url": f"/api/fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/",
"filename": generated.filename or filename,
"expires_at": generated.expires_at.isoformat() if generated.expires_at else None,
},
)
return {"status": "ready", "generated_pdf_id": str(generated.pk)}
except Exception as exc:
generated.status = 'failed'
generated.error_message = str(exc)
generated.save(update_fields=['status', 'error_message', 'updated_at'])
if generated.requested_by:
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}.",
level="warning",
send_email=False,
metadata={
"entity_type": "work_order_pdf",
"work_order_id": str(work_order.pk),
"vehicle_id": str(work_order.vehicle_id),
"section": "work-orders",
"pdf_type": generated.pdf_type,
"stage": "failed",
"generated_pdf_id": str(generated.pk),
},
)
logger.exception("Greška kod build_work_order_pdf_cached_task: %s", exc)
return {"status": "failed", "error": str(exc)}

View File

@@ -7,7 +7,7 @@ import uuid
from io import BytesIO
from PIL import Image
from modules.fleet.models import Vehicle, WorkOrder, VehicleServiceRecord, VehicleServicePhoto
from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto
def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
@@ -94,3 +94,47 @@ class WorkOrderImagesEndpointTests(TestCase):
response = self.client.get(service_photos_url)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 0)
def test_work_order_images_endpoint_returns_api_file_urls(self):
upload_url = reverse('workorder-images', kwargs={'pk': self.work_order.pk})
create_response = self.client.post(
upload_url,
data={
'image': create_test_image('workorder-url.jpg'),
'description': 'URL test',
},
format='multipart',
)
self.assertEqual(create_response.status_code, 201, create_response.data)
list_response = self.client.get(upload_url)
self.assertEqual(list_response.status_code, 200)
images = list_response.data.get('images', [])
self.assertEqual(len(images), 1)
photo_id = images[0]['id']
self.assertIn('/api/fleet/work-orders/', images[0]['original_url'])
self.assertIn(f'/images/{photo_id}/file', images[0]['original_url'])
file_response = self.client.get(images[0]['original_url'])
self.assertEqual(file_response.status_code, 200)
self.assertTrue(file_response['Content-Type'].startswith('image/'))
def test_work_order_invoice_serializer_exposes_image_api_url(self):
invoice = WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='Test račun',
image=create_test_image('invoice.jpg'),
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)
image_response = self.client.get(image_url)
self.assertEqual(image_response.status_code, 200)
self.assertTrue(image_response['Content-Type'].startswith('image/'))

View File

@@ -2,11 +2,12 @@
from io import BytesIO
import base64
from datetime import timedelta
from PIL import Image, UnidentifiedImageError
from django.conf import settings
from django.core.mail import EmailMessage
from django.http import HttpResponse
from django.http import HttpResponse, FileResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.html import escape
@@ -31,6 +32,7 @@ from .models import (
VehicleNotification,
WorkOrder,
WorkOrderPhoto,
GeneratedWorkOrderPdf,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
@@ -53,7 +55,11 @@ from .services import (
VehicleServicePhotoService,
NotificationService,
)
from .tasks import build_work_order_invoices_pdf_task
from .tasks import (
build_work_order_invoices_pdf_task,
build_work_order_pdf_cached_task,
cleanup_expired_generated_pdfs_task,
)
register_unicode_fonts()
@@ -132,6 +138,165 @@ def _parse_format(value):
extension = 'jpg' if selected in ('jpeg', 'jpg') else selected
return supported[selected], extension
GENERATED_PDF_TTL_HOURS = 24
def _pdf_filename(work_order, pdf_type):
if pdf_type == 'invoices':
return f"{work_order.pk}.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"
def _get_cached_pdf(work_order, pdf_type):
now = timezone.now()
return (
GeneratedWorkOrderPdf.objects
.filter(
is_active=True,
work_order=work_order,
pdf_type=pdf_type,
status='ready',
expires_at__gt=now,
)
.exclude(file='')
.exclude(file__isnull=True)
.order_by('-created_at')
.first()
)
def _cleanup_expired_generated_pdfs():
now = timezone.now()
expired = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
expires_at__isnull=False,
expires_at__lte=now,
)
for item in expired:
if item.file:
item.file.delete(save=False)
item.is_active = False
item.status = 'failed'
item.error_message = 'PDF cache istekao.'
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
def _cached_pdf_file_response(generated_pdf, *, default_filename):
generated_pdf.file.open('rb')
filename = generated_pdf.filename or default_filename
response = FileResponse(generated_pdf.file, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}"'
response['Cache-Control'] = 'private, max-age=3600'
return response
def _notify_pdf_request(*, user, work_order, doc_type, stage, generated_pdf=None):
if user is None:
return
if doc_type == 'work_order':
doc_label = "putnog naloga"
elif doc_type == 'service_records':
doc_label = "servisnih zapisa putnog naloga"
else:
doc_label = "računa putnog naloga"
if stage == 'requested':
title = f"Zahtjev za PDF {doc_label}"
message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}."
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}."
level = "warning"
else:
title = f"PDF spreman ({doc_label})"
message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}."
level = "success"
metadata = {
"entity_type": "work_order_pdf",
"work_order_id": str(work_order.pk),
"vehicle_id": str(work_order.vehicle_id),
"section": "work-orders",
"pdf_type": doc_type,
"stage": stage,
}
if generated_pdf and generated_pdf.pk:
metadata["generated_pdf_id"] = str(generated_pdf.pk)
metadata["download_url"] = (
f"/api/fleet/work-orders/{work_order.pk}/generated-pdfs/{generated_pdf.pk}/download/"
)
metadata["filename"] = generated_pdf.filename or _pdf_filename(work_order, doc_type)
if generated_pdf.expires_at:
metadata["expires_at"] = generated_pdf.expires_at.isoformat()
NotificationService.create_notification(
recipient=user,
title=title,
message=message,
level=level,
send_email=False,
metadata=metadata,
)
def _request_cached_pdf_generation(*, request, work_order, pdf_type):
_cleanup_expired_generated_pdfs()
cached = _get_cached_pdf(work_order, pdf_type)
if cached:
_notify_pdf_request(
user=request.user,
work_order=work_order,
doc_type=pdf_type,
stage='completed',
generated_pdf=cached,
)
return {
"status": "ready",
"generated_pdf_id": str(cached.pk),
"download_url": f"/api/fleet/work-orders/{work_order.pk}/generated-pdfs/{cached.pk}/download/",
"filename": cached.filename or _pdf_filename(work_order, pdf_type),
"expires_at": cached.expires_at.isoformat() if cached.expires_at else None,
}
existing_pending = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order=work_order,
pdf_type=pdf_type,
status='pending',
).order_by('-created_at').first()
if existing_pending:
return {
"status": "processing",
"generated_pdf_id": str(existing_pending.pk),
}
generated_pdf = GeneratedWorkOrderPdf.objects.create(
work_order=work_order,
requested_by=request.user,
pdf_type=pdf_type,
status='pending',
filename=_pdf_filename(work_order, pdf_type),
expires_at=timezone.now() + timedelta(hours=GENERATED_PDF_TTL_HOURS),
)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type=pdf_type, stage='requested')
try:
build_work_order_pdf_cached_task.delay(str(generated_pdf.pk))
cleanup_expired_generated_pdfs_task.delay()
except KombuOperationalError:
build_work_order_pdf_cached_task.apply(args=[str(generated_pdf.pk)]).get()
cleanup_expired_generated_pdfs_task.apply().get()
return {
"status": "processing",
"generated_pdf_id": str(generated_pdf.pk),
}
def _build_simple_pdf(title, rows):
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
@@ -1160,41 +1325,6 @@ def _send_document_email(*, recipient, subject, body, filename, pdf_bytes):
message.send(fail_silently=False)
def _notify_pdf_request(*, user, work_order, doc_type, stage):
if user is None:
return
if doc_type == 'work_order':
doc_label = "putnog naloga"
elif doc_type == 'service_records':
doc_label = "servisnih zapisa putnog naloga"
else:
doc_label = "računa putnog naloga"
if stage == 'requested':
title = f"Zahtjev za PDF {doc_label}"
message = f"Zaprimljen je zahtjev za generiranje PDF dokumenta za WO-{work_order.pk}."
level = "info"
else:
title = f"PDF spreman ({doc_label})"
message = f"PDF dokument je uspješno generiran za WO-{work_order.pk}."
level = "success"
NotificationService.create_notification(
recipient=user,
title=title,
message=message,
level=level,
send_email=False,
metadata={
"entity_type": "work_order_pdf",
"work_order_id": str(work_order.pk),
"vehicle_id": str(work_order.vehicle_id),
"section": "work-orders",
"pdf_type": doc_type,
"stage": stage,
},
)
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
def pusher_auth(request):
@@ -1273,13 +1403,15 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
images = []
for photo in photos_qs:
original_url = request.build_absolute_uri(photo.image.url) if photo.image else None
file_path = reverse('workorder-image-file', kwargs={'pk': work_order.pk, 'photo_id': photo.pk})
original_url = request.build_absolute_uri(file_path)
optimized_url = request.build_absolute_uri(f"{file_path}?w={width}&q={quality}&fmt={image_format.lower()}")
images.append({
"id": photo.pk,
"work_order_id": photo.work_order_id,
"description": photo.description,
"original_url": original_url,
"optimized_url": original_url,
"optimized_url": optimized_url,
"created_at": photo.created_at,
})
@@ -1292,9 +1424,65 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
"images": images,
}, status=status.HTTP_200_OK)
@action(detail=True, methods=['get'], url_path=r'images/(?P<photo_id>[^/.]+)/file')
def image_file(self, request, pk=None, photo_id=None):
work_order = self.get_object()
photo = WorkOrderPhoto.objects.filter(
is_active=True,
work_order_id=work_order.pk,
pk=photo_id,
).first()
if photo is None or not photo.image:
raise DRFValidationError({"detail": "Fotografija putnog naloga nije dostupna."})
width = _parse_positive_int(
request.query_params.get('w'),
field_name='w',
default=1280,
min_value=160,
max_value=3840,
)
quality = _parse_positive_int(
request.query_params.get('q'),
field_name='q',
default=75,
min_value=30,
max_value=95,
)
image_format, extension = _parse_format(request.query_params.get('fmt'))
try:
photo.image.open('rb')
with Image.open(photo.image) as source:
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
if width and image.width > width:
ratio = width / float(image.width)
target_height = max(1, int(image.height * ratio))
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
buffer = BytesIO()
save_kwargs = {"format": image_format, "optimize": True}
if image_format in ('WEBP', 'JPEG'):
save_kwargs["quality"] = quality
image.save(buffer, **save_kwargs)
except (UnidentifiedImageError, OSError):
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
finally:
photo.image.close()
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
response['Cache-Control'] = 'private, max-age=86400'
response['Content-Disposition'] = f'inline; filename="work-order-photo-{photo.pk}.{extension}"'
return response
@action(detail=True, methods=['get'], url_path='pdf')
def pdf(self, request, pk=None):
work_order = self.get_object()
_cleanup_expired_generated_pdfs()
cached = _get_cached_pdf(work_order, 'work_order')
if cached:
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'work_order'))
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='requested')
pdf_bytes = _build_work_order_pdf(work_order)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='work_order', stage='completed')
@@ -1302,9 +1490,20 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order.pdf"'
return response
@action(detail=True, methods=['post'], url_path='pdf-request')
def pdf_request(self, request, pk=None):
work_order = self.get_object()
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='work_order')
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
@action(detail=True, methods=['get'], url_path='service-records-pdf')
def service_records_pdf(self, request, pk=None):
work_order = self.get_object()
_cleanup_expired_generated_pdfs()
cached = _get_cached_pdf(work_order, 'service_records')
if cached:
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'service_records'))
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested')
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')
@@ -1312,9 +1511,20 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="{work_order.pk}.work-order-service-records.pdf"'
return response
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')
def service_records_pdf_request(self, request, pk=None):
work_order = self.get_object()
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='service_records')
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
@action(detail=True, methods=['get'], url_path='invoices-pdf')
def invoices_pdf(self, request, pk=None):
work_order = self.get_object()
_cleanup_expired_generated_pdfs()
cached = _get_cached_pdf(work_order, 'invoices')
if cached:
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'invoices'))
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='invoices', stage='requested')
try:
task_result = build_work_order_invoices_pdf_task.delay(str(work_order.pk))
@@ -1341,13 +1551,34 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
@action(detail=True, methods=['post'], url_path='invoices-pdf-request')
def invoices_pdf_request(self, request, pk=None):
work_order = self.get_object()
payload = _request_cached_pdf_generation(request=request, work_order=work_order, pdf_type='invoices')
return Response(payload, status=status.HTTP_200_OK if payload.get('status') == 'ready' else status.HTTP_202_ACCEPTED)
@action(detail=True, methods=['get'], url_path=r'generated-pdfs/(?P<pdf_id>[^/.]+)/download')
def generated_pdf_download(self, request, pk=None, pdf_id=None):
work_order = self.get_object()
_cleanup_expired_generated_pdfs()
generated_pdf = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order=work_order,
pk=pdf_id,
status='ready',
expires_at__gt=timezone.now(),
).exclude(file='').exclude(file__isnull=True).first()
if generated_pdf is None:
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type))
@action(detail=True, methods=['get'], url_path='pdf-preview')
def pdf_preview(self, request, pk=None):
work_order = self.get_object()
invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')
invoice_rows = []
for invoice in invoices:
image_url = request.build_absolute_uri(invoice.image.url) if invoice.image else None
image_url = request.build_absolute_uri(reverse('work-order-invoice-image', kwargs={'pk': invoice.pk})) if invoice.image else None
datum = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'
invoice_rows.append(
f"""
@@ -1535,6 +1766,57 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
qs = qs.filter(work_order_id=work_order_id)
return qs
def get_serializer_context(self):
context = super().get_serializer_context()
context['request'] = self.request
return context
@action(detail=True, methods=['get'], url_path='image')
def image(self, request, pk=None):
invoice = self.get_object()
if not invoice.image:
raise DRFValidationError({"detail": "Slika računa nije dostupna."})
width = _parse_positive_int(
request.query_params.get('w'),
field_name='w',
default=1280,
min_value=160,
max_value=3840,
)
quality = _parse_positive_int(
request.query_params.get('q'),
field_name='q',
default=75,
min_value=30,
max_value=95,
)
image_format, extension = _parse_format(request.query_params.get('fmt'))
try:
invoice.image.open('rb')
with Image.open(invoice.image) as source:
image = source.convert('RGB') if image_format in ('WEBP', 'JPEG') else source.copy()
if width and image.width > width:
ratio = width / float(image.width)
target_height = max(1, int(image.height * ratio))
image = image.resize((width, target_height), Image.Resampling.LANCZOS)
buffer = BytesIO()
save_kwargs = {"format": image_format, "optimize": True}
if image_format in ('WEBP', 'JPEG'):
save_kwargs["quality"] = quality
image.save(buffer, **save_kwargs)
except (UnidentifiedImageError, OSError):
raise DRFValidationError({"detail": "Datoteka nije valjana slika ili je oštećena."})
finally:
invoice.image.close()
response = HttpResponse(buffer.getvalue(), content_type=f"image/{'jpeg' if extension == 'jpg' else extension}")
response['Cache-Control'] = 'private, max-age=86400'
response['Content-Disposition'] = f'inline; filename="invoice-{invoice.pk}.{extension}"'
return response
def perform_create(self, serializer):
work_order = serializer.validated_data.get('work_order')
if work_order is None:

View File

@@ -50,6 +50,22 @@ services:
- redis
- worker
beat:
container_name: 004erpbeat
build:
context: ./backend
dockerfile: Dockerfile
command: celery -A core beat --loglevel=info
user: "1000:1000"
volumes:
- ./backend:/app
- media_volume:/app/media
env_file:
- .env
depends_on:
- redis
- worker
volumes:
media_volume:
external: true # Ako je definiran u glavnom file-u, ovo ga povezuje

View File

@@ -46,6 +46,26 @@ services:
redis:
condition: service_started
beat:
build:
context: ./backend
dockerfile: Dockerfile
container_name: 004erpbeat
restart: unless-stopped
env_file:
- .env
environment:
DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core beat --loglevel=info
volumes:
- media_volume:/app/media
depends_on:
redis:
condition: service_started
worker:
condition: service_started
flower:
build:
context: ./backend

View File

@@ -1,20 +1,16 @@
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useStore } from '@nanostores/preact';
import WorkOrderImageCarousel from './WorkOrderImageCarousel';
import WorkOrderPhotoUpload from '../fleet/WorkOrderPhotoUpload';
import ModalShell from '../ui/ModalShell';
import {
createWorkOrderInvoice,
fetchVehicleById,
fetchTeamMembers,
updateVehicle,
fetchWorkOrderInvoices,
fetchWorkOrderImages,
openWorkOrderInvoicesPdfPage,
sendWorkOrderEmail,
} from '../../stores/fleetDashboardStore';
import { showToast } from '../../stores/toastStore';
import { $craneSerialByVehicle, cacheCraneSerial, getCachedCraneSerial } from '../../stores/craneSerialStore';
import { formatEntityCode } from '../../lib/displayIds';
const PURPOSE_OPTIONS = [
@@ -24,21 +20,22 @@ const PURPOSE_OPTIONS = [
];
export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmit }) {
const serialByVehicle = useStore($craneSerialByVehicle);
const [isEditing, setIsEditing] = useState(false);
const [form, setForm] = useState({
has_travel_order: false,
status: 'open',
start_mileage: '',
end_mileage: '',
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: '',
purpose: '',
notes: '',
crane_current_mileage: '',
crane_superstructure_working_hours: '',
crane_chassis_working_hours: '',
});
const [imageFiles, setImageFiles] = useState([]);
const [submitting, setSubmitting] = useState(false);
@@ -65,7 +62,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
opis: '',
imageFile: null,
});
const [resolvedCraneSerialNumber, setResolvedCraneSerialNumber] = useState('');
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [locationQuery, setLocationQuery] = useState('');
const [locationResults, setLocationResults] = useState([]);
@@ -73,15 +69,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
const [searchingLocation, setSearchingLocation] = useState(false);
const [mapError, setMapError] = useState('');
const targetCraneId = useMemo(
() => workOrder?.crane || workOrder?.vehicle || null,
[workOrder?.crane, workOrder?.vehicle]
);
const cachedCraneSerial = useMemo(
() => getCachedCraneSerial(targetCraneId),
[serialByVehicle, targetCraneId]
);
const craneLabel = useMemo(() => {
if (!workOrder) return '-';
return workOrder.craneLabel || workOrder.craneInfo?.display_name || workOrder.craneInfo?.registration_number || `#${workOrder.crane || workOrder.vehicle}`;
@@ -103,9 +90,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
useEffect(() => {
if (!open || !workOrder) return;
setIsEditing(false);
const initialSerial = String(workOrder.craneInfo?.crane_serial_number || cachedCraneSerial || '').trim();
setResolvedCraneSerialNumber(initialSerial);
cacheCraneSerial(targetCraneId, initialSerial);
setForm({
has_travel_order: !!(workOrder.travel_start_at || workOrder.travel_end_at || workOrder.location || workOrder.start_mileage != null || workOrder.end_mileage != null),
status: workOrder.status || 'open',
@@ -121,9 +105,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
servicer_vehicle_start_mileage: workOrder.servicer_vehicle_start_mileage == null ? '' : String(workOrder.servicer_vehicle_start_mileage),
servicer_vehicle_end_mileage: workOrder.servicer_vehicle_end_mileage == null ? '' : String(workOrder.servicer_vehicle_end_mileage),
servicer_vehicle_fuel_cost: workOrder.servicer_vehicle_fuel_cost == null ? '' : String(workOrder.servicer_vehicle_fuel_cost),
crane_current_mileage: String(workOrder.craneInfo?.current_mileage ?? ''),
crane_superstructure_working_hours: String(workOrder.craneInfo?.superstructure_working_hours ?? ''),
crane_chassis_working_hours: String(workOrder.craneInfo?.chassis_working_hours ?? ''),
});
setSubmitting(false);
setError('');
@@ -155,38 +136,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setSelectedLocation(null);
setSearchingLocation(false);
setMapError('');
}, [open, workOrder, cachedCraneSerial, targetCraneId]);
useEffect(() => {
if (!open || !targetCraneId) {
return;
}
let cancelled = false;
(async () => {
try {
const freshCrane = await fetchVehicleById(targetCraneId);
if (cancelled || !freshCrane) {
return;
}
const incomingSerial = String(freshCrane.crane_serial_number || '').trim();
if (incomingSerial) {
cacheCraneSerial(targetCraneId, incomingSerial);
setResolvedCraneSerialNumber(incomingSerial);
}
setForm((prev) => ({
...prev,
crane_current_mileage: String(freshCrane.current_mileage ?? prev.crane_current_mileage ?? ''),
crane_superstructure_working_hours: String(freshCrane.superstructure_working_hours ?? prev.crane_superstructure_working_hours ?? ''),
crane_chassis_working_hours: String(freshCrane.chassis_working_hours ?? prev.crane_chassis_working_hours ?? ''),
}));
} catch {
// no-op: fallback remains workOrder.craneInfo values
}
})();
return () => {
cancelled = true;
};
}, [open, workOrder?.id, targetCraneId]);
}, [open, workOrder]);
useEffect(() => {
if (!emailModalOpen) return;
@@ -318,13 +268,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
const endMileage = form.end_mileage === '' ? null : Number(form.end_mileage);
let travelStart = null;
let travelEnd = null;
const craneCurrentMileage = form.crane_current_mileage === '' ? null : Number(form.crane_current_mileage);
const craneSuperstructureHours = form.crane_superstructure_working_hours === '' ? null : Number(form.crane_superstructure_working_hours);
const craneChassisHours = form.crane_chassis_working_hours === '' ? null : Number(form.crane_chassis_working_hours);
const existingCraneCurrentMileage = Number(workOrder?.craneInfo?.current_mileage ?? 0);
const existingCraneSuperstructureHours = Number(workOrder?.craneInfo?.superstructure_working_hours ?? 0);
const existingCraneChassisHours = Number(workOrder?.craneInfo?.chassis_working_hours ?? 0);
if (startMileage != null && (Number.isNaN(startMileage) || startMileage < 0)) {
setError('Početna kilometraža mora biti valjan nenegativan broj.');
return;
@@ -341,18 +284,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setError('Završna kilometraža mora biti veća ili jednaka početnoj.');
return;
}
if (craneCurrentMileage != null && (Number.isNaN(craneCurrentMileage) || craneCurrentMileage < 0)) {
setError('Trenutna km dizalice mora biti valjan nenegativan broj.');
return;
}
if (craneSuperstructureHours != null && (Number.isNaN(craneSuperstructureHours) || craneSuperstructureHours < 0)) {
setError('Radni sati nadogradnje moraju biti valjan nenegativan broj.');
return;
}
if (craneChassisHours != null && (Number.isNaN(craneChassisHours) || craneChassisHours < 0)) {
setError('Radni sati podvozja moraju biti valjan nenegativan broj.');
return;
}
if ((form.travel_start_at && !form.travel_end_at) || (!form.travel_start_at && form.travel_end_at)) {
setError('Unesite i početak i kraj puta, ili ostavite oba prazna.');
return;
@@ -380,43 +311,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
setSubmitting(true);
try {
const hasCraneChanges = (
craneCurrentMileage != null
&& craneSuperstructureHours != null
&& craneChassisHours != null
&& (
craneCurrentMileage !== existingCraneCurrentMileage
|| craneSuperstructureHours !== existingCraneSuperstructureHours
|| craneChassisHours !== existingCraneChassisHours
)
);
if (hasCraneChanges && targetCraneId) {
let craneSerialNumber = String(resolvedCraneSerialNumber || '').trim();
if (!craneSerialNumber) {
craneSerialNumber = cachedCraneSerial;
}
if (!craneSerialNumber) {
const freshCrane = await fetchVehicleById(targetCraneId);
craneSerialNumber = String(freshCrane?.crane_serial_number || '').trim();
if (craneSerialNumber) {
cacheCraneSerial(targetCraneId, craneSerialNumber);
setResolvedCraneSerialNumber(craneSerialNumber);
}
}
if (!craneSerialNumber) {
// nastavi sa spremanjem sati i kilometraže za legacy dizalice bez SN
}
const cranePatch = {
current_mileage: craneCurrentMileage,
superstructure_working_hours: craneSuperstructureHours,
chassis_working_hours: craneChassisHours,
};
if (craneSerialNumber) {
cranePatch.crane_serial_number = craneSerialNumber;
}
await updateVehicle(targetCraneId, cranePatch);
}
await onSubmit(workOrder, {
status: form.status,
start_mileage: startMileage,
@@ -536,7 +430,7 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
<div>
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-text-main">Uredi nalog {formatEntityCode('PN', workOrder.id)}</h3>
<h3 className="text-lg font-semibold text-text-main">Uredi putni nalog {formatEntityCode('PN', workOrder.id)}</h3>
<p className="text-sm text-text-muted">Dizalica: {craneLabel}</p>
</div>
<button
@@ -676,67 +570,6 @@ export default function WorkOrderDetailModal({ open, workOrder, onClose, onSubmi
</div>
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Podaci dizalice</h4>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Početna km dizalice (nalog)</span>
<input
type="number"
min="0"
value={form.start_mileage}
onInput={(event) => setForm({ ...form, start_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Završna km dizalice (nalog)</span>
<input
type="number"
min="0"
value={form.end_mileage}
onInput={(event) => setForm({ ...form, end_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Trenutna km dizalice</span>
<input
type="number"
min="0"
value={form.crane_current_mileage}
onInput={(event) => setForm({ ...form, crane_current_mileage: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati nadogradnje</span>
<input
type="number"
min="0"
value={form.crane_superstructure_working_hours}
onInput={(event) => setForm({ ...form, crane_superstructure_working_hours: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-text-main">Radni sati podvozja</span>
<input
type="number"
min="0"
value={form.crane_chassis_working_hours}
onInput={(event) => setForm({ ...form, crane_chassis_working_hours: event.currentTarget.value })}
className="rounded-lg border border-border-hairline bg-canvas-base px-3 py-2 text-text-main disabled:bg-canvas-deep"
disabled={!isEditing}
/>
</label>
</div>
</div>
<div className="rounded-xl border border-border-hairline bg-canvas-base p-3">
<h4 className="mb-2 text-sm font-semibold text-text-main">Putni podaci</h4>
<div className="grid gap-4 sm:grid-cols-2">

View File

@@ -143,8 +143,9 @@ export default function WorkOrderInvoicesPdfPage() {
}
if (Array.isArray(invoices)) {
invoices.forEach((invoice) => {
if (invoice?.image) {
paths.push(invoice.image);
const invoiceImage = invoice?.image_url || invoice?.image;
if (invoiceImage) {
paths.push(invoiceImage);
}
});
}
@@ -304,7 +305,7 @@ export default function WorkOrderInvoicesPdfPage() {
<div><span className="font-semibold">opis:</span> {invoice.opis || '-'}</div>
</div>
<InvoiceImagePreview
imageUrl={getAuthenticatedMediaSrc(invoice.image) || resolveMediaUrl(invoice.image)}
imageUrl={getAuthenticatedMediaSrc(invoice.image_url || invoice.image) || resolveMediaUrl(invoice.image_url || invoice.image)}
alt={`Račun ${invoice.naziv_racuna}`}
/>
</div>

View File

@@ -1,6 +1,11 @@
import { useMemo } from 'preact/hooks';
import ModalShell from '../ui/ModalShell';
import { downloadWorkOrderInvoicesPdf, downloadWorkOrderPdf, downloadWorkOrderServiceRecordsPdf } from '../../stores/fleetDashboardStore';
import {
downloadGeneratedPdfByUrl,
downloadWorkOrderInvoicesPdf,
downloadWorkOrderPdf,
downloadWorkOrderServiceRecordsPdf,
} from '../../stores/fleetDashboardStore';
function formatTimestamp(value) {
if (!value) return '';
@@ -57,6 +62,12 @@ function getEntityAction(notification, meta) {
? 'Preuzmi PDF servisnih zapisa'
: 'Preuzmi PDF putnog naloga',
run() {
if (metadata.download_url) {
return downloadGeneratedPdfByUrl(
metadata.download_url,
metadata.filename || `${metadata.work_order_id}.${metadata.pdf_type || 'work-order'}.pdf`
);
}
if (metadata.pdf_type === 'invoices') {
return downloadWorkOrderInvoicesPdf(metadata.work_order_id);
}

View File

@@ -742,9 +742,14 @@ export async function downloadWorkOrderPdf(workOrderId) {
throw new Error('Work order ID je obavezan.');
}
showToast('Kreiran je zahtjev za PDF putnog naloga.', 'info');
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order.pdf`);
showToast('PDF putnog naloga je preuzet.', 'success');
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/pdf-request/`, {});
if (payload?.status === 'ready' && payload?.download_url) {
await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order.pdf`);
showToast('PDF putnog naloga je preuzet iz cache-a.', 'success');
return payload;
}
showToast('PDF putnog naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
return payload;
}
export async function downloadWorkOrderInvoicesPdf(workOrderId) {
@@ -752,9 +757,14 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
throw new Error('Work order ID je obavezan.');
}
showToast('Kreiran je zahtjev za PDF računa putnog naloga.', 'info');
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-invoices.pdf`);
showToast('PDF računa putnog naloga je preuzet.', 'success');
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/invoices-pdf-request/`, {});
if (payload?.status === 'ready' && payload?.download_url) {
await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order-invoices.pdf`);
showToast('PDF računa putnog naloga je preuzet iz cache-a.', 'success');
return payload;
}
showToast('PDF računa se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
return payload;
}
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
@@ -762,9 +772,23 @@ export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
throw new Error('Work order ID je obavezan.');
}
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/`, { responseType: 'blob' });
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.pdf`);
showToast('PDF servisnih zapisa putnog naloga je preuzet.', 'success');
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
if (payload?.status === 'ready' && payload?.download_url) {
await downloadGeneratedPdfByUrl(payload.download_url, payload.filename || `${workOrderId}.work-order-service-records.pdf`);
showToast('PDF servisnih zapisa je preuzet iz cache-a.', 'success');
return payload;
}
showToast('PDF servisnih zapisa se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
return payload;
}
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
if (!downloadUrl) {
throw new Error('Nedostaje URL za preuzimanje PDF-a.');
}
const blob = await api.get(downloadUrl, { responseType: 'blob' });
saveBlobToFile(blob, filename);
return true;
}
export function openWorkOrderInvoicesPdfPage(workOrderId) {