From b6fe8d8969b914c8bc370df5b4ecc7a31ae4f8fd Mon Sep 17 00:00:00 2001 From: mariomitte Date: Sat, 5 Sep 2026 08:35:39 +0200 Subject: [PATCH] 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 --- backend/core/celery.py | 22 ++ backend/core/settings/base.py | 12 + backend/modules/fleet/tasks.py | 30 ++- backend/modules/fleet/views.py | 299 ++++++++++----------- backend/scripts/cleanup_tmp_archives.sh | 12 + docker-compose.prod.yml | 2 +- frontend/src/stores/fleetDashboardStore.js | 227 +--------------- 7 files changed, 227 insertions(+), 377 deletions(-) create mode 100644 backend/scripts/cleanup_tmp_archives.sh diff --git a/backend/core/celery.py b/backend/core/celery.py index 4af78a7..2c6ac50 100644 --- a/backend/core/celery.py +++ b/backend/core/celery.py @@ -2,11 +2,33 @@ import os from celery import Celery +from celery import Task +import gc +import logging # Postavi Django settings modul os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') 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_' app.config_from_object('django.conf:settings', namespace='CELERY') diff --git a/backend/core/settings/base.py b/backend/core/settings/base.py index 8a72846..8dc8f81 100644 --- a/backend/core/settings/base.py +++ b/backend/core/settings/base.py @@ -154,6 +154,10 @@ REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0') CELERY_BROKER_URL = 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) # 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', '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': { 'task': 'modules.task_management.tasks.notify_upcoming_tasks', 'schedule': crontab(hour=8, minute=0), diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py index 5ee21d8..edd5575 100644 --- a/backend/modules/fleet/tasks.py +++ b/backend/modules/fleet/tasks.py @@ -1,9 +1,11 @@ # backend/modules/fleet/tasks.py from celery import shared_task +from core.celery import ResourceAwareTask from django.core.mail import send_mail from django.core.mail import EmailMessage from django.conf import settings from django.utils import timezone +from django.core.files import File from django.core.files.base import ContentFile import logging from io import BytesIO @@ -835,13 +837,12 @@ def process_work_order_invoice_ocr(invoice_id): return {"status": "ok", "invoice_id": str(invoice.pk)} -@shared_task +@shared_task(base=ResourceAwareTask) def build_monthly_archive_cached_task(generated_archive_id): from .models import GeneratedFleetArchive - from .services import NotificationService from .views import ( - _build_monthly_service_tasks_archive_content, - _build_monthly_work_orders_archive_content, + _build_monthly_service_tasks_archive_to_temp_file, + _build_monthly_work_orders_archive_to_temp_file, _notify_monthly_archive_request, ) @@ -854,25 +855,27 @@ def build_monthly_archive_cached_task(generated_archive_id): if generated is None: return {"status": "failed", "error": "Generated archive record not found"} + tmp_archive_path = None try: 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, year=generated.year, month=generated.month, ) else: - archive_content = _build_monthly_work_orders_archive_content( + tmp_archive_path = _build_monthly_work_orders_archive_to_temp_file( user=generated.requested_by, year=generated.year, 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.') 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.error_message = '' 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) return {"status": "failed", "error": str(exc)} + finally: + if tmp_archive_path is not None: + tmp_archive_path.unlink(missing_ok=True) @shared_task @@ -924,6 +930,14 @@ def cleanup_expired_generated_archives_task(): 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 def cleanup_expired_generated_pdfs_task(): now = timezone.now() diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 24e77f7..1e10e91 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -5,6 +5,7 @@ from io import StringIO import base64 import csv import mimetypes +import tempfile import threading import zipfile import re @@ -14,7 +15,7 @@ from pathlib import Path from decimal import Decimal, InvalidOperation 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.core.mail import EmailMessage 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, task_id__isnull=False, task__is_active=True, - task__work_order=work_order, ).values_list('task_id', flat=True) ) 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') -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): related_tasks = list(_work_order_related_tasks_queryset(work_order)) trip_entries = [] @@ -327,16 +319,8 @@ def _work_order_travel_expenses_context(work_order): if trip_entries: entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')] - start_candidates = [ - _normalize_datetime_for_compare(entry.get('start_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') - ] + start_candidates = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')] + end_candidates = [entry['end_dt'] for entry in trip_entries if entry.get('end_dt')] if entry_dates: trip_start_date = min(entry_dates) trip_end_date = max(entry_dates) @@ -400,6 +384,8 @@ def _parse_format(value): GENERATED_PDF_TTL_HOURS = 24 GENERATED_ARCHIVE_TTL_DAYS = 7 +TMP_ARCHIVE_RETENTION_HOURS = 24 +TMP_ARCHIVE_DIRNAME = 'erp-generated-archives' 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 -_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=800, quality=75): +def _compress_image_for_pdf(image_field, max_width=1280, quality=75): """ Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji, 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: image_field.open('rb') - with Image.open(image_field) as img: - img = ImageOps.exif_transpose(img) - w, h = img.size - if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000: - return None - img.thumbnail((max_width, max_width * 2), Image.BILINEAR) - rgb = img.convert('RGB') + with Image.open(image_field) as src: + img = src.convert('RGB') + if img.width > max_width: + ratio = max_width / float(img.width) + new_h = max(1, int(img.height * ratio)) + img = img.resize((max_width, new_h), Image.LANCZOS) buf = BytesIO() - rgb.save(buf, format='JPEG', quality=quality) + img.save(buf, format='JPEG', quality=quality, optimize=True) buf.seek(0) return ImageReader(buf) - except BaseException: + except Exception: return None finally: try: @@ -498,28 +476,23 @@ def _compress_image_for_pdf(image_field, max_width=800, quality=75): 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. - - 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: image_field.open('rb') - with Image.open(image_field) as img: - img = ImageOps.exif_transpose(img) - w, h = img.size - if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000: - return None - img.thumbnail((max_width, max_width * 2), Image.BILINEAR) - rgb = img.convert('RGB') + with Image.open(image_field) as src: + img = src.convert('RGB') + if img.width > max_width: + ratio = max_width / float(img.width) + new_h = max(1, int(img.height * ratio)) + img = img.resize((max_width, new_h), Image.LANCZOS) buf = BytesIO() - rgb.save(buf, format='JPEG', quality=quality) + img.save(buf, format='JPEG', quality=quality, optimize=True) buf.seek(0) return buf - except BaseException: + except Exception: return None finally: try: @@ -546,30 +519,10 @@ def _get_cached_pdf(work_order, pdf_type): ) for candidate in candidates: if str(candidate.filename or '').strip() == expected_filename: - file_name = getattr(candidate.file, 'name', '') - 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 candidate 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(): now = timezone.now() expired = GeneratedWorkOrderPdf.objects.filter( @@ -580,11 +533,10 @@ def _cleanup_expired_generated_pdfs(): for item in expired: if item.file: item.file.delete(save=False) - item.file = None item.is_active = False item.status = 'failed' 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): @@ -607,11 +559,10 @@ def _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None): for cached in cache_qs: if cached.file: cached.file.delete(save=False) - cached.file = None cached.is_active = False cached.status = 'failed' 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): @@ -643,11 +594,7 @@ def _upsert_additional_cost_row_from_invoice(invoice): def _cached_pdf_file_response(generated_pdf, *, default_filename): - try: - 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."}) + 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}"' @@ -2988,27 +2935,40 @@ def _unique_zip_entry_name(entry_name, used_names): return candidate -def _parse_year_month_params(request): - from datetime import date as _dt_date - - 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 _tmp_archive_dir(): + return Path(tempfile.gettempdir()) / TMP_ARCHIVE_DIRNAME -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 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.'}) used_names = set() - archive_buffer = BytesIO() - with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive: - for task in tasks: - work_order = task.work_order - if work_order is None: - continue - docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task]) - base_name = _service_records_docx_filename(work_order, task) - entry_name = _unique_zip_entry_name(base_name, used_names) - archive.writestr(entry_name, docx_bytes) - - archive_content = archive_buffer.getvalue() - if not archive_content: - raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) - return archive_content + tmp_path = _create_tmp_archive_path(prefix='monthly-service-tasks-') + try: + with zipfile.ZipFile(str(tmp_path), mode='w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive: + for task in tasks: + work_order = task.work_order + if work_order is None: + continue + docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task]) + base_name = _service_records_docx_filename(work_order, task) + entry_name = _unique_zip_entry_name(base_name, used_names) + archive.writestr(entry_name, docx_bytes) + 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 -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 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.'}) used_names = set() - archive_buffer = BytesIO() - with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive: - for work_order in work_orders: - pdf_bytes = _build_work_order_pdf(work_order) - work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names) - archive.writestr(work_order_pdf_name, pdf_bytes) + tmp_path = _create_tmp_archive_path(prefix='monthly-work-orders-') + try: + with zipfile.ZipFile(str(tmp_path), mode='w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive: + for work_order in work_orders: + pdf_bytes = _build_work_order_pdf(work_order) + 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) - invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at') - for index, invoice in enumerate(invoices, start=1): - attachment = _file_attachment(invoice.image, fallback_name=f"invoice-{index}.bin") - if not attachment: - continue - invoice_filename, content, _content_type = attachment - archive_path = f"Racuni/{display_code}/{invoice_filename}" - archive.writestr(_unique_zip_entry_name(archive_path, used_names), content) + display_code = _work_order_display_code(work_order) + invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at') + for index, invoice in enumerate(invoices, start=1): + if not invoice.image: + continue + invoice_filename = Path(str(getattr(invoice.image, 'name', '') or f"invoice-{index}.bin")).name + archive_path = _unique_zip_entry_name(f"Racuni/{display_code}/{invoice_filename}", used_names) + try: + 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: - raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) - return archive_content + +def _parse_year_month_params(request): + from datetime import date as _dt_date + + 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(): @@ -3121,6 +3129,7 @@ def _cleanup_expired_generated_archive_records(): item.status = 'failed' item.error_message = 'ZIP arhiva je istekla.' 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): @@ -3412,11 +3421,8 @@ def generated_archive_download(request, archive_id): raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'}) generated_archive.file.open('rb') - try: - archive_bytes = generated_archive.file.read() - finally: + if generated_archive.file.size <= 0: generated_archive.file.close() - if not archive_bytes: raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'}) filename = generated_archive.filename or _generated_archive_filename_for_user( request.user, @@ -3424,10 +3430,9 @@ def generated_archive_download(request, archive_id): month=generated_archive.month, 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['Cache-Control'] = 'private, max-age=3600' - response['Content-Length'] = str(len(archive_bytes)) return response @@ -3706,18 +3711,6 @@ class WorkOrderViewSet(viewsets.ModelViewSet): ).exclude(file='').exclude(file__isnull=True).first() if generated_pdf is None: 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)) @action(detail=True, methods=['get'], url_path='pdf-preview') diff --git a/backend/scripts/cleanup_tmp_archives.sh b/backend/scripts/cleanup_tmp_archives.sh new file mode 100644 index 0000000..227bb52 --- /dev/null +++ b/backend/scripts/cleanup_tmp_archives.sh @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 03e110e..9fa51ce 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -37,7 +37,7 @@ services: environment: DEBUG: "False" 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: - media_volume:/app/media depends_on: diff --git a/frontend/src/stores/fleetDashboardStore.js b/frontend/src/stores/fleetDashboardStore.js index 9e261a5..12f729e 100644 --- a/frontend/src/stores/fleetDashboardStore.js +++ b/frontend/src/stores/fleetDashboardStore.js @@ -28,9 +28,6 @@ const DASHBOARD_FETCH_TTL_MS = 30_000; // 30 sekundi let dbPromise = null; let syncListenerStarted = 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() { return typeof window !== 'undefined'; @@ -467,19 +464,16 @@ export const $dashboardStats = computed( const servicesToday = tasks.filter( (t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis') ).length; - const totalWorkOrders = workOrders.length; - // Aktivni taskovi bez dodijeljenog putnog naloga - const tasksWithoutWorkOrder = tasks.filter( - (t) => (t.status === 'aktivan' || t.status === 'servis' || t.status === 'spreman_za_zavrsetak') - && !t.work_order - ).length; + const warnings = serviceRecords.filter((record) => { + if (record.next_service_due_at == null || record.mileage == null) return false; + return Number(record.next_service_due_at) - Number(record.mileage) <= 1000; + }).length; return { openWorkOrders, closedWorkOrders, - totalWorkOrders, servicesToday, - tasksWithoutWorkOrder, + warnings, }; } ); @@ -737,12 +731,10 @@ export async function fetchTasksByWorkOrder(workOrderId) { export async function fetchWorkOrderTaskServiceContext(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/`); - return payload && typeof payload === 'object' - ? payload - : { tasks: [], additional_costs_table: null, travel_expenses_table: null }; + return payload && typeof payload === 'object' ? payload : { tasks: [], additional_costs_table: null }; } export async function fetchWorkOrderAdditionalCostsTable(workOrderId) { @@ -767,42 +759,6 @@ export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) { 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) { if (!taskId) { throw new Error('Task ID je obavezan.'); @@ -812,17 +768,6 @@ export async function updateTaskWorkHoursTable(taskId, data) { 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) { if (!workOrderId) { 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) { if (!workOrderId) { throw new Error('Work order ID je obavezan.'); @@ -970,30 +851,10 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) { return payload; } -export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null, taskOptions = {}) { +export async function downloadWorkOrderServiceRecordsPdf(workOrderId) { if (!workOrderId) { 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'); const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {}); 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) { throw new Error('Work order ID je obavezan.'); } - const query = taskId ? `?task_id=${encodeURIComponent(taskId)}` : ''; try { - const blob = await api.get( - `fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/${query}`, - { responseType: 'blob' }, - ); - 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'); - } + const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' }); + saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`); + showToast('DOCX servisnih zapisa je preuzet.', 'success'); } catch (err) { const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.'; 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') { if (!downloadUrl) { 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) { if (!isBrowser() || !workOrderId) { return;