Refactor fleet module and add cleanup script
- Update Celery configuration in celery.py - Modify base settings for improved performance - Enhance task management in fleet tasks.py - Revise fleet views.py for better data handling - Add cleanup_tmp_archives.sh script for temporary file management - Adjust docker-compose.prod.yml for consistency - Optimize fleetDashboardStore.js with reduced code complexity
This commit is contained in:
@@ -2,11 +2,33 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery import Task
|
||||||
|
import gc
|
||||||
|
import logging
|
||||||
|
|
||||||
# Postavi Django settings modul
|
# Postavi Django settings modul
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
app = Celery('core')
|
app = Celery('core')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAwareTask(Task):
|
||||||
|
"""Celery base task s hookovima za memory-heavy async operacije."""
|
||||||
|
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def on_success(self, retval, task_id, args, kwargs):
|
||||||
|
logger.info("Celery task uspješan: %s (%s)", self.name, task_id)
|
||||||
|
super().on_success(retval, task_id, args, kwargs)
|
||||||
|
|
||||||
|
def on_failure(self, exc, task_id, args, kwargs, einfo):
|
||||||
|
logger.exception("Celery task neuspješan: %s (%s): %s", self.name, task_id, exc)
|
||||||
|
super().on_failure(exc, task_id, args, kwargs, einfo)
|
||||||
|
|
||||||
|
def after_return(self, status, retval, task_id, args, kwargs, einfo):
|
||||||
|
gc.collect()
|
||||||
|
super().after_return(status, retval, task_id, args, kwargs, einfo)
|
||||||
|
|
||||||
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
|
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
|
||||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
|||||||
|
|
||||||
CELERY_BROKER_URL = REDIS_URL
|
CELERY_BROKER_URL = REDIS_URL
|
||||||
CELERY_RESULT_BACKEND = REDIS_URL
|
CELERY_RESULT_BACKEND = REDIS_URL
|
||||||
|
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
|
||||||
|
CELERY_TASK_ACKS_LATE = True
|
||||||
|
CELERY_WORKER_MAX_TASKS_PER_CHILD = 20
|
||||||
|
CELERY_WORKER_MAX_MEMORY_PER_CHILD = 300000
|
||||||
|
|
||||||
# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
|
# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
|
||||||
# Za ovo ti je potreban paket 'django-redis' u requirements.txt
|
# Za ovo ti je potreban paket 'django-redis' u requirements.txt
|
||||||
@@ -175,6 +179,14 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
|
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
|
||||||
'schedule': crontab(minute=0),
|
'schedule': crontab(minute=0),
|
||||||
},
|
},
|
||||||
|
'cleanup-expired-generated-archives-hourly': {
|
||||||
|
'task': 'modules.fleet.tasks.cleanup_expired_generated_archives_task',
|
||||||
|
'schedule': crontab(minute=10),
|
||||||
|
},
|
||||||
|
'cleanup-stale-tmp-archives-daily': {
|
||||||
|
'task': 'modules.fleet.tasks.cleanup_stale_tmp_archives_task',
|
||||||
|
'schedule': crontab(hour=3, minute=30),
|
||||||
|
},
|
||||||
'notify-upcoming-tasks-daily': {
|
'notify-upcoming-tasks-daily': {
|
||||||
'task': 'modules.task_management.tasks.notify_upcoming_tasks',
|
'task': 'modules.task_management.tasks.notify_upcoming_tasks',
|
||||||
'schedule': crontab(hour=8, minute=0),
|
'schedule': crontab(hour=8, minute=0),
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# backend/modules/fleet/tasks.py
|
# backend/modules/fleet/tasks.py
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
|
from core.celery import ResourceAwareTask
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.core.files import File
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
import logging
|
import logging
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -835,13 +837,12 @@ def process_work_order_invoice_ocr(invoice_id):
|
|||||||
return {"status": "ok", "invoice_id": str(invoice.pk)}
|
return {"status": "ok", "invoice_id": str(invoice.pk)}
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task(base=ResourceAwareTask)
|
||||||
def build_monthly_archive_cached_task(generated_archive_id):
|
def build_monthly_archive_cached_task(generated_archive_id):
|
||||||
from .models import GeneratedFleetArchive
|
from .models import GeneratedFleetArchive
|
||||||
from .services import NotificationService
|
|
||||||
from .views import (
|
from .views import (
|
||||||
_build_monthly_service_tasks_archive_content,
|
_build_monthly_service_tasks_archive_to_temp_file,
|
||||||
_build_monthly_work_orders_archive_content,
|
_build_monthly_work_orders_archive_to_temp_file,
|
||||||
_notify_monthly_archive_request,
|
_notify_monthly_archive_request,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -854,25 +855,27 @@ def build_monthly_archive_cached_task(generated_archive_id):
|
|||||||
if generated is None:
|
if generated is None:
|
||||||
return {"status": "failed", "error": "Generated archive record not found"}
|
return {"status": "failed", "error": "Generated archive record not found"}
|
||||||
|
|
||||||
|
tmp_archive_path = None
|
||||||
try:
|
try:
|
||||||
if generated.archive_type == 'service_tasks':
|
if generated.archive_type == 'service_tasks':
|
||||||
archive_content = _build_monthly_service_tasks_archive_content(
|
tmp_archive_path = _build_monthly_service_tasks_archive_to_temp_file(
|
||||||
user=generated.requested_by,
|
user=generated.requested_by,
|
||||||
year=generated.year,
|
year=generated.year,
|
||||||
month=generated.month,
|
month=generated.month,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
archive_content = _build_monthly_work_orders_archive_content(
|
tmp_archive_path = _build_monthly_work_orders_archive_to_temp_file(
|
||||||
user=generated.requested_by,
|
user=generated.requested_by,
|
||||||
year=generated.year,
|
year=generated.year,
|
||||||
month=generated.month,
|
month=generated.month,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not archive_content:
|
if tmp_archive_path is None or tmp_archive_path.stat().st_size <= 0:
|
||||||
raise ValueError('ZIP arhiva je prazna.')
|
raise ValueError('ZIP arhiva je prazna.')
|
||||||
|
|
||||||
filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip"
|
filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip"
|
||||||
generated.file.save(filename, ContentFile(archive_content), save=False)
|
with tmp_archive_path.open('rb') as temp_handle:
|
||||||
|
generated.file.save(filename, File(temp_handle), save=False)
|
||||||
generated.status = 'ready'
|
generated.status = 'ready'
|
||||||
generated.error_message = ''
|
generated.error_message = ''
|
||||||
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
|
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
|
||||||
@@ -902,6 +905,9 @@ def build_monthly_archive_cached_task(generated_archive_id):
|
|||||||
)
|
)
|
||||||
logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc)
|
logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc)
|
||||||
return {"status": "failed", "error": str(exc)}
|
return {"status": "failed", "error": str(exc)}
|
||||||
|
finally:
|
||||||
|
if tmp_archive_path is not None:
|
||||||
|
tmp_archive_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
@@ -924,6 +930,14 @@ def cleanup_expired_generated_archives_task():
|
|||||||
return {"deleted": deleted}
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_stale_tmp_archives_task():
|
||||||
|
from .views import _cleanup_stale_tmp_archives
|
||||||
|
|
||||||
|
deleted = _cleanup_stale_tmp_archives()
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def cleanup_expired_generated_pdfs_task():
|
def cleanup_expired_generated_pdfs_task():
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from io import StringIO
|
|||||||
import base64
|
import base64
|
||||||
import csv
|
import csv
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import zipfile
|
import zipfile
|
||||||
import re
|
import re
|
||||||
@@ -14,7 +15,7 @@ from pathlib import Path
|
|||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import EmailMessage
|
from django.core.mail import EmailMessage
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
@@ -155,7 +156,6 @@ def _work_order_related_tasks_queryset(work_order):
|
|||||||
vehicle_id=work_order.vehicle_id,
|
vehicle_id=work_order.vehicle_id,
|
||||||
task_id__isnull=False,
|
task_id__isnull=False,
|
||||||
task__is_active=True,
|
task__is_active=True,
|
||||||
task__work_order=work_order,
|
|
||||||
).values_list('task_id', flat=True)
|
).values_list('task_id', flat=True)
|
||||||
)
|
)
|
||||||
task_ids = list({*direct_task_ids, *inferred_task_ids})
|
task_ids = list({*direct_task_ids, *inferred_task_ids})
|
||||||
@@ -305,14 +305,6 @@ def _format_decimal_fixed(value, *, default='0.00', places=2):
|
|||||||
return format(normalized.quantize(quantizer), 'f')
|
return format(normalized.quantize(quantizer), 'f')
|
||||||
|
|
||||||
|
|
||||||
def _normalize_datetime_for_compare(value):
|
|
||||||
if not isinstance(value, datetime):
|
|
||||||
return value
|
|
||||||
if timezone.is_naive(value):
|
|
||||||
return timezone.make_aware(value, timezone.get_current_timezone())
|
|
||||||
return timezone.localtime(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _work_order_travel_expenses_context(work_order):
|
def _work_order_travel_expenses_context(work_order):
|
||||||
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
||||||
trip_entries = []
|
trip_entries = []
|
||||||
@@ -327,16 +319,8 @@ def _work_order_travel_expenses_context(work_order):
|
|||||||
|
|
||||||
if trip_entries:
|
if trip_entries:
|
||||||
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
|
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
|
||||||
start_candidates = [
|
start_candidates = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')]
|
||||||
_normalize_datetime_for_compare(entry.get('start_dt'))
|
end_candidates = [entry['end_dt'] for entry in trip_entries if entry.get('end_dt')]
|
||||||
for entry in trip_entries
|
|
||||||
if entry.get('start_dt')
|
|
||||||
]
|
|
||||||
end_candidates = [
|
|
||||||
_normalize_datetime_for_compare(entry.get('end_dt'))
|
|
||||||
for entry in trip_entries
|
|
||||||
if entry.get('end_dt')
|
|
||||||
]
|
|
||||||
if entry_dates:
|
if entry_dates:
|
||||||
trip_start_date = min(entry_dates)
|
trip_start_date = min(entry_dates)
|
||||||
trip_end_date = max(entry_dates)
|
trip_end_date = max(entry_dates)
|
||||||
@@ -400,6 +384,8 @@ def _parse_format(value):
|
|||||||
|
|
||||||
GENERATED_PDF_TTL_HOURS = 24
|
GENERATED_PDF_TTL_HOURS = 24
|
||||||
GENERATED_ARCHIVE_TTL_DAYS = 7
|
GENERATED_ARCHIVE_TTL_DAYS = 7
|
||||||
|
TMP_ARCHIVE_RETENTION_HOURS = 24
|
||||||
|
TMP_ARCHIVE_DIRNAME = 'erp-generated-archives'
|
||||||
|
|
||||||
|
|
||||||
def _work_order_display_code(work_order):
|
def _work_order_display_code(work_order):
|
||||||
@@ -464,32 +450,24 @@ def _resolve_service_report_tasks(work_order, task_id):
|
|||||||
return [selected_task], selected_task
|
return [selected_task], selected_task
|
||||||
|
|
||||||
|
|
||||||
_COMPRESS_IMAGE_MAX_MEGAPIXELS = 30 # preskači tek vrlo velike slike (>30 MP); standardni telefoni 12–20 MP ostaju uključeni
|
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
||||||
|
|
||||||
|
|
||||||
def _compress_image_for_pdf(image_field, max_width=800, quality=75):
|
|
||||||
"""
|
"""
|
||||||
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
||||||
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
|
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
|
||||||
|
|
||||||
BILINEAR umjesto LANCZOS: višestruko brže za velike slike (izbjeći Gunicorn timeout).
|
|
||||||
Megapixel guard čita samo header i preskače slike > 8 MP bez dekodiranja piksela.
|
|
||||||
except BaseException hvata i SystemExit koji Gunicorn diže na SIGABRT (worker timeout).
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
image_field.open('rb')
|
image_field.open('rb')
|
||||||
with Image.open(image_field) as img:
|
with Image.open(image_field) as src:
|
||||||
img = ImageOps.exif_transpose(img)
|
img = src.convert('RGB')
|
||||||
w, h = img.size
|
if img.width > max_width:
|
||||||
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
ratio = max_width / float(img.width)
|
||||||
return None
|
new_h = max(1, int(img.height * ratio))
|
||||||
img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
|
img = img.resize((max_width, new_h), Image.LANCZOS)
|
||||||
rgb = img.convert('RGB')
|
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
rgb.save(buf, format='JPEG', quality=quality)
|
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return ImageReader(buf)
|
return ImageReader(buf)
|
||||||
except BaseException:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -498,28 +476,23 @@ def _compress_image_for_pdf(image_field, max_width=800, quality=75):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _compress_image_for_docx(image_field, max_width=800, quality=80):
|
def _compress_image_for_docx(image_field, max_width=1600, quality=80):
|
||||||
"""
|
"""
|
||||||
Pripremi sliku za python-docx kao JPEG stream razumne veličine.
|
Pripremi sliku za python-docx kao JPEG stream razumne veličine.
|
||||||
|
|
||||||
BILINEAR umjesto LANCZOS: višestruko brže za velike slike (izbjeći Gunicorn timeout).
|
|
||||||
Megapixel guard čita samo header i preskače slike > 8 MP bez dekodiranja piksela.
|
|
||||||
except BaseException hvata i SystemExit koji Gunicorn diže na SIGABRT (worker timeout).
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
image_field.open('rb')
|
image_field.open('rb')
|
||||||
with Image.open(image_field) as img:
|
with Image.open(image_field) as src:
|
||||||
img = ImageOps.exif_transpose(img)
|
img = src.convert('RGB')
|
||||||
w, h = img.size
|
if img.width > max_width:
|
||||||
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
ratio = max_width / float(img.width)
|
||||||
return None
|
new_h = max(1, int(img.height * ratio))
|
||||||
img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
|
img = img.resize((max_width, new_h), Image.LANCZOS)
|
||||||
rgb = img.convert('RGB')
|
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
rgb.save(buf, format='JPEG', quality=quality)
|
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return buf
|
return buf
|
||||||
except BaseException:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -546,30 +519,10 @@ def _get_cached_pdf(work_order, pdf_type):
|
|||||||
)
|
)
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
if str(candidate.filename or '').strip() == expected_filename:
|
if str(candidate.filename or '').strip() == expected_filename:
|
||||||
file_name = getattr(candidate.file, 'name', '')
|
return candidate
|
||||||
storage = getattr(candidate.file, 'storage', None)
|
|
||||||
if file_name and storage:
|
|
||||||
try:
|
|
||||||
if storage.exists(file_name):
|
|
||||||
return candidate
|
|
||||||
except (FileNotFoundError, OSError, ValueError):
|
|
||||||
pass
|
|
||||||
candidate.file = None
|
|
||||||
candidate.is_active = False
|
|
||||||
candidate.status = 'failed'
|
|
||||||
candidate.error_message = 'PDF datoteka nije dostupna.'
|
|
||||||
candidate.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _mark_generated_pdf_failed(generated_pdf, error_message):
|
|
||||||
generated_pdf.file = None
|
|
||||||
generated_pdf.is_active = False
|
|
||||||
generated_pdf.status = 'failed'
|
|
||||||
generated_pdf.error_message = error_message
|
|
||||||
generated_pdf.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_expired_generated_pdfs():
|
def _cleanup_expired_generated_pdfs():
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
expired = GeneratedWorkOrderPdf.objects.filter(
|
expired = GeneratedWorkOrderPdf.objects.filter(
|
||||||
@@ -580,11 +533,10 @@ def _cleanup_expired_generated_pdfs():
|
|||||||
for item in expired:
|
for item in expired:
|
||||||
if item.file:
|
if item.file:
|
||||||
item.file.delete(save=False)
|
item.file.delete(save=False)
|
||||||
item.file = None
|
|
||||||
item.is_active = False
|
item.is_active = False
|
||||||
item.status = 'failed'
|
item.status = 'failed'
|
||||||
item.error_message = 'PDF cache istekao.'
|
item.error_message = 'PDF cache istekao.'
|
||||||
item.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||||
|
|
||||||
|
|
||||||
def _parse_amount_decimal(value):
|
def _parse_amount_decimal(value):
|
||||||
@@ -607,11 +559,10 @@ def _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None):
|
|||||||
for cached in cache_qs:
|
for cached in cache_qs:
|
||||||
if cached.file:
|
if cached.file:
|
||||||
cached.file.delete(save=False)
|
cached.file.delete(save=False)
|
||||||
cached.file = None
|
|
||||||
cached.is_active = False
|
cached.is_active = False
|
||||||
cached.status = 'failed'
|
cached.status = 'failed'
|
||||||
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
|
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
|
||||||
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||||
|
|
||||||
|
|
||||||
def _upsert_additional_cost_row_from_invoice(invoice):
|
def _upsert_additional_cost_row_from_invoice(invoice):
|
||||||
@@ -643,11 +594,7 @@ def _upsert_additional_cost_row_from_invoice(invoice):
|
|||||||
|
|
||||||
|
|
||||||
def _cached_pdf_file_response(generated_pdf, *, default_filename):
|
def _cached_pdf_file_response(generated_pdf, *, default_filename):
|
||||||
try:
|
generated_pdf.file.open('rb')
|
||||||
generated_pdf.file.open('rb')
|
|
||||||
except (FileNotFoundError, OSError, ValueError):
|
|
||||||
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
|
||||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
|
||||||
filename = generated_pdf.filename or default_filename
|
filename = generated_pdf.filename or default_filename
|
||||||
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
||||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||||
@@ -2988,27 +2935,40 @@ def _unique_zip_entry_name(entry_name, used_names):
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
def _parse_year_month_params(request):
|
def _tmp_archive_dir():
|
||||||
from datetime import date as _dt_date
|
return Path(tempfile.gettempdir()) / TMP_ARCHIVE_DIRNAME
|
||||||
|
|
||||||
raw_year = request.data.get('year') if isinstance(getattr(request, 'data', None), dict) else None
|
|
||||||
raw_month = request.data.get('month') if isinstance(getattr(request, 'data', None), dict) else None
|
|
||||||
if raw_year in (None, ''):
|
|
||||||
raw_year = request.query_params.get('year', _dt_date.today().year)
|
|
||||||
if raw_month in (None, ''):
|
|
||||||
raw_month = request.query_params.get('month', _dt_date.today().month)
|
|
||||||
|
|
||||||
try:
|
|
||||||
year = int(raw_year)
|
|
||||||
month = int(raw_month)
|
|
||||||
if not (1 <= month <= 12):
|
|
||||||
raise ValueError()
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
raise DRFValidationError({'detail': 'Nevažeći year/month parametar.'})
|
|
||||||
return year, month
|
|
||||||
|
|
||||||
|
|
||||||
def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
def _create_tmp_archive_path(*, prefix):
|
||||||
|
target_dir = _tmp_archive_dir()
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode='wb',
|
||||||
|
suffix='.zip',
|
||||||
|
prefix=prefix,
|
||||||
|
dir=target_dir,
|
||||||
|
delete=False,
|
||||||
|
) as handle:
|
||||||
|
return Path(handle.name)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_stale_tmp_archives(*, retention_hours=TMP_ARCHIVE_RETENTION_HOURS):
|
||||||
|
target_dir = _tmp_archive_dir()
|
||||||
|
if not target_dir.exists():
|
||||||
|
return 0
|
||||||
|
cutoff_ts = timezone.now().timestamp() - (retention_hours * 3600)
|
||||||
|
deleted = 0
|
||||||
|
for item in target_dir.glob('*.zip'):
|
||||||
|
try:
|
||||||
|
if item.stat().st_mtime <= cutoff_ts:
|
||||||
|
item.unlink(missing_ok=True)
|
||||||
|
deleted += 1
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Greška pri čišćenju privremene ZIP datoteke %s", item)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def _build_monthly_service_tasks_archive_to_temp_file(*, user, year, month):
|
||||||
from modules.task_management.models import Task
|
from modules.task_management.models import Task
|
||||||
|
|
||||||
tasks = list(
|
tasks = list(
|
||||||
@@ -3028,24 +2988,26 @@ def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
|||||||
raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'})
|
raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'})
|
||||||
|
|
||||||
used_names = set()
|
used_names = set()
|
||||||
archive_buffer = BytesIO()
|
tmp_path = _create_tmp_archive_path(prefix='monthly-service-tasks-')
|
||||||
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
try:
|
||||||
for task in tasks:
|
with zipfile.ZipFile(str(tmp_path), mode='w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||||
work_order = task.work_order
|
for task in tasks:
|
||||||
if work_order is None:
|
work_order = task.work_order
|
||||||
continue
|
if work_order is None:
|
||||||
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task])
|
continue
|
||||||
base_name = _service_records_docx_filename(work_order, task)
|
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task])
|
||||||
entry_name = _unique_zip_entry_name(base_name, used_names)
|
base_name = _service_records_docx_filename(work_order, task)
|
||||||
archive.writestr(entry_name, docx_bytes)
|
entry_name = _unique_zip_entry_name(base_name, used_names)
|
||||||
|
archive.writestr(entry_name, docx_bytes)
|
||||||
archive_content = archive_buffer.getvalue()
|
if tmp_path.stat().st_size <= 0:
|
||||||
if not archive_content:
|
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
||||||
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
return tmp_path
|
||||||
return archive_content
|
except Exception:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def _build_monthly_work_orders_archive_content(*, user, year, month):
|
def _build_monthly_work_orders_archive_to_temp_file(*, user, year, month):
|
||||||
from modules.task_management.models import Task
|
from modules.task_management.models import Task
|
||||||
|
|
||||||
monthly_tasks = (
|
monthly_tasks = (
|
||||||
@@ -3084,27 +3046,73 @@ def _build_monthly_work_orders_archive_content(*, user, year, month):
|
|||||||
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
|
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
|
||||||
|
|
||||||
used_names = set()
|
used_names = set()
|
||||||
archive_buffer = BytesIO()
|
tmp_path = _create_tmp_archive_path(prefix='monthly-work-orders-')
|
||||||
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
try:
|
||||||
for work_order in work_orders:
|
with zipfile.ZipFile(str(tmp_path), mode='w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||||
pdf_bytes = _build_work_order_pdf(work_order)
|
for work_order in work_orders:
|
||||||
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
|
pdf_bytes = _build_work_order_pdf(work_order)
|
||||||
archive.writestr(work_order_pdf_name, pdf_bytes)
|
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
|
||||||
|
archive.writestr(work_order_pdf_name, pdf_bytes)
|
||||||
|
|
||||||
display_code = _work_order_display_code(work_order)
|
display_code = _work_order_display_code(work_order)
|
||||||
invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at')
|
invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at')
|
||||||
for index, invoice in enumerate(invoices, start=1):
|
for index, invoice in enumerate(invoices, start=1):
|
||||||
attachment = _file_attachment(invoice.image, fallback_name=f"invoice-{index}.bin")
|
if not invoice.image:
|
||||||
if not attachment:
|
continue
|
||||||
continue
|
invoice_filename = Path(str(getattr(invoice.image, 'name', '') or f"invoice-{index}.bin")).name
|
||||||
invoice_filename, content, _content_type = attachment
|
archive_path = _unique_zip_entry_name(f"Racuni/{display_code}/{invoice_filename}", used_names)
|
||||||
archive_path = f"Racuni/{display_code}/{invoice_filename}"
|
try:
|
||||||
archive.writestr(_unique_zip_entry_name(archive_path, used_names), content)
|
invoice.image.open('rb')
|
||||||
|
with archive.open(archive_path, mode='w') as dest:
|
||||||
|
while True:
|
||||||
|
chunk = invoice.image.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
dest.write(chunk)
|
||||||
|
finally:
|
||||||
|
invoice.image.close()
|
||||||
|
if tmp_path.stat().st_size <= 0:
|
||||||
|
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
||||||
|
return tmp_path
|
||||||
|
except Exception:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
archive_content = archive_buffer.getvalue()
|
|
||||||
if not archive_content:
|
def _parse_year_month_params(request):
|
||||||
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
from datetime import date as _dt_date
|
||||||
return archive_content
|
|
||||||
|
raw_year = request.data.get('year') if isinstance(getattr(request, 'data', None), dict) else None
|
||||||
|
raw_month = request.data.get('month') if isinstance(getattr(request, 'data', None), dict) else None
|
||||||
|
if raw_year in (None, ''):
|
||||||
|
raw_year = request.query_params.get('year', _dt_date.today().year)
|
||||||
|
if raw_month in (None, ''):
|
||||||
|
raw_month = request.query_params.get('month', _dt_date.today().month)
|
||||||
|
|
||||||
|
try:
|
||||||
|
year = int(raw_year)
|
||||||
|
month = int(raw_month)
|
||||||
|
if not (1 <= month <= 12):
|
||||||
|
raise ValueError()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise DRFValidationError({'detail': 'Nevažeći year/month parametar.'})
|
||||||
|
return year, month
|
||||||
|
|
||||||
|
|
||||||
|
def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
||||||
|
tmp_path = _build_monthly_service_tasks_archive_to_temp_file(user=user, year=year, month=month)
|
||||||
|
try:
|
||||||
|
return tmp_path.read_bytes()
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_monthly_work_orders_archive_content(*, user, year, month):
|
||||||
|
tmp_path = _build_monthly_work_orders_archive_to_temp_file(user=user, year=year, month=month)
|
||||||
|
try:
|
||||||
|
return tmp_path.read_bytes()
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_expired_generated_archive_records():
|
def _cleanup_expired_generated_archive_records():
|
||||||
@@ -3121,6 +3129,7 @@ def _cleanup_expired_generated_archive_records():
|
|||||||
item.status = 'failed'
|
item.status = 'failed'
|
||||||
item.error_message = 'ZIP arhiva je istekla.'
|
item.error_message = 'ZIP arhiva je istekla.'
|
||||||
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||||
|
_cleanup_stale_tmp_archives()
|
||||||
|
|
||||||
|
|
||||||
def _notify_monthly_archive_request(*, user, archive_type, stage, year, month, generated_archive=None):
|
def _notify_monthly_archive_request(*, user, archive_type, stage, year, month, generated_archive=None):
|
||||||
@@ -3412,11 +3421,8 @@ def generated_archive_download(request, archive_id):
|
|||||||
raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'})
|
raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'})
|
||||||
|
|
||||||
generated_archive.file.open('rb')
|
generated_archive.file.open('rb')
|
||||||
try:
|
if generated_archive.file.size <= 0:
|
||||||
archive_bytes = generated_archive.file.read()
|
|
||||||
finally:
|
|
||||||
generated_archive.file.close()
|
generated_archive.file.close()
|
||||||
if not archive_bytes:
|
|
||||||
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
|
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
|
||||||
filename = generated_archive.filename or _generated_archive_filename_for_user(
|
filename = generated_archive.filename or _generated_archive_filename_for_user(
|
||||||
request.user,
|
request.user,
|
||||||
@@ -3424,10 +3430,9 @@ def generated_archive_download(request, archive_id):
|
|||||||
month=generated_archive.month,
|
month=generated_archive.month,
|
||||||
archive_type=generated_archive.archive_type,
|
archive_type=generated_archive.archive_type,
|
||||||
)
|
)
|
||||||
response = HttpResponse(archive_bytes, content_type='application/zip')
|
response = FileResponse(generated_archive.file, content_type='application/zip')
|
||||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||||
response['Cache-Control'] = 'private, max-age=3600'
|
response['Cache-Control'] = 'private, max-age=3600'
|
||||||
response['Content-Length'] = str(len(archive_bytes))
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -3706,18 +3711,6 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
).exclude(file='').exclude(file__isnull=True).first()
|
).exclude(file='').exclude(file__isnull=True).first()
|
||||||
if generated_pdf is None:
|
if generated_pdf is None:
|
||||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
|
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
|
||||||
file_name = getattr(generated_pdf.file, 'name', '')
|
|
||||||
storage = getattr(generated_pdf.file, 'storage', None)
|
|
||||||
if not file_name or storage is None:
|
|
||||||
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
|
||||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
|
||||||
try:
|
|
||||||
if not storage.exists(file_name):
|
|
||||||
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
|
||||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
|
||||||
except (FileNotFoundError, OSError, ValueError):
|
|
||||||
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
|
||||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
|
||||||
return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type))
|
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')
|
@action(detail=True, methods=['get'], url_path='pdf-preview')
|
||||||
|
|||||||
12
backend/scripts/cleanup_tmp_archives.sh
Normal file
12
backend/scripts/cleanup_tmp_archives.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
TARGET_DIR="${TMP_ARCHIVE_DIR:-/tmp/erp-generated-archives}"
|
||||||
|
RETENTION_DAYS="${TMP_RETENTION_DAYS:-1}"
|
||||||
|
|
||||||
|
if [ ! -d "$TARGET_DIR" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "$TARGET_DIR" -type f -name '*.zip' -mtime +"$RETENTION_DAYS" -delete
|
||||||
|
find "$TARGET_DIR" -type d -empty -delete
|
||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DEBUG: "False"
|
DEBUG: "False"
|
||||||
DJANGO_SETTINGS_MODULE: core.settings.production
|
DJANGO_SETTINGS_MODULE: core.settings.production
|
||||||
command: celery -A core worker --loglevel=info
|
command: celery -A core worker --loglevel=info --concurrency=2 --prefetch-multiplier=1 --max-tasks-per-child=20 --max-memory-per-child=300000
|
||||||
volumes:
|
volumes:
|
||||||
- media_volume:/app/media
|
- media_volume:/app/media
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ const DASHBOARD_FETCH_TTL_MS = 30_000; // 30 sekundi
|
|||||||
let dbPromise = null;
|
let dbPromise = null;
|
||||||
let syncListenerStarted = false;
|
let syncListenerStarted = false;
|
||||||
let isSyncInProgress = false;
|
let isSyncInProgress = false;
|
||||||
const archiveNotificationPollers = new Map();
|
|
||||||
const ARCHIVE_NOTIFICATION_POLL_INTERVAL_MS = 10_000;
|
|
||||||
const ARCHIVE_NOTIFICATION_POLL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
||||||
|
|
||||||
function isBrowser() {
|
function isBrowser() {
|
||||||
return typeof window !== 'undefined';
|
return typeof window !== 'undefined';
|
||||||
@@ -467,19 +464,16 @@ export const $dashboardStats = computed(
|
|||||||
const servicesToday = tasks.filter(
|
const servicesToday = tasks.filter(
|
||||||
(t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis')
|
(t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis')
|
||||||
).length;
|
).length;
|
||||||
const totalWorkOrders = workOrders.length;
|
const warnings = serviceRecords.filter((record) => {
|
||||||
// Aktivni taskovi bez dodijeljenog putnog naloga
|
if (record.next_service_due_at == null || record.mileage == null) return false;
|
||||||
const tasksWithoutWorkOrder = tasks.filter(
|
return Number(record.next_service_due_at) - Number(record.mileage) <= 1000;
|
||||||
(t) => (t.status === 'aktivan' || t.status === 'servis' || t.status === 'spreman_za_zavrsetak')
|
}).length;
|
||||||
&& !t.work_order
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openWorkOrders,
|
openWorkOrders,
|
||||||
closedWorkOrders,
|
closedWorkOrders,
|
||||||
totalWorkOrders,
|
|
||||||
servicesToday,
|
servicesToday,
|
||||||
tasksWithoutWorkOrder,
|
warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -737,12 +731,10 @@ export async function fetchTasksByWorkOrder(workOrderId) {
|
|||||||
|
|
||||||
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
return { tasks: [], additional_costs_table: null, travel_expenses_table: null };
|
return { tasks: [], additional_costs_table: null };
|
||||||
}
|
}
|
||||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
|
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
|
||||||
return payload && typeof payload === 'object'
|
return payload && typeof payload === 'object' ? payload : { tasks: [], additional_costs_table: null };
|
||||||
? payload
|
|
||||||
: { tasks: [], additional_costs_table: null, travel_expenses_table: null };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWorkOrderAdditionalCostsTable(workOrderId) {
|
export async function fetchWorkOrderAdditionalCostsTable(workOrderId) {
|
||||||
@@ -767,42 +759,6 @@ export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWorkOrderTravelExpensesTable(workOrderId) {
|
|
||||||
if (!workOrderId) {
|
|
||||||
return {
|
|
||||||
work_order: null,
|
|
||||||
broj_sati: '0.00',
|
|
||||||
kolicina_dnevnica: '0.00',
|
|
||||||
iznos_dnevnica: '30.00',
|
|
||||||
daily_rate_country: 'HR',
|
|
||||||
total_for_payout: '0.00',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/travel-expenses-table/`);
|
|
||||||
return payload && typeof payload === 'object'
|
|
||||||
? payload
|
|
||||||
: {
|
|
||||||
work_order: null,
|
|
||||||
broj_sati: '0.00',
|
|
||||||
kolicina_dnevnica: '0.00',
|
|
||||||
iznos_dnevnica: '30.00',
|
|
||||||
daily_rate_country: 'HR',
|
|
||||||
total_for_payout: '0.00',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateWorkOrderTravelExpensesTable(workOrderId, data) {
|
|
||||||
if (!workOrderId) {
|
|
||||||
throw new Error('Work order ID je obavezan.');
|
|
||||||
}
|
|
||||||
const payload = await api.put(
|
|
||||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/travel-expenses-table/`,
|
|
||||||
data ?? {},
|
|
||||||
);
|
|
||||||
showToast('Obračun putnih troškova je uspješno spremljen.', 'success');
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateTaskWorkHoursTable(taskId, data) {
|
export async function updateTaskWorkHoursTable(taskId, data) {
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
throw new Error('Task ID je obavezan.');
|
throw new Error('Task ID je obavezan.');
|
||||||
@@ -812,17 +768,6 @@ export async function updateTaskWorkHoursTable(taskId, data) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTaskServiceReportNote(taskId, note) {
|
|
||||||
if (!taskId) {
|
|
||||||
throw new Error('Task ID je obavezan.');
|
|
||||||
}
|
|
||||||
const payload = await api.patch(`tasks/tasks/${encodeURIComponent(taskId)}/`, {
|
|
||||||
service_report_note: String(note ?? ''),
|
|
||||||
});
|
|
||||||
showToast('Napomena servisnog izvještaja je uspješno spremljena.', 'success');
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
|
export async function createWorkOrderInvoice(workOrderId, data = {}, signal = null) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
@@ -872,70 +817,6 @@ function _schedulePdfNotificationPolling() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _findCompletedArchiveNotification(notifications, generatedArchiveId) {
|
|
||||||
if (!generatedArchiveId || !Array.isArray(notifications)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return notifications.find((notification) => {
|
|
||||||
const metadata = notification?.metadata || {};
|
|
||||||
return metadata.entity_type === 'fleet_archive'
|
|
||||||
&& String(metadata.generated_archive_id || '') === String(generatedArchiveId)
|
|
||||||
&& ['completed', 'failed'].includes(String(metadata.stage || ''));
|
|
||||||
}) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _clearArchiveNotificationPoller(generatedArchiveId) {
|
|
||||||
const key = String(generatedArchiveId || '');
|
|
||||||
const handles = archiveNotificationPollers.get(key);
|
|
||||||
if (!handles) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.clearInterval(handles.intervalId);
|
|
||||||
window.clearTimeout(handles.timeoutId);
|
|
||||||
archiveNotificationPollers.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _scheduleArchiveNotificationPolling(generatedArchiveId = null) {
|
|
||||||
if (!isBrowser()) return;
|
|
||||||
if (!generatedArchiveId) {
|
|
||||||
[5000, 20000, 60000].forEach((delay) => {
|
|
||||||
setTimeout(() => _refreshNotificationsAsync(), delay);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = String(generatedArchiveId);
|
|
||||||
if (archiveNotificationPollers.has(key)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pollOnce = async () => {
|
|
||||||
try {
|
|
||||||
const notificationModule = await import('./notificationStore.js');
|
|
||||||
await notificationModule.fetchNotifications();
|
|
||||||
const resolvedNotification = _findCompletedArchiveNotification(
|
|
||||||
notificationModule.$notifications.get(),
|
|
||||||
key
|
|
||||||
);
|
|
||||||
if (resolvedNotification) {
|
|
||||||
_clearArchiveNotificationPoller(key);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// silent — korisnik će i dalje vidjeti toast ili ručno osvježiti notifikacije
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
|
||||||
void pollOnce();
|
|
||||||
}, ARCHIVE_NOTIFICATION_POLL_INTERVAL_MS);
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
_clearArchiveNotificationPoller(key);
|
|
||||||
}, ARCHIVE_NOTIFICATION_POLL_TIMEOUT_MS);
|
|
||||||
|
|
||||||
archiveNotificationPollers.set(key, { intervalId, timeoutId });
|
|
||||||
void pollOnce();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadWorkOrderPdf(workOrderId) {
|
export async function downloadWorkOrderPdf(workOrderId) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
@@ -970,30 +851,10 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null, taskOptions = {}) {
|
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
}
|
}
|
||||||
if (taskId) {
|
|
||||||
try {
|
|
||||||
const blob = await api.get(
|
|
||||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/?task_id=${encodeURIComponent(taskId)}`,
|
|
||||||
{ responseType: 'blob' },
|
|
||||||
);
|
|
||||||
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
|
|
||||||
const taskTitle = String(taskOptions?.taskTitle || '').trim()
|
|
||||||
.replace(/\s+/g, '_')
|
|
||||||
.replace(/[^A-Za-z0-9_-]+/g, '')
|
|
||||||
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
|
|
||||||
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.pdf`);
|
|
||||||
showToast('PDF servisnih zapisa za odabrani task je preuzet.', 'success');
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err?.message || 'Preuzimanje PDF-a servisnih zapisa za task nije uspjelo.';
|
|
||||||
showToast(msg, 'error');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
|
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
|
||||||
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
|
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
|
||||||
if (payload?.status === 'ready' && payload?.download_url) {
|
if (payload?.status === 'ready' && payload?.download_url) {
|
||||||
@@ -1022,28 +883,14 @@ export async function downloadWorkOrderDocx(workOrderId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null, taskOptions = {}) {
|
export async function downloadWorkOrderServiceRecordsDocx(workOrderId) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
throw new Error('Work order ID je obavezan.');
|
throw new Error('Work order ID je obavezan.');
|
||||||
}
|
}
|
||||||
const query = taskId ? `?task_id=${encodeURIComponent(taskId)}` : '';
|
|
||||||
try {
|
try {
|
||||||
const blob = await api.get(
|
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' });
|
||||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/${query}`,
|
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
||||||
{ responseType: 'blob' },
|
showToast('DOCX servisnih zapisa je preuzet.', 'success');
|
||||||
);
|
|
||||||
if (taskId) {
|
|
||||||
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
|
|
||||||
const taskTitle = String(taskOptions?.taskTitle || '').trim()
|
|
||||||
.replace(/\s+/g, '_')
|
|
||||||
.replace(/[^A-Za-z0-9_-]+/g, '')
|
|
||||||
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
|
|
||||||
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.docx`);
|
|
||||||
showToast('DOCX servisnih zapisa za odabrani task je preuzet.', 'success');
|
|
||||||
} else {
|
|
||||||
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
|
||||||
showToast('DOCX servisnih zapisa je preuzet.', 'success');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
|
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
|
||||||
showToast(msg, 'error');
|
showToast(msg, 'error');
|
||||||
@@ -1125,40 +972,6 @@ export async function downloadMonthlyCostsReport(year, month) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadMonthlyServiceTasksArchive(year, month) {
|
|
||||||
try {
|
|
||||||
const payload = await api.post('fleet/reports/monthly-service-tasks-archive-request/', { year, month });
|
|
||||||
await _refreshNotificationsAsync();
|
|
||||||
if (payload?.status === 'ready' && payload?.download_url) {
|
|
||||||
showToast('ZIP arhiva servisnih taskova je spremna. Preuzmite je kroz notifikaciju.', 'success');
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
showToast('ZIP arhiva servisnih taskova se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
|
|
||||||
_scheduleArchiveNotificationPolling(payload?.generated_archive_id || null);
|
|
||||||
return payload;
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err?.message || 'Pokretanje generiranja ZIP arhive servisnih taskova nije uspjelo.', 'error');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadMonthlyWorkOrdersArchive(year, month) {
|
|
||||||
try {
|
|
||||||
const payload = await api.post('fleet/reports/monthly-work-orders-archive-request/', { year, month });
|
|
||||||
await _refreshNotificationsAsync();
|
|
||||||
if (payload?.status === 'ready' && payload?.download_url) {
|
|
||||||
showToast('ZIP arhiva putnih naloga je spremna. Preuzmite je kroz notifikaciju.', 'success');
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
showToast('ZIP arhiva putnih naloga se generira u pozadini. Preuzimanje je dostupno kroz notifikaciju.', 'info');
|
|
||||||
_scheduleArchiveNotificationPolling(payload?.generated_archive_id || null);
|
|
||||||
return payload;
|
|
||||||
} catch (err) {
|
|
||||||
showToast(err?.message || 'Pokretanje generiranja ZIP arhive putnih naloga nije uspjelo.', 'error');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
|
export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'document.pdf') {
|
||||||
if (!downloadUrl) {
|
if (!downloadUrl) {
|
||||||
showToast('Nedostaje URL za preuzimanje PDF-a.', 'error');
|
showToast('Nedostaje URL za preuzimanje PDF-a.', 'error');
|
||||||
@@ -1175,22 +988,6 @@ export async function downloadGeneratedPdfByUrl(downloadUrl, filename = 'documen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadGeneratedArchiveByUrl(downloadUrl, filename = 'archive.zip') {
|
|
||||||
if (!downloadUrl) {
|
|
||||||
showToast('Nedostaje URL za preuzimanje ZIP arhive.', 'error');
|
|
||||||
throw new Error('Nedostaje URL za preuzimanje ZIP arhive.');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const blob = await api.get(downloadUrl, { responseType: 'blob' });
|
|
||||||
saveBlobToFile(blob, filename);
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err?.message || 'Preuzimanje ZIP arhive nije uspjelo.';
|
|
||||||
showToast(msg, 'error');
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openWorkOrderInvoicesPdfPage(workOrderId) {
|
export function openWorkOrderInvoicesPdfPage(workOrderId) {
|
||||||
if (!isBrowser() || !workOrderId) {
|
if (!isBrowser() || !workOrderId) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user