Refactor fleet module and add cleanup script
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

- 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:
mariomitte
2026-09-05 08:35:39 +02:00
parent b985c285f9
commit b6fe8d8969
7 changed files with 227 additions and 377 deletions

View File

@@ -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')

View File

@@ -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),

View File

@@ -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()

View File

@@ -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 1220 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')

View 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

View File

@@ -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:

View File

@@ -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;