Compare commits
14 Commits
1e68c663f2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
242c8dc9b2 | ||
|
|
1172dfabd7 | ||
|
|
fc2a683b9c | ||
|
|
f243bad6db | ||
|
|
334d1acd37 | ||
|
|
dd27e59c2c | ||
|
|
3931c9065b | ||
|
|
5af66c31b6 | ||
|
|
b6fe8d8969 | ||
|
|
b985c285f9 | ||
|
|
aa690d4ac4 | ||
|
|
52408bc5a9 | ||
|
|
8e9d23fcfa | ||
|
|
a7220d27f5 |
@@ -7,7 +7,7 @@ Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promje
|
||||
```bash
|
||||
cd /opt/erp/app
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
git reset --hard origin/main ili git pull --ff-only origin main
|
||||
|
||||
# rebuild backend + worker + beat + frontend
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend
|
||||
|
||||
@@ -2,11 +2,33 @@
|
||||
|
||||
import os
|
||||
from celery import Celery
|
||||
from celery import Task
|
||||
import gc
|
||||
import logging
|
||||
|
||||
# Postavi Django settings modul
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
app = Celery('core')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceAwareTask(Task):
|
||||
"""Celery base task s hookovima za memory-heavy async operacije."""
|
||||
|
||||
abstract = True
|
||||
|
||||
def on_success(self, retval, task_id, args, kwargs):
|
||||
logger.info("Celery task uspješan: %s (%s)", self.name, task_id)
|
||||
super().on_success(retval, task_id, args, kwargs)
|
||||
|
||||
def on_failure(self, exc, task_id, args, kwargs, einfo):
|
||||
logger.exception("Celery task neuspješan: %s (%s): %s", self.name, task_id, exc)
|
||||
super().on_failure(exc, task_id, args, kwargs, einfo)
|
||||
|
||||
def after_return(self, status, retval, task_id, args, kwargs, einfo):
|
||||
gc.collect()
|
||||
super().after_return(status, retval, task_id, args, kwargs, einfo)
|
||||
|
||||
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
@@ -154,6 +154,10 @@ REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
|
||||
CELERY_TASK_ACKS_LATE = True
|
||||
CELERY_WORKER_MAX_TASKS_PER_CHILD = 20
|
||||
CELERY_WORKER_MAX_MEMORY_PER_CHILD = 300000
|
||||
|
||||
# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
|
||||
# Za ovo ti je potreban paket 'django-redis' u requirements.txt
|
||||
@@ -175,6 +179,14 @@ CELERY_BEAT_SCHEDULE = {
|
||||
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
|
||||
'schedule': crontab(minute=0),
|
||||
},
|
||||
'cleanup-expired-generated-archives-hourly': {
|
||||
'task': 'modules.fleet.tasks.cleanup_expired_generated_archives_task',
|
||||
'schedule': crontab(minute=10),
|
||||
},
|
||||
'cleanup-stale-tmp-archives-daily': {
|
||||
'task': 'modules.fleet.tasks.cleanup_stale_tmp_archives_task',
|
||||
'schedule': crontab(hour=3, minute=30),
|
||||
},
|
||||
'notify-upcoming-tasks-daily': {
|
||||
'task': 'modules.task_management.tasks.notify_upcoming_tasks',
|
||||
'schedule': crontab(hour=8, minute=0),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# backend/modules/fleet/tasks.py
|
||||
from celery import shared_task
|
||||
from core.celery import ResourceAwareTask
|
||||
from django.core.mail import send_mail
|
||||
from django.core.mail import EmailMessage
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.core.files import File
|
||||
from django.core.files.base import ContentFile
|
||||
import logging
|
||||
from io import BytesIO
|
||||
@@ -835,13 +837,12 @@ def process_work_order_invoice_ocr(invoice_id):
|
||||
return {"status": "ok", "invoice_id": str(invoice.pk)}
|
||||
|
||||
|
||||
@shared_task
|
||||
@shared_task(base=ResourceAwareTask)
|
||||
def build_monthly_archive_cached_task(generated_archive_id):
|
||||
from .models import GeneratedFleetArchive
|
||||
from .services import NotificationService
|
||||
from .views import (
|
||||
_build_monthly_service_tasks_archive_content,
|
||||
_build_monthly_work_orders_archive_content,
|
||||
_build_monthly_service_tasks_archive_to_temp_file,
|
||||
_build_monthly_work_orders_archive_to_temp_file,
|
||||
_notify_monthly_archive_request,
|
||||
)
|
||||
|
||||
@@ -854,25 +855,27 @@ def build_monthly_archive_cached_task(generated_archive_id):
|
||||
if generated is None:
|
||||
return {"status": "failed", "error": "Generated archive record not found"}
|
||||
|
||||
tmp_archive_path = None
|
||||
try:
|
||||
if generated.archive_type == 'service_tasks':
|
||||
archive_content = _build_monthly_service_tasks_archive_content(
|
||||
tmp_archive_path = _build_monthly_service_tasks_archive_to_temp_file(
|
||||
user=generated.requested_by,
|
||||
year=generated.year,
|
||||
month=generated.month,
|
||||
)
|
||||
else:
|
||||
archive_content = _build_monthly_work_orders_archive_content(
|
||||
tmp_archive_path = _build_monthly_work_orders_archive_to_temp_file(
|
||||
user=generated.requested_by,
|
||||
year=generated.year,
|
||||
month=generated.month,
|
||||
)
|
||||
|
||||
if not archive_content:
|
||||
if tmp_archive_path is None or tmp_archive_path.stat().st_size <= 0:
|
||||
raise ValueError('ZIP arhiva je prazna.')
|
||||
|
||||
filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip"
|
||||
generated.file.save(filename, ContentFile(archive_content), save=False)
|
||||
with tmp_archive_path.open('rb') as temp_handle:
|
||||
generated.file.save(filename, File(temp_handle), save=False)
|
||||
generated.status = 'ready'
|
||||
generated.error_message = ''
|
||||
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
|
||||
@@ -902,6 +905,9 @@ def build_monthly_archive_cached_task(generated_archive_id):
|
||||
)
|
||||
logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc)
|
||||
return {"status": "failed", "error": str(exc)}
|
||||
finally:
|
||||
if tmp_archive_path is not None:
|
||||
tmp_archive_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@shared_task
|
||||
@@ -924,6 +930,14 @@ def cleanup_expired_generated_archives_task():
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_stale_tmp_archives_task():
|
||||
from .views import _cleanup_stale_tmp_archives
|
||||
|
||||
deleted = _cleanup_stale_tmp_archives()
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_expired_generated_pdfs_task():
|
||||
now = timezone.now()
|
||||
|
||||
@@ -29,6 +29,7 @@ from modules.fleet.tasks import build_monthly_archive_cached_task
|
||||
from modules.fleet.views import (
|
||||
_build_monthly_servicer_report_rows,
|
||||
_calculate_daily_quantity_from_hours,
|
||||
_compress_image_for_docx,
|
||||
_work_order_related_tasks_queryset,
|
||||
)
|
||||
|
||||
@@ -240,6 +241,31 @@ class WorkOrderImagesEndpointTests(TestCase):
|
||||
self.assertIn('additional_costs_table', payload)
|
||||
self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00')
|
||||
|
||||
def test_compress_image_for_docx_keeps_standard_phone_photos(self):
|
||||
file = BytesIO()
|
||||
Image.new('RGB', (4000, 3000), color='blue').save(file, format='JPEG', quality=85)
|
||||
file.seek(0)
|
||||
uploaded = SimpleUploadedFile('phone-12mp.jpg', file.getvalue(), content_type='image/jpeg')
|
||||
|
||||
result = _compress_image_for_docx(uploaded)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertGreater(len(result.getvalue()), 0)
|
||||
|
||||
def test_compress_image_for_docx_handles_exif_orientation(self):
|
||||
exif = Image.Exif()
|
||||
exif[0x0112] = 6
|
||||
|
||||
file = BytesIO()
|
||||
Image.new('RGB', (3000, 2000), color='green').save(file, format='JPEG', quality=85, exif=exif.tobytes())
|
||||
file.seek(0)
|
||||
uploaded = SimpleUploadedFile('rotated-phone.jpg', file.getvalue(), content_type='image/jpeg')
|
||||
|
||||
result = _compress_image_for_docx(uploaded)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertGreater(len(result.getvalue()), 0)
|
||||
|
||||
def test_work_order_related_tasks_queryset_excludes_other_work_orders_for_same_vehicle(self):
|
||||
other_work_order = WorkOrder.objects.create(
|
||||
vehicle=self.vehicle,
|
||||
@@ -471,6 +497,67 @@ class WorkOrderImagesEndpointTests(TestCase):
|
||||
self.assertIn('MT170726', 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):
|
||||
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
|
||||
self.assertEqual(response.status_code, 200, response.content)
|
||||
@@ -552,6 +639,40 @@ class WorkOrderImagesEndpointTests(TestCase):
|
||||
self.assertNotIn('Liebherr LTM 1090', 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):
|
||||
WorkOrderInvoice.objects.create(
|
||||
work_order=self.work_order,
|
||||
|
||||
@@ -5,6 +5,7 @@ from io import StringIO
|
||||
import base64
|
||||
import csv
|
||||
import mimetypes
|
||||
import tempfile
|
||||
import threading
|
||||
import zipfile
|
||||
import re
|
||||
@@ -155,7 +156,6 @@ def _work_order_related_tasks_queryset(work_order):
|
||||
vehicle_id=work_order.vehicle_id,
|
||||
task_id__isnull=False,
|
||||
task__is_active=True,
|
||||
task__work_order=work_order,
|
||||
).values_list('task_id', flat=True)
|
||||
)
|
||||
task_ids = list({*direct_task_ids, *inferred_task_ids})
|
||||
@@ -305,14 +305,6 @@ def _format_decimal_fixed(value, *, default='0.00', places=2):
|
||||
return format(normalized.quantize(quantizer), 'f')
|
||||
|
||||
|
||||
def _normalize_datetime_for_compare(value):
|
||||
if not isinstance(value, datetime):
|
||||
return value
|
||||
if timezone.is_naive(value):
|
||||
return timezone.make_aware(value, timezone.get_current_timezone())
|
||||
return timezone.localtime(value)
|
||||
|
||||
|
||||
def _work_order_travel_expenses_context(work_order):
|
||||
related_tasks = list(_work_order_related_tasks_queryset(work_order))
|
||||
trip_entries = []
|
||||
@@ -327,16 +319,8 @@ def _work_order_travel_expenses_context(work_order):
|
||||
|
||||
if trip_entries:
|
||||
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
|
||||
start_candidates = [
|
||||
_normalize_datetime_for_compare(entry.get('start_dt'))
|
||||
for entry in trip_entries
|
||||
if entry.get('start_dt')
|
||||
]
|
||||
end_candidates = [
|
||||
_normalize_datetime_for_compare(entry.get('end_dt'))
|
||||
for entry in trip_entries
|
||||
if entry.get('end_dt')
|
||||
]
|
||||
start_candidates = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')]
|
||||
end_candidates = [entry['end_dt'] for entry in trip_entries if entry.get('end_dt')]
|
||||
if entry_dates:
|
||||
trip_start_date = min(entry_dates)
|
||||
trip_end_date = max(entry_dates)
|
||||
@@ -533,30 +517,10 @@ def _get_cached_pdf(work_order, pdf_type):
|
||||
)
|
||||
for candidate in candidates:
|
||||
if str(candidate.filename or '').strip() == expected_filename:
|
||||
file_name = getattr(candidate.file, 'name', '')
|
||||
storage = getattr(candidate.file, 'storage', None)
|
||||
if file_name and storage:
|
||||
try:
|
||||
if storage.exists(file_name):
|
||||
return candidate
|
||||
except (FileNotFoundError, OSError, ValueError):
|
||||
pass
|
||||
candidate.file = None
|
||||
candidate.is_active = False
|
||||
candidate.status = 'failed'
|
||||
candidate.error_message = 'PDF datoteka nije dostupna.'
|
||||
candidate.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
||||
return None
|
||||
|
||||
|
||||
def _mark_generated_pdf_failed(generated_pdf, error_message):
|
||||
generated_pdf.file = None
|
||||
generated_pdf.is_active = False
|
||||
generated_pdf.status = 'failed'
|
||||
generated_pdf.error_message = error_message
|
||||
generated_pdf.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
||||
|
||||
|
||||
def _cleanup_expired_generated_pdfs():
|
||||
now = timezone.now()
|
||||
expired = GeneratedWorkOrderPdf.objects.filter(
|
||||
@@ -567,11 +531,10 @@ def _cleanup_expired_generated_pdfs():
|
||||
for item in expired:
|
||||
if item.file:
|
||||
item.file.delete(save=False)
|
||||
item.file = None
|
||||
item.is_active = False
|
||||
item.status = 'failed'
|
||||
item.error_message = 'PDF cache istekao.'
|
||||
item.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
||||
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||
|
||||
|
||||
def _parse_amount_decimal(value):
|
||||
@@ -594,11 +557,10 @@ def _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None):
|
||||
for cached in cache_qs:
|
||||
if cached.file:
|
||||
cached.file.delete(save=False)
|
||||
cached.file = None
|
||||
cached.is_active = False
|
||||
cached.status = 'failed'
|
||||
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
|
||||
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
|
||||
cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
||||
|
||||
|
||||
def _upsert_additional_cost_row_from_invoice(invoice):
|
||||
@@ -630,11 +592,7 @@ def _upsert_additional_cost_row_from_invoice(invoice):
|
||||
|
||||
|
||||
def _cached_pdf_file_response(generated_pdf, *, default_filename):
|
||||
try:
|
||||
generated_pdf.file.open('rb')
|
||||
except (FileNotFoundError, OSError, ValueError):
|
||||
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
||||
filename = generated_pdf.filename or default_filename
|
||||
response = FileResponse(generated_pdf.file, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
@@ -1377,6 +1335,7 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
|
||||
"Kilometri\nvozila",
|
||||
]
|
||||
table3_rows = [table3_headers]
|
||||
total_hours_work = 0.0
|
||||
total_hours_travel = 0.0
|
||||
total_vehicle_km = 0.0
|
||||
|
||||
@@ -1393,13 +1352,24 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
|
||||
row['places'],
|
||||
row['vehicle_km'],
|
||||
])
|
||||
total_hours_work += _parse_decimal(row['work_hours'])
|
||||
total_hours_travel += _parse_decimal(row['travel_hours'])
|
||||
total_vehicle_km += _parse_decimal(row['vehicle_km'])
|
||||
else:
|
||||
for _ in range(12):
|
||||
table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"])
|
||||
|
||||
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('.', ',')])
|
||||
table3_rows.append([
|
||||
"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,
|
||||
table3_rows,
|
||||
@@ -1411,15 +1381,11 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
|
||||
('GRID', (0, 0), (-1, -1), 0.5, colors.black),
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('ALIGN', (0, 0), (0, -1), 'CENTER'),
|
||||
('ALIGN', (1, 0), (6, -1), 'CENTER'),
|
||||
('SPAN', (0, -1), (5, -1)),
|
||||
('ALIGN', (0, -1), (5, -1), 'LEFT'),
|
||||
('ALIGN', (1, 0), (8, -1), 'CENTER'),
|
||||
('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'),
|
||||
('FONTNAME', (6, -1), (6, -1), 'Vera-Bold'),
|
||||
('FONTNAME', (8, -1), (8, -1), 'Vera-Bold'),
|
||||
('FONTNAME', (5, -1), (8, -1), 'Vera-Bold'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
||||
('LEFTPADDING', (0, -1), (5, -1), 12),
|
||||
('VALIGN', (7, 1), (7, -2), 'TOP'),
|
||||
('ALIGN', (7, 1), (7, -2), 'LEFT'),
|
||||
]),
|
||||
@@ -2308,6 +2274,8 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
|
||||
for index, header in enumerate(headers):
|
||||
hours_table.rows[0].cells[index].text = header
|
||||
_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:
|
||||
for row in normalized_rows:
|
||||
cells = hours_table.add_row().cells
|
||||
@@ -2324,6 +2292,16 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
|
||||
cells = hours_table.add_row().cells
|
||||
for index in range(9):
|
||||
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_cleanup_service_report_template(doc)
|
||||
_docx_remove_empty_page_break_paragraphs(doc)
|
||||
@@ -2960,6 +2938,25 @@ def _generated_archive_filename_for_user(user, *, year, month, archive_type):
|
||||
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):
|
||||
candidate = entry_name
|
||||
entry_path = Path(entry_name)
|
||||
@@ -3033,28 +3030,7 @@ def _build_monthly_service_tasks_archive_content(*, user, year, month):
|
||||
|
||||
|
||||
def _build_monthly_work_orders_archive_content(*, user, year, 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)
|
||||
ordered_work_order_ids = _user_monthly_work_order_ids(user=user, year=year, month=month)
|
||||
if not ordered_work_order_ids:
|
||||
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
|
||||
|
||||
@@ -3181,26 +3157,80 @@ def _get_cached_generated_archive(*, user, archive_type, year, month):
|
||||
)
|
||||
|
||||
|
||||
def _latest_monthly_archive_source_update(*, user, archive_type, year, month):
|
||||
def _user_monthly_work_order_ids(*, user, year, month):
|
||||
from modules.task_management.models import Task
|
||||
|
||||
base_tasks = Task.objects.filter(
|
||||
assigned_to=user,
|
||||
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):
|
||||
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(
|
||||
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)
|
||||
)
|
||||
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 = [
|
||||
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__vehicle__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':
|
||||
latest_candidates.append(
|
||||
base_tasks.aggregate(value=Max('work_order__invoices__updated_at')).get('value')
|
||||
WorkOrderInvoice.objects.filter(
|
||||
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]
|
||||
if not latest_values:
|
||||
@@ -3693,18 +3723,6 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
||||
).exclude(file='').exclude(file__isnull=True).first()
|
||||
if generated_pdf is None:
|
||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je istekao."})
|
||||
file_name = getattr(generated_pdf.file, 'name', '')
|
||||
storage = getattr(generated_pdf.file, 'storage', None)
|
||||
if not file_name or storage is None:
|
||||
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
||||
try:
|
||||
if not storage.exists(file_name):
|
||||
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
||||
except (FileNotFoundError, OSError, ValueError):
|
||||
_mark_generated_pdf_failed(generated_pdf, 'PDF datoteka nije dostupna.')
|
||||
raise DRFValidationError({"detail": "PDF nije dostupan ili je obrisan."})
|
||||
return _cached_pdf_file_response(generated_pdf, default_filename=_pdf_filename(work_order, generated_pdf.pdf_type))
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='pdf-preview')
|
||||
|
||||
12
backend/scripts/cleanup_tmp_archives.sh
Normal file
12
backend/scripts/cleanup_tmp_archives.sh
Normal file
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TARGET_DIR="${TMP_ARCHIVE_DIR:-/tmp/erp-generated-archives}"
|
||||
RETENTION_DAYS="${TMP_RETENTION_DAYS:-1}"
|
||||
|
||||
if [ ! -d "$TARGET_DIR" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
find "$TARGET_DIR" -type f -name '*.zip' -mtime +"$RETENTION_DAYS" -delete
|
||||
find "$TARGET_DIR" -type d -empty -delete
|
||||
@@ -25,6 +25,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
media-permissions:
|
||||
condition: service_completed_successfully
|
||||
|
||||
worker:
|
||||
build:
|
||||
@@ -37,7 +39,7 @@ services:
|
||||
environment:
|
||||
DEBUG: "False"
|
||||
DJANGO_SETTINGS_MODULE: core.settings.production
|
||||
command: celery -A core worker --loglevel=info
|
||||
command: celery -A core worker --loglevel=info --concurrency=2 --prefetch-multiplier=1 --max-tasks-per-child=20 --max-memory-per-child=300000
|
||||
volumes:
|
||||
- media_volume:/app/media
|
||||
depends_on:
|
||||
@@ -45,6 +47,8 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
media-permissions:
|
||||
condition: service_completed_successfully
|
||||
|
||||
beat:
|
||||
build:
|
||||
@@ -65,6 +69,8 @@ services:
|
||||
condition: service_started
|
||||
worker:
|
||||
condition: service_started
|
||||
media-permissions:
|
||||
condition: service_completed_successfully
|
||||
|
||||
flower:
|
||||
build:
|
||||
|
||||
@@ -21,6 +21,17 @@ services:
|
||||
# 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
|
||||
|
||||
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:
|
||||
container_name: 004erpworker
|
||||
build:
|
||||
@@ -36,6 +47,7 @@ services:
|
||||
depends_on:
|
||||
db: { condition: service_healthy }
|
||||
redis: { condition: service_started }
|
||||
media-permissions: { condition: service_completed_successfully }
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
@@ -467,19 +467,16 @@ export const $dashboardStats = computed(
|
||||
const servicesToday = tasks.filter(
|
||||
(t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis')
|
||||
).length;
|
||||
const totalWorkOrders = workOrders.length;
|
||||
// Aktivni taskovi bez dodijeljenog putnog naloga
|
||||
const tasksWithoutWorkOrder = tasks.filter(
|
||||
(t) => (t.status === 'aktivan' || t.status === 'servis' || t.status === 'spreman_za_zavrsetak')
|
||||
&& !t.work_order
|
||||
).length;
|
||||
const warnings = serviceRecords.filter((record) => {
|
||||
if (record.next_service_due_at == null || record.mileage == null) return false;
|
||||
return Number(record.next_service_due_at) - Number(record.mileage) <= 1000;
|
||||
}).length;
|
||||
|
||||
return {
|
||||
openWorkOrders,
|
||||
closedWorkOrders,
|
||||
totalWorkOrders,
|
||||
servicesToday,
|
||||
tasksWithoutWorkOrder,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -970,30 +967,10 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null, taskOptions = {}) {
|
||||
export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
if (taskId) {
|
||||
try {
|
||||
const blob = await api.get(
|
||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/?task_id=${encodeURIComponent(taskId)}`,
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
|
||||
const taskTitle = String(taskOptions?.taskTitle || '').trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^A-Za-z0-9_-]+/g, '')
|
||||
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
|
||||
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.pdf`);
|
||||
showToast('PDF servisnih zapisa za odabrani task je preuzet.', 'success');
|
||||
return;
|
||||
} catch (err) {
|
||||
const msg = err?.message || 'Preuzimanje PDF-a servisnih zapisa za task nije uspjelo.';
|
||||
showToast(msg, 'error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
|
||||
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
|
||||
if (payload?.status === 'ready' && payload?.download_url) {
|
||||
@@ -1022,28 +999,14 @@ export async function downloadWorkOrderDocx(workOrderId) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null, taskOptions = {}) {
|
||||
export async function downloadWorkOrderServiceRecordsDocx(workOrderId) {
|
||||
if (!workOrderId) {
|
||||
throw new Error('Work order ID je obavezan.');
|
||||
}
|
||||
const query = taskId ? `?task_id=${encodeURIComponent(taskId)}` : '';
|
||||
try {
|
||||
const blob = await api.get(
|
||||
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/${query}`,
|
||||
{ responseType: 'blob' },
|
||||
);
|
||||
if (taskId) {
|
||||
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
|
||||
const taskTitle = String(taskOptions?.taskTitle || '').trim()
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^A-Za-z0-9_-]+/g, '')
|
||||
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
|
||||
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.docx`);
|
||||
showToast('DOCX servisnih zapisa za odabrani task je preuzet.', 'success');
|
||||
} else {
|
||||
const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' });
|
||||
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
|
||||
showToast('DOCX servisnih zapisa je preuzet.', 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
|
||||
showToast(msg, 'error');
|
||||
|
||||
Reference in New Issue
Block a user