Compare commits
1 Commits
main
...
agents/out
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61bc772054 |
@@ -1,37 +1,57 @@
|
|||||||
# backend/core/celery.py
|
# backend/core/celery.py
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from celery import Celery
|
|
||||||
from celery import Task
|
|
||||||
import gc
|
import gc
|
||||||
import logging
|
import logging
|
||||||
|
from celery import Celery
|
||||||
|
from celery import Task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 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')
|
||||||
|
|
||||||
|
|
||||||
|
def _rss_memory_mb():
|
||||||
|
try:
|
||||||
|
import resource
|
||||||
|
|
||||||
|
usage_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||||
|
# Na Linuxu je ru_maxrss u KiB.
|
||||||
|
return round(float(usage_kb) / 1024.0, 2)
|
||||||
|
except (ImportError, AttributeError, OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryAwareTask(Task):
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
gc.collect()
|
||||||
|
return super().__call__(*args, **kwargs)
|
||||||
|
|
||||||
|
def after_return(self, status, retval, task_id, args, kwargs, einfo):
|
||||||
|
memory_before_gc = _rss_memory_mb()
|
||||||
|
gc.collect()
|
||||||
|
memory_after_gc = _rss_memory_mb()
|
||||||
|
if memory_before_gc is not None and memory_after_gc is not None:
|
||||||
|
logger.info(
|
||||||
|
"Task %s (%s) status=%s RSS prije/poslije GC: %.2fMB -> %.2fMB",
|
||||||
|
self.name,
|
||||||
|
task_id,
|
||||||
|
status,
|
||||||
|
memory_before_gc,
|
||||||
|
memory_after_gc,
|
||||||
|
)
|
||||||
|
return super().after_return(status, retval, task_id, args, kwargs, einfo)
|
||||||
|
|
||||||
|
|
||||||
|
app.Task = MemoryAwareTask
|
||||||
|
|
||||||
# Automatski pronalazi taskove u svim 'modules.*'
|
# Automatski pronalazi taskove u svim 'modules.*'
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
@@ -154,10 +154,11 @@ 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_WORKER_PREFETCH_MULTIPLIER = int(os.environ.get('CELERY_WORKER_PREFETCH_MULTIPLIER', '1'))
|
||||||
|
CELERY_WORKER_MAX_TASKS_PER_CHILD = int(os.environ.get('CELERY_WORKER_MAX_TASKS_PER_CHILD', '20'))
|
||||||
|
CELERY_WORKER_MAX_MEMORY_PER_CHILD = int(os.environ.get('CELERY_WORKER_MAX_MEMORY_PER_CHILD', '350000'))
|
||||||
CELERY_TASK_ACKS_LATE = True
|
CELERY_TASK_ACKS_LATE = True
|
||||||
CELERY_WORKER_MAX_TASKS_PER_CHILD = 20
|
CELERY_TASK_REJECT_ON_WORKER_LOST = True
|
||||||
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,17 +176,17 @@ CACHES = {
|
|||||||
CELERY_ACCEPT_CONTENT = ['json']
|
CELERY_ACCEPT_CONTENT = ['json']
|
||||||
CELERY_TASK_SERIALIZER = 'json'
|
CELERY_TASK_SERIALIZER = 'json'
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
'cleanup-expired-generated-archives-hourly': {
|
||||||
|
'task': 'modules.fleet.tasks.cleanup_expired_generated_archives_task',
|
||||||
|
'schedule': crontab(minute=5),
|
||||||
|
},
|
||||||
'cleanup-expired-generated-pdfs-hourly': {
|
'cleanup-expired-generated-pdfs-hourly': {
|
||||||
'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': {
|
'cleanup-app-tmp-daily': {
|
||||||
'task': 'modules.fleet.tasks.cleanup_expired_generated_archives_task',
|
'task': 'modules.fleet.tasks.cleanup_tmp_files_task',
|
||||||
'schedule': crontab(minute=10),
|
'schedule': crontab(hour=3, minute=15),
|
||||||
},
|
|
||||||
'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',
|
||||||
@@ -202,6 +203,13 @@ STATIC_URL = '/static/'
|
|||||||
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
|
# (Opcionalno, ali preporučeno za ERP) Ako koristiš i medije (dokumente)
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = '/media/'
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
APP_TMP_CLEANUP_DIR = os.environ.get('APP_TMP_CLEANUP_DIR', '/tmp')
|
||||||
|
APP_TMP_CLEANUP_MAX_AGE_HOURS = int(os.environ.get('APP_TMP_CLEANUP_MAX_AGE_HOURS', '24'))
|
||||||
|
APP_TMP_CLEANUP_PREFIXES = [
|
||||||
|
prefix.strip()
|
||||||
|
for prefix in os.environ.get('APP_TMP_CLEANUP_PREFIXES', 'erp-fleet-archive-').split(',')
|
||||||
|
if prefix.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
@@ -1,12 +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
|
||||||
|
from django.core.files import File
|
||||||
import logging
|
import logging
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
@@ -15,6 +14,9 @@ import base64
|
|||||||
import re
|
import re
|
||||||
import csv
|
import csv
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from smtplib import SMTPSenderRefused
|
from smtplib import SMTPSenderRefused
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -837,12 +839,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(base=ResourceAwareTask)
|
@shared_task
|
||||||
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 .views import (
|
from .views import (
|
||||||
_build_monthly_service_tasks_archive_to_temp_file,
|
_write_monthly_service_tasks_archive_entries,
|
||||||
_build_monthly_work_orders_archive_to_temp_file,
|
_write_monthly_work_orders_archive_entries,
|
||||||
_notify_monthly_archive_request,
|
_notify_monthly_archive_request,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -855,27 +857,39 @@ 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
|
tmp_dir = str(getattr(settings, 'APP_TMP_CLEANUP_DIR', '/tmp'))
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode='w+b',
|
||||||
|
delete=False,
|
||||||
|
dir=tmp_dir if os.path.isdir(tmp_dir) else None,
|
||||||
|
prefix='erp-fleet-archive-',
|
||||||
|
suffix='.zip',
|
||||||
|
) as temp_zip:
|
||||||
|
temp_zip_path = temp_zip.name
|
||||||
try:
|
try:
|
||||||
|
with zipfile.ZipFile(temp_zip_path, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
if generated.archive_type == 'service_tasks':
|
if generated.archive_type == 'service_tasks':
|
||||||
tmp_archive_path = _build_monthly_service_tasks_archive_to_temp_file(
|
entries_written = _write_monthly_service_tasks_archive_entries(
|
||||||
|
archive,
|
||||||
user=generated.requested_by,
|
user=generated.requested_by,
|
||||||
year=generated.year,
|
year=generated.year,
|
||||||
month=generated.month,
|
month=generated.month,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
tmp_archive_path = _build_monthly_work_orders_archive_to_temp_file(
|
entries_written = _write_monthly_work_orders_archive_entries(
|
||||||
|
archive,
|
||||||
user=generated.requested_by,
|
user=generated.requested_by,
|
||||||
year=generated.year,
|
year=generated.year,
|
||||||
month=generated.month,
|
month=generated.month,
|
||||||
)
|
)
|
||||||
|
if entries_written == 0:
|
||||||
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.')
|
||||||
|
|
||||||
|
if os.path.getsize(temp_zip_path) <= 0:
|
||||||
|
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"
|
||||||
with tmp_archive_path.open('rb') as temp_handle:
|
with open(temp_zip_path, 'rb') as temp_file:
|
||||||
generated.file.save(filename, File(temp_handle), save=False)
|
generated.file.save(filename, File(temp_file), 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'])
|
||||||
@@ -906,8 +920,11 @@ 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:
|
finally:
|
||||||
if tmp_archive_path is not None:
|
try:
|
||||||
tmp_archive_path.unlink(missing_ok=True)
|
if os.path.exists(temp_zip_path):
|
||||||
|
os.remove(temp_zip_path)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Ne mogu obrisati privremenu ZIP datoteku: %s", temp_zip_path)
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
@@ -930,14 +947,6 @@ 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()
|
||||||
@@ -958,6 +967,46 @@ def cleanup_expired_generated_pdfs_task():
|
|||||||
return {"deleted": deleted}
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_tmp_files_task():
|
||||||
|
tmp_dir = str(getattr(settings, 'APP_TMP_CLEANUP_DIR', '/tmp'))
|
||||||
|
max_age_hours = int(getattr(settings, 'APP_TMP_CLEANUP_MAX_AGE_HOURS', 24))
|
||||||
|
prefixes = tuple(getattr(settings, 'APP_TMP_CLEANUP_PREFIXES', ['erp-fleet-archive-']))
|
||||||
|
|
||||||
|
if max_age_hours <= 0:
|
||||||
|
raise ValueError('APP_TMP_CLEANUP_MAX_AGE_HOURS mora biti > 0.')
|
||||||
|
if not prefixes:
|
||||||
|
raise ValueError('APP_TMP_CLEANUP_PREFIXES ne smije biti prazan.')
|
||||||
|
if not os.path.isdir(tmp_dir):
|
||||||
|
return {'deleted': 0, 'tmp_dir': tmp_dir, 'reason': 'tmp-dir-not-found'}
|
||||||
|
|
||||||
|
now = timezone.now().timestamp()
|
||||||
|
cutoff = now - (max_age_hours * 3600)
|
||||||
|
deleted = 0
|
||||||
|
|
||||||
|
for name in os.listdir(tmp_dir):
|
||||||
|
if not any(name.startswith(prefix) for prefix in prefixes):
|
||||||
|
continue
|
||||||
|
full_path = os.path.join(tmp_dir, name)
|
||||||
|
try:
|
||||||
|
stat_info = os.stat(full_path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
if stat_info.st_mtime > cutoff:
|
||||||
|
continue
|
||||||
|
if os.path.isdir(full_path):
|
||||||
|
try:
|
||||||
|
os.rmdir(full_path)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("Preskačem %s: direktorij nije prazan ili je nedostupan.", full_path)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
os.remove(full_path)
|
||||||
|
deleted += 1
|
||||||
|
|
||||||
|
return {'deleted': deleted, 'tmp_dir': tmp_dir, 'max_age_hours': max_age_hours}
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def build_work_order_pdf_cached_task(generated_pdf_id):
|
def build_work_order_pdf_cached_task(generated_pdf_id):
|
||||||
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
|
from .views import _build_work_order_pdf, _build_work_order_service_records_pdf
|
||||||
|
|||||||
@@ -497,67 +497,6 @@ class WorkOrderImagesEndpointTests(TestCase):
|
|||||||
self.assertIn('MT170726', text)
|
self.assertIn('MT170726', text)
|
||||||
self.assertNotIn('MT150726', text)
|
self.assertNotIn('MT150726', text)
|
||||||
|
|
||||||
def test_service_records_docx_includes_work_and_travel_total_row(self):
|
|
||||||
TaskWorkHoursTable.objects.create(
|
|
||||||
task=self.task,
|
|
||||||
data={
|
|
||||||
'rows': [{
|
|
||||||
'day': 'PON',
|
|
||||||
'date': '01.12.2033',
|
|
||||||
'work_time_from': '08:00',
|
|
||||||
'work_time_to': '12:30',
|
|
||||||
'travel_time_from': '07:00',
|
|
||||||
'travel_time_to': '08:00',
|
|
||||||
'break_hours': '0,5',
|
|
||||||
'work_hours': '4,0',
|
|
||||||
'travel_hours': '1,0',
|
|
||||||
'departure_place': 'Zagreb',
|
|
||||||
'arrival_place': 'Split',
|
|
||||||
'vehicle_km': '120',
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}")
|
|
||||||
self.assertEqual(response.status_code, 200, response.content)
|
|
||||||
|
|
||||||
archive = zipfile.ZipFile(BytesIO(response.content))
|
|
||||||
document_xml = archive.read('word/document.xml').decode('utf-8', errors='ignore')
|
|
||||||
self.assertIn('Ukupno', document_xml)
|
|
||||||
self.assertIn('4,0', document_xml)
|
|
||||||
self.assertIn('1,0', document_xml)
|
|
||||||
|
|
||||||
def test_service_records_pdf_includes_work_and_travel_total_row(self):
|
|
||||||
TaskWorkHoursTable.objects.create(
|
|
||||||
task=self.task,
|
|
||||||
data={
|
|
||||||
'rows': [{
|
|
||||||
'day': 'PON',
|
|
||||||
'date': '01.12.2033',
|
|
||||||
'work_time_from': '08:00',
|
|
||||||
'work_time_to': '12:30',
|
|
||||||
'travel_time_from': '07:00',
|
|
||||||
'travel_time_to': '08:00',
|
|
||||||
'break_hours': '0,5',
|
|
||||||
'work_hours': '4,0',
|
|
||||||
'travel_hours': '1,0',
|
|
||||||
'departure_place': 'Zagreb',
|
|
||||||
'arrival_place': 'Split',
|
|
||||||
'vehicle_km': '120',
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-pdf/?task_id={self.task.pk}")
|
|
||||||
self.assertEqual(response.status_code, 200, response.content)
|
|
||||||
self.assertEqual(response['Content-Type'], 'application/pdf')
|
|
||||||
|
|
||||||
reader = PdfReader(BytesIO(response.content))
|
|
||||||
text = "\n".join((page.extract_text() or '') for page in reader.pages)
|
|
||||||
self.assertIn('UKUPNO', text)
|
|
||||||
self.assertIn('4,0', text)
|
|
||||||
self.assertIn('1,0', text)
|
|
||||||
|
|
||||||
def test_monthly_service_tasks_archive_returns_zip_with_task_docx(self):
|
def test_monthly_service_tasks_archive_returns_zip_with_task_docx(self):
|
||||||
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
|
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
|
||||||
self.assertEqual(response.status_code, 200, response.content)
|
self.assertEqual(response.status_code, 200, response.content)
|
||||||
@@ -639,40 +578,6 @@ class WorkOrderImagesEndpointTests(TestCase):
|
|||||||
self.assertNotIn('Liebherr LTM 1090', second_header)
|
self.assertNotIn('Liebherr LTM 1090', second_header)
|
||||||
self.assertNotIn('MT150726', second_header)
|
self.assertNotIn('MT150726', second_header)
|
||||||
|
|
||||||
def test_monthly_work_orders_archive_includes_work_orders_for_user_even_without_assigned_task(self):
|
|
||||||
other_user = get_user_model().objects.create_user(
|
|
||||||
username=f'wo-other-{uuid.uuid4().hex[:8]}',
|
|
||||||
email=f'wo-other-{uuid.uuid4().hex[:8]}@example.test',
|
|
||||||
password='test1234',
|
|
||||||
)
|
|
||||||
linked_work_order = WorkOrder.objects.create(
|
|
||||||
vehicle=self.vehicle,
|
|
||||||
creator=self.user,
|
|
||||||
display_code='MT170726',
|
|
||||||
purpose='kontrola',
|
|
||||||
)
|
|
||||||
Task.objects.create(
|
|
||||||
title='Zadatak drugog servisera',
|
|
||||||
assigned_to=other_user,
|
|
||||||
vehicle=self.vehicle,
|
|
||||||
work_order=linked_work_order,
|
|
||||||
scheduled_date=date(2033, 12, 20),
|
|
||||||
)
|
|
||||||
WorkOrderInvoice.objects.create(
|
|
||||||
work_order=linked_work_order,
|
|
||||||
naziv_racuna='Prosinac račun osoba',
|
|
||||||
datum='2033-12-12',
|
|
||||||
image=create_test_pdf('racun-prosinac-osoba.pdf'),
|
|
||||||
created_by=self.user,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get('/api/fleet/reports/monthly-work-orders-archive/?year=2033&month=12')
|
|
||||||
self.assertEqual(response.status_code, 200, response.content)
|
|
||||||
|
|
||||||
archive = zipfile.ZipFile(BytesIO(response.content))
|
|
||||||
self.assertIn('MT150726.work-order.pdf', archive.namelist())
|
|
||||||
self.assertIn('MT170726.work-order.pdf', archive.namelist())
|
|
||||||
|
|
||||||
def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self):
|
def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self):
|
||||||
WorkOrderInvoice.objects.create(
|
WorkOrderInvoice.objects.create(
|
||||||
work_order=self.work_order,
|
work_order=self.work_order,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -15,7 +14,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, UnidentifiedImageError
|
from PIL import Image, ImageOps, 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
|
||||||
@@ -156,6 +155,7 @@ 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,6 +305,14 @@ 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 = []
|
||||||
@@ -319,8 +327,16 @@ 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 = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')]
|
start_candidates = [
|
||||||
end_candidates = [entry['end_dt'] for entry in trip_entries if entry.get('end_dt')]
|
_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')
|
||||||
|
]
|
||||||
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)
|
||||||
@@ -448,24 +464,32 @@ def _resolve_service_report_tasks(work_order, task_id):
|
|||||||
return [selected_task], selected_task
|
return [selected_task], selected_task
|
||||||
|
|
||||||
|
|
||||||
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
_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):
|
||||||
"""
|
"""
|
||||||
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 src:
|
with Image.open(image_field) as img:
|
||||||
img = src.convert('RGB')
|
img = ImageOps.exif_transpose(img)
|
||||||
if img.width > max_width:
|
w, h = img.size
|
||||||
ratio = max_width / float(img.width)
|
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
||||||
new_h = max(1, int(img.height * ratio))
|
return None
|
||||||
img = img.resize((max_width, new_h), Image.LANCZOS)
|
img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
|
||||||
|
rgb = img.convert('RGB')
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
rgb.save(buf, format='JPEG', quality=quality)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return ImageReader(buf)
|
return ImageReader(buf)
|
||||||
except Exception:
|
except BaseException:
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -474,23 +498,28 @@ def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _compress_image_for_docx(image_field, max_width=1600, quality=80):
|
def _compress_image_for_docx(image_field, max_width=800, 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 src:
|
with Image.open(image_field) as img:
|
||||||
img = src.convert('RGB')
|
img = ImageOps.exif_transpose(img)
|
||||||
if img.width > max_width:
|
w, h = img.size
|
||||||
ratio = max_width / float(img.width)
|
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
||||||
new_h = max(1, int(img.height * ratio))
|
return None
|
||||||
img = img.resize((max_width, new_h), Image.LANCZOS)
|
img.thumbnail((max_width, max_width * 2), Image.BILINEAR)
|
||||||
|
rgb = img.convert('RGB')
|
||||||
buf = BytesIO()
|
buf = BytesIO()
|
||||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
rgb.save(buf, format='JPEG', quality=quality)
|
||||||
buf.seek(0)
|
buf.seek(0)
|
||||||
return buf
|
return buf
|
||||||
except Exception:
|
except BaseException:
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -517,10 +546,30 @@ 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', '')
|
||||||
|
storage = getattr(candidate.file, 'storage', None)
|
||||||
|
if file_name and storage:
|
||||||
|
try:
|
||||||
|
if storage.exists(file_name):
|
||||||
return candidate
|
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(
|
||||||
@@ -531,10 +580,11 @@ 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=['is_active', 'status', 'error_message', 'updated_at'])
|
item.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
||||||
|
|
||||||
|
|
||||||
def _parse_amount_decimal(value):
|
def _parse_amount_decimal(value):
|
||||||
@@ -557,10 +607,11 @@ 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=['is_active', 'status', 'error_message', 'updated_at'])
|
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
||||||
|
|
||||||
|
|
||||||
def _upsert_additional_cost_row_from_invoice(invoice):
|
def _upsert_additional_cost_row_from_invoice(invoice):
|
||||||
@@ -592,7 +643,11 @@ 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}"'
|
||||||
@@ -1335,7 +1390,6 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
|
|||||||
"Kilometri\nvozila",
|
"Kilometri\nvozila",
|
||||||
]
|
]
|
||||||
table3_rows = [table3_headers]
|
table3_rows = [table3_headers]
|
||||||
total_hours_work = 0.0
|
|
||||||
total_hours_travel = 0.0
|
total_hours_travel = 0.0
|
||||||
total_vehicle_km = 0.0
|
total_vehicle_km = 0.0
|
||||||
|
|
||||||
@@ -1352,24 +1406,13 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
|
|||||||
row['places'],
|
row['places'],
|
||||||
row['vehicle_km'],
|
row['vehicle_km'],
|
||||||
])
|
])
|
||||||
total_hours_work += _parse_decimal(row['work_hours'])
|
|
||||||
total_hours_travel += _parse_decimal(row['travel_hours'])
|
total_hours_travel += _parse_decimal(row['travel_hours'])
|
||||||
total_vehicle_km += _parse_decimal(row['vehicle_km'])
|
total_vehicle_km += _parse_decimal(row['vehicle_km'])
|
||||||
else:
|
else:
|
||||||
for _ in range(12):
|
for _ in range(12):
|
||||||
table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"])
|
table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"])
|
||||||
|
|
||||||
table3_rows.append([
|
table3_rows.append(["UKUPNO", "", "", "", "", "", _format_decimal(total_hours_travel), "", str(int(total_vehicle_km) if total_vehicle_km.is_integer() else total_vehicle_km).replace('.', ',')])
|
||||||
"UKUPNO",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
"",
|
|
||||||
_format_decimal(total_hours_work),
|
|
||||||
_format_decimal(total_hours_travel),
|
|
||||||
"",
|
|
||||||
str(int(total_vehicle_km) if total_vehicle_km.is_integer() else total_vehicle_km).replace('.', ','),
|
|
||||||
])
|
|
||||||
y = draw_table(
|
y = draw_table(
|
||||||
y,
|
y,
|
||||||
table3_rows,
|
table3_rows,
|
||||||
@@ -1381,11 +1424,15 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
|
|||||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||||
('ALIGN', (0, 0), (0, -1), 'CENTER'),
|
('ALIGN', (0, 0), (0, -1), 'CENTER'),
|
||||||
('ALIGN', (1, 0), (8, -1), 'CENTER'),
|
('ALIGN', (1, 0), (6, -1), 'CENTER'),
|
||||||
|
('SPAN', (0, -1), (5, -1)),
|
||||||
|
('ALIGN', (0, -1), (5, -1), 'LEFT'),
|
||||||
('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'),
|
('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'),
|
||||||
('FONTNAME', (5, -1), (8, -1), 'Vera-Bold'),
|
('FONTNAME', (6, -1), (6, -1), 'Vera-Bold'),
|
||||||
|
('FONTNAME', (8, -1), (8, -1), 'Vera-Bold'),
|
||||||
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
||||||
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
||||||
|
('LEFTPADDING', (0, -1), (5, -1), 12),
|
||||||
('VALIGN', (7, 1), (7, -2), 'TOP'),
|
('VALIGN', (7, 1), (7, -2), 'TOP'),
|
||||||
('ALIGN', (7, 1), (7, -2), 'LEFT'),
|
('ALIGN', (7, 1), (7, -2), 'LEFT'),
|
||||||
]),
|
]),
|
||||||
@@ -2274,8 +2321,6 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
|
|||||||
for index, header in enumerate(headers):
|
for index, header in enumerate(headers):
|
||||||
hours_table.rows[0].cells[index].text = header
|
hours_table.rows[0].cells[index].text = header
|
||||||
_docx_remove_rows_after(hours_table, keep_rows=1)
|
_docx_remove_rows_after(hours_table, keep_rows=1)
|
||||||
total_work_hours = sum(_parse_report_decimal_value(row['work_hours']) for row in normalized_rows)
|
|
||||||
total_travel_hours = sum(_parse_report_decimal_value(row['travel_hours']) for row in normalized_rows)
|
|
||||||
if normalized_rows:
|
if normalized_rows:
|
||||||
for row in normalized_rows:
|
for row in normalized_rows:
|
||||||
cells = hours_table.add_row().cells
|
cells = hours_table.add_row().cells
|
||||||
@@ -2292,16 +2337,6 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
|
|||||||
cells = hours_table.add_row().cells
|
cells = hours_table.add_row().cells
|
||||||
for index in range(9):
|
for index in range(9):
|
||||||
cells[index].text = '-'
|
cells[index].text = '-'
|
||||||
cells = hours_table.add_row().cells
|
|
||||||
cells[0].text = 'Ukupno'
|
|
||||||
cells[1].text = ''
|
|
||||||
cells[2].text = ''
|
|
||||||
cells[3].text = ''
|
|
||||||
cells[4].text = ''
|
|
||||||
cells[5].text = _format_decimal_display(total_work_hours, default='0')
|
|
||||||
cells[6].text = _format_decimal_display(total_travel_hours, default='0')
|
|
||||||
cells[7].text = ''
|
|
||||||
cells[8].text = ''
|
|
||||||
_docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati')
|
_docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati')
|
||||||
_docx_cleanup_service_report_template(doc)
|
_docx_cleanup_service_report_template(doc)
|
||||||
_docx_remove_empty_page_break_paragraphs(doc)
|
_docx_remove_empty_page_break_paragraphs(doc)
|
||||||
@@ -2415,6 +2450,26 @@ def _file_attachment(file_field, fallback_name):
|
|||||||
return (filename, content, _guess_content_type(filename))
|
return (filename, content, _guess_content_type(filename))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_file_field_to_zip(archive, *, file_field, entry_name, chunk_size=64 * 1024):
|
||||||
|
if not file_field:
|
||||||
|
return False
|
||||||
|
file_field.open('rb')
|
||||||
|
try:
|
||||||
|
first_chunk = file_field.read(chunk_size)
|
||||||
|
if not first_chunk:
|
||||||
|
return False
|
||||||
|
with archive.open(entry_name, mode='w') as destination:
|
||||||
|
destination.write(first_chunk)
|
||||||
|
while True:
|
||||||
|
chunk = file_field.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
destination.write(chunk)
|
||||||
|
finally:
|
||||||
|
file_field.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _build_image_attachments_for_work_order(work_order):
|
def _build_image_attachments_for_work_order(work_order):
|
||||||
attachments = []
|
attachments = []
|
||||||
photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
|
photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at')
|
||||||
@@ -2938,25 +2993,6 @@ def _generated_archive_filename_for_user(user, *, year, month, archive_type):
|
|||||||
return f"{prefix}-{month:02d}-{year}-{suffix}.zip"
|
return f"{prefix}-{month:02d}-{year}-{suffix}.zip"
|
||||||
|
|
||||||
|
|
||||||
def _build_monthly_archive_to_temp_file(*, user, year, month, archive_type):
|
|
||||||
if archive_type == 'service_tasks':
|
|
||||||
archive_content = _build_monthly_service_tasks_archive_content(user=user, year=year, month=month)
|
|
||||||
else:
|
|
||||||
archive_content = _build_monthly_work_orders_archive_content(user=user, year=year, month=month)
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_file:
|
|
||||||
temp_file.write(archive_content)
|
|
||||||
return Path(temp_file.name)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_monthly_service_tasks_archive_to_temp_file(*, user, year, month):
|
|
||||||
return _build_monthly_archive_to_temp_file(user=user, year=year, month=month, archive_type='service_tasks')
|
|
||||||
|
|
||||||
|
|
||||||
def _build_monthly_work_orders_archive_to_temp_file(*, user, year, month):
|
|
||||||
return _build_monthly_archive_to_temp_file(user=user, year=year, month=month, archive_type='work_orders')
|
|
||||||
|
|
||||||
|
|
||||||
def _unique_zip_entry_name(entry_name, used_names):
|
def _unique_zip_entry_name(entry_name, used_names):
|
||||||
candidate = entry_name
|
candidate = entry_name
|
||||||
entry_path = Path(entry_name)
|
entry_path = Path(entry_name)
|
||||||
@@ -2992,7 +3028,7 @@ def _parse_year_month_params(request):
|
|||||||
return year, month
|
return year, month
|
||||||
|
|
||||||
|
|
||||||
def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
def _write_monthly_service_tasks_archive_entries(archive, *, user, year, month):
|
||||||
from modules.task_management.models import Task
|
from modules.task_management.models import Task
|
||||||
|
|
||||||
tasks = list(
|
tasks = list(
|
||||||
@@ -3012,8 +3048,7 @@ 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()
|
entries_written = 0
|
||||||
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
work_order = task.work_order
|
work_order = task.work_order
|
||||||
if work_order is None:
|
if work_order is None:
|
||||||
@@ -3022,15 +3057,50 @@ def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
|||||||
base_name = _service_records_docx_filename(work_order, task)
|
base_name = _service_records_docx_filename(work_order, task)
|
||||||
entry_name = _unique_zip_entry_name(base_name, used_names)
|
entry_name = _unique_zip_entry_name(base_name, used_names)
|
||||||
archive.writestr(entry_name, docx_bytes)
|
archive.writestr(entry_name, docx_bytes)
|
||||||
|
entries_written += 1
|
||||||
|
if entries_written == 0:
|
||||||
|
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
||||||
|
return entries_written
|
||||||
|
|
||||||
|
|
||||||
|
def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
||||||
|
archive_buffer = BytesIO()
|
||||||
|
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
_write_monthly_service_tasks_archive_entries(
|
||||||
|
archive,
|
||||||
|
user=user,
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
)
|
||||||
archive_content = archive_buffer.getvalue()
|
archive_content = archive_buffer.getvalue()
|
||||||
if not archive_content:
|
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 archive_content
|
return archive_content
|
||||||
|
|
||||||
|
|
||||||
def _build_monthly_work_orders_archive_content(*, user, year, month):
|
def _write_monthly_work_orders_archive_entries(archive, *, user, year, month):
|
||||||
ordered_work_order_ids = _user_monthly_work_order_ids(user=user, year=year, month=month)
|
from modules.task_management.models import Task
|
||||||
|
|
||||||
|
monthly_tasks = (
|
||||||
|
Task.objects
|
||||||
|
.filter(
|
||||||
|
assigned_to=user,
|
||||||
|
is_active=True,
|
||||||
|
scheduled_date__year=year,
|
||||||
|
scheduled_date__month=month,
|
||||||
|
work_order__isnull=False,
|
||||||
|
work_order__is_active=True,
|
||||||
|
)
|
||||||
|
.select_related('work_order')
|
||||||
|
.order_by('scheduled_date', 'created_at')
|
||||||
|
)
|
||||||
|
ordered_work_order_ids = []
|
||||||
|
seen_work_order_ids = set()
|
||||||
|
for task in monthly_tasks:
|
||||||
|
if not task.work_order_id or task.work_order_id in seen_work_order_ids:
|
||||||
|
continue
|
||||||
|
seen_work_order_ids.add(task.work_order_id)
|
||||||
|
ordered_work_order_ids.append(task.work_order_id)
|
||||||
if not ordered_work_order_ids:
|
if not ordered_work_order_ids:
|
||||||
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
|
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
|
||||||
|
|
||||||
@@ -3047,23 +3117,34 @@ 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()
|
entries_written = 0
|
||||||
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
|
||||||
for work_order in work_orders:
|
for work_order in work_orders:
|
||||||
pdf_bytes = _build_work_order_pdf(work_order)
|
pdf_bytes = _build_work_order_pdf(work_order)
|
||||||
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
|
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
|
||||||
archive.writestr(work_order_pdf_name, pdf_bytes)
|
archive.writestr(work_order_pdf_name, pdf_bytes)
|
||||||
|
entries_written += 1
|
||||||
|
|
||||||
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")
|
file_name = Path(str(getattr(getattr(invoice, 'image', None), 'name', '') or f"invoice-{index}.bin")).name
|
||||||
if not attachment:
|
archive_path = _unique_zip_entry_name(f"Racuni/{display_code}/{file_name}", used_names)
|
||||||
continue
|
if _write_file_field_to_zip(archive, file_field=invoice.image, entry_name=archive_path):
|
||||||
invoice_filename, content, _content_type = attachment
|
entries_written += 1
|
||||||
archive_path = f"Racuni/{display_code}/{invoice_filename}"
|
if entries_written == 0:
|
||||||
archive.writestr(_unique_zip_entry_name(archive_path, used_names), content)
|
raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'})
|
||||||
|
return entries_written
|
||||||
|
|
||||||
|
|
||||||
|
def _build_monthly_work_orders_archive_content(*, user, year, month):
|
||||||
|
archive_buffer = BytesIO()
|
||||||
|
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
_write_monthly_work_orders_archive_entries(
|
||||||
|
archive,
|
||||||
|
user=user,
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
)
|
||||||
archive_content = archive_buffer.getvalue()
|
archive_content = archive_buffer.getvalue()
|
||||||
if not archive_content:
|
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.'})
|
||||||
@@ -3157,80 +3238,26 @@ def _get_cached_generated_archive(*, user, archive_type, year, month):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _user_monthly_work_order_ids(*, user, year, month):
|
|
||||||
from modules.task_management.models import Task
|
|
||||||
|
|
||||||
task_work_order_ids = set(
|
|
||||||
Task.objects
|
|
||||||
.filter(
|
|
||||||
is_active=True,
|
|
||||||
scheduled_date__year=year,
|
|
||||||
scheduled_date__month=month,
|
|
||||||
work_order__isnull=False,
|
|
||||||
work_order__is_active=True,
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
Q(assigned_to=user)
|
|
||||||
| Q(work_order__creator=user)
|
|
||||||
| Q(vehicle__assigned_servicer=user)
|
|
||||||
)
|
|
||||||
.values_list('work_order_id', flat=True)
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
work_order_ids = set(
|
|
||||||
WorkOrder.objects
|
|
||||||
.filter(
|
|
||||||
is_active=True,
|
|
||||||
date__year=year,
|
|
||||||
date__month=month,
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
Q(creator=user)
|
|
||||||
| Q(vehicle__assigned_servicer=user)
|
|
||||||
)
|
|
||||||
.values_list('id', flat=True)
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
return list(dict.fromkeys([*task_work_order_ids, *work_order_ids]))
|
|
||||||
|
|
||||||
|
|
||||||
def _latest_monthly_archive_source_update(*, user, archive_type, year, month):
|
def _latest_monthly_archive_source_update(*, user, archive_type, year, month):
|
||||||
from modules.task_management.models import Task
|
from modules.task_management.models import Task
|
||||||
|
|
||||||
task_work_order_ids = _user_monthly_work_order_ids(user=user, year=year, month=month)
|
|
||||||
base_tasks = Task.objects.filter(
|
base_tasks = Task.objects.filter(
|
||||||
|
assigned_to=user,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
scheduled_date__year=year,
|
scheduled_date__year=year,
|
||||||
scheduled_date__month=month,
|
scheduled_date__month=month,
|
||||||
work_order__isnull=False,
|
work_order__isnull=False,
|
||||||
work_order__is_active=True,
|
work_order__is_active=True,
|
||||||
).filter(
|
|
||||||
Q(assigned_to=user)
|
|
||||||
| Q(work_order__creator=user)
|
|
||||||
| Q(vehicle__assigned_servicer=user)
|
|
||||||
)
|
|
||||||
base_work_orders = WorkOrder.objects.filter(
|
|
||||||
is_active=True,
|
|
||||||
date__year=year,
|
|
||||||
date__month=month,
|
|
||||||
).filter(
|
|
||||||
Q(creator=user)
|
|
||||||
| Q(vehicle__assigned_servicer=user)
|
|
||||||
)
|
)
|
||||||
latest_candidates = [
|
latest_candidates = [
|
||||||
base_tasks.aggregate(value=Max('updated_at')).get('value'),
|
base_tasks.aggregate(value=Max('updated_at')).get('value'),
|
||||||
base_tasks.aggregate(value=Max('work_order__updated_at')).get('value'),
|
base_tasks.aggregate(value=Max('work_order__updated_at')).get('value'),
|
||||||
base_tasks.aggregate(value=Max('work_order__vehicle__updated_at')).get('value'),
|
base_tasks.aggregate(value=Max('work_order__vehicle__updated_at')).get('value'),
|
||||||
base_tasks.aggregate(value=Max('work_hours_table__updated_at')).get('value'),
|
base_tasks.aggregate(value=Max('work_hours_table__updated_at')).get('value'),
|
||||||
base_work_orders.aggregate(value=Max('updated_at')).get('value'),
|
|
||||||
base_work_orders.aggregate(value=Max('vehicle__updated_at')).get('value'),
|
|
||||||
]
|
]
|
||||||
if archive_type == 'work_orders':
|
if archive_type == 'work_orders':
|
||||||
latest_candidates.append(
|
latest_candidates.append(
|
||||||
WorkOrderInvoice.objects.filter(
|
base_tasks.aggregate(value=Max('work_order__invoices__updated_at')).get('value')
|
||||||
work_order_id__in=task_work_order_ids,
|
|
||||||
is_active=True,
|
|
||||||
).aggregate(value=Max('updated_at')).get('value')
|
|
||||||
)
|
)
|
||||||
latest_values = [value for value in latest_candidates if value is not None]
|
latest_values = [value for value in latest_candidates if value is not None]
|
||||||
if not latest_values:
|
if not latest_values:
|
||||||
@@ -3428,12 +3455,18 @@ def generated_archive_download(request, archive_id):
|
|||||||
if generated_archive is None:
|
if generated_archive is None:
|
||||||
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')
|
file_name = getattr(generated_archive.file, 'name', '')
|
||||||
|
storage = getattr(generated_archive.file, 'storage', None)
|
||||||
|
if not file_name or storage is None:
|
||||||
|
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
|
||||||
try:
|
try:
|
||||||
archive_bytes = generated_archive.file.read()
|
if not storage.exists(file_name):
|
||||||
finally:
|
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
|
||||||
generated_archive.file.close()
|
except (FileNotFoundError, OSError, ValueError):
|
||||||
if not archive_bytes:
|
raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'})
|
||||||
|
try:
|
||||||
|
generated_archive.file.open('rb')
|
||||||
|
except (FileNotFoundError, OSError, ValueError):
|
||||||
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,
|
||||||
@@ -3441,10 +3474,13 @@ 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))
|
try:
|
||||||
|
response['Content-Length'] = str(generated_archive.file.size)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@@ -3723,6 +3759,18 @@ 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')
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/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
|
|
||||||
24
backend/scripts/cleanup_tmp_files.sh
Normal file
24
backend/scripts/cleanup_tmp_files.sh
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
TMP_DIR="${APP_TMP_CLEANUP_DIR:-/tmp}"
|
||||||
|
MAX_AGE_DAYS="${APP_TMP_CLEANUP_MAX_AGE_DAYS:-1}"
|
||||||
|
PREFIXES="${APP_TMP_CLEANUP_PREFIXES:-erp-fleet-archive-}"
|
||||||
|
|
||||||
|
if [ ! -d "$TMP_DIR" ]; then
|
||||||
|
echo "TMP dir ne postoji: $TMP_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
OLD_IFS="$IFS"
|
||||||
|
IFS=','
|
||||||
|
for prefix in $PREFIXES; do
|
||||||
|
prefix_trimmed="$(echo "$prefix" | xargs)"
|
||||||
|
if [ -z "$prefix_trimmed" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
find "$TMP_DIR" -maxdepth 1 -mindepth 1 -name "${prefix_trimmed}*" -mtime "+${MAX_AGE_DAYS}" -print -delete
|
||||||
|
done
|
||||||
|
IFS="$OLD_IFS"
|
||||||
|
|
||||||
|
echo "TMP cleanup dovršen za $TMP_DIR (older than ${MAX_AGE_DAYS} day(s))."
|
||||||
@@ -25,8 +25,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
media-permissions:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
build:
|
build:
|
||||||
@@ -39,7 +37,13 @@ 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 --concurrency=2 --prefetch-multiplier=1 --max-tasks-per-child=20 --max-memory-per-child=300000
|
CELERY_WORKER_PREFETCH_MULTIPLIER: "1"
|
||||||
|
CELERY_WORKER_MAX_TASKS_PER_CHILD: "20"
|
||||||
|
CELERY_WORKER_MAX_MEMORY_PER_CHILD: "350000"
|
||||||
|
APP_TMP_CLEANUP_DIR: "/tmp"
|
||||||
|
APP_TMP_CLEANUP_MAX_AGE_HOURS: "24"
|
||||||
|
APP_TMP_CLEANUP_PREFIXES: "erp-fleet-archive-"
|
||||||
|
command: celery -A core worker --loglevel=info --concurrency=${CELERY_WORKER_CONCURRENCY:-2}
|
||||||
volumes:
|
volumes:
|
||||||
- media_volume:/app/media
|
- media_volume:/app/media
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -47,8 +51,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
media-permissions:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
|
|
||||||
beat:
|
beat:
|
||||||
build:
|
build:
|
||||||
@@ -69,8 +71,6 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
worker:
|
worker:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
media-permissions:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
|
|
||||||
flower:
|
flower:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -21,17 +21,6 @@ services:
|
|||||||
# i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe.
|
# i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe.
|
||||||
command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru
|
command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru
|
||||||
|
|
||||||
media-permissions:
|
|
||||||
image: alpine:3.22
|
|
||||||
container_name: 004erpmediafix
|
|
||||||
command: >
|
|
||||||
sh -c "mkdir -p /app/media/fleet/generated_archives /app/media/fleet/generated_pdfs
|
|
||||||
&& chown -R 1001:1001 /app/media
|
|
||||||
&& chmod -R u+rwX,g+rwX /app/media"
|
|
||||||
volumes:
|
|
||||||
- media_volume:/app/media
|
|
||||||
restart: "no"
|
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
container_name: 004erpworker
|
container_name: 004erpworker
|
||||||
build:
|
build:
|
||||||
@@ -47,7 +36,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db: { condition: service_healthy }
|
db: { condition: service_healthy }
|
||||||
redis: { condition: service_started }
|
redis: { condition: service_started }
|
||||||
media-permissions: { condition: service_completed_successfully }
|
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -467,16 +467,19 @@ 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 warnings = serviceRecords.filter((record) => {
|
const totalWorkOrders = workOrders.length;
|
||||||
if (record.next_service_due_at == null || record.mileage == null) return false;
|
// Aktivni taskovi bez dodijeljenog putnog naloga
|
||||||
return Number(record.next_service_due_at) - Number(record.mileage) <= 1000;
|
const tasksWithoutWorkOrder = tasks.filter(
|
||||||
}).length;
|
(t) => (t.status === 'aktivan' || t.status === 'servis' || t.status === 'spreman_za_zavrsetak')
|
||||||
|
&& !t.work_order
|
||||||
|
).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openWorkOrders,
|
openWorkOrders,
|
||||||
closedWorkOrders,
|
closedWorkOrders,
|
||||||
|
totalWorkOrders,
|
||||||
servicesToday,
|
servicesToday,
|
||||||
warnings,
|
tasksWithoutWorkOrder,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -967,10 +970,30 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
|
export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null, taskOptions = {}) {
|
||||||
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) {
|
||||||
@@ -999,14 +1022,28 @@ export async function downloadWorkOrderDocx(workOrderId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkOrderServiceRecordsDocx(workOrderId) {
|
export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null, taskOptions = {}) {
|
||||||
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(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' });
|
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`);
|
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
||||||
showToast('DOCX servisnih zapisa je preuzet.', 'success');
|
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');
|
||||||
|
|||||||
Reference in New Issue
Block a user