# backend/modules/fleet/tasks.py from celery import shared_task 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.base import ContentFile import logging from io import BytesIO from io import StringIO from pathlib import Path import base64 import re import csv import mimetypes from decimal import Decimal, InvalidOperation from smtplib import SMTPSenderRefused from datetime import timedelta from django.core.exceptions import ValidationError as DjangoValidationError from django.core.validators import validate_email from PIL import Image, UnidentifiedImageError from django.db import transaction from pypdf import PdfReader from pypdf.errors import PdfReadError import pytesseract from pytesseract import TesseractNotFoundError from reportlab.lib.pagesizes import A4 from reportlab.lib.utils import ImageReader from reportlab.pdfgen import canvas from .models import VehicleNotification, GeneratedWorkOrderPdf, GeneratedFleetArchive from .pdf_layout import register_unicode_fonts, draw_standard_header_footer from .email_utils import append_user_signature logger = logging.getLogger(__name__) register_unicode_fonts() def _work_order_display_code(work_order): normalized = str(getattr(work_order, 'display_code', '') or '').strip().upper() if normalized: return normalized return 'NALOG' @shared_task(bind=True, max_retries=3, default_retry_delay=60) def send_notification_email(self, notification_id): """ Celery task koji pošalje e-mail za VehicleNotification i označi is_sent = True. Prihvaća notification_id (PK) i radi safe-lookup. U slučaju greške rekreira retry. """ try: # Lokalni import kako bismo izbjegli kružne importove from .models import VehicleNotification notif = VehicleNotification.objects.select_related('recipient').get(pk=notification_id) except Exception as exc: logger.warning(f"Notification {notification_id} ne postoji ili nije dostupna: {exc}") return recipient = notif.recipient recipient_email = getattr(recipient, 'email', None) if recipient else None if not recipient_email: logger.info(f"Notification {notification_id} nema email primatelja; preskačem slanje.") return subject = notif.title message = notif.message from_email = getattr(settings, "DEFAULT_FROM_EMAIL", None) try: send_mail( subject=subject, message=message, from_email=from_email, recipient_list=[recipient_email], fail_silently=False, ) notif.is_sent = True notif.save(update_fields=['is_sent']) logger.info(f"Notification email poslan za Notification(id={notification_id}) na {recipient_email}") except Exception as exc: logger.exception(f"Greška pri slanju emaila za Notification(id={notification_id}): {exc}") try: # Retry with exponential backoff handled by celery config; here max_retries = 3 raise self.retry(exc=exc) except self.MaxRetriesExceededError: logger.error(f"Max retries exceeded za Notification(id={notification_id})") @shared_task def send_email_task(recipient_email, subject, message, notif_id): try: send_mail( subject=subject, message=message, from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[recipient_email], fail_silently=False, ) VehicleNotification.objects.filter(id=notif_id).update(is_sent=True) except Exception as exc: logger.error(f"Async mail failed: {exc}") def _guess_content_type(filename): mime, _ = mimetypes.guess_type(str(filename or '')) return mime or 'application/octet-stream' def _file_attachment(file_field, fallback_name): if not file_field: return None file_field.open('rb') try: content = file_field.read() finally: file_field.close() if not content: return None filename = Path(str(getattr(file_field, 'name', '') or fallback_name)).name or fallback_name return (filename, content, _guess_content_type(filename)) def _build_image_attachments_for_work_order(work_order): from .models import WorkOrderPhoto, WorkOrderInvoice attachments = [] photos = WorkOrderPhoto.objects.filter(is_active=True, work_order=work_order).order_by('created_at') for index, photo in enumerate(photos, start=1): attachment = _file_attachment(photo.image, fallback_name=f"work-order-photo-{index}.bin") if attachment: attachments.append(attachment) invoices = WorkOrderInvoice.objects.filter(is_active=True, work_order=work_order).order_by('created_at') for index, invoice in enumerate(invoices, start=1): attachment = _file_attachment(invoice.image, fallback_name=f"work-order-invoice-{index}.bin") if attachment: attachments.append(attachment) return attachments def _user_display_name(user): if not user: return None full_name = user.get_full_name() if full_name: return full_name if getattr(user, 'email', None): return user.email if getattr(user, 'username', None): return user.username return str(getattr(user, 'pk', '')) def _build_monthly_tasks_csv_attachment(work_order, *, year, month, month_key): from modules.task_management.models import Task tasks = ( Task.objects .filter( is_active=True, vehicle_id=work_order.vehicle_id, scheduled_date__year=year, scheduled_date__month=month, ) .select_related('assigned_to', 'work_order') .order_by('scheduled_date', 'created_at') ) csv_buffer = StringIO() writer = csv.writer(csv_buffer) writer.writerow(['Datum', 'Naslov zadatka', 'Status', 'Serviser', 'Putni nalog', 'Opis']) for task in tasks: assignee_name = _user_display_name(task.assigned_to) or '-' work_order_label = str(getattr(getattr(task, 'work_order', None), 'display_code', '') or '').strip().upper() or '-' writer.writerow([ task.scheduled_date.isoformat() if task.scheduled_date else '', task.title or '', task.status or '', assignee_name, work_order_label, task.description or '', ]) csv_content = csv_buffer.getvalue().encode('utf-8') filename = f"{_work_order_display_code(work_order)}.monthly-tasks-{month_key}.csv" return (filename, csv_content, 'text/csv'), tasks.count() def _resolve_from_email(): candidates = [ getattr(settings, 'DEFAULT_FROM_EMAIL', None), getattr(settings, 'EMAIL_HOST_USER', None), ] for candidate in candidates: if not candidate: continue normalized = str(candidate).strip() if not normalized: continue try: validate_email(normalized) return normalized except DjangoValidationError: continue return None def _detect_generated_pdf_type(filename): normalized = str(filename or '').strip().lower() if normalized.endswith('.work-order-invoices.pdf'): return 'invoices' if normalized.endswith('.work-order-service-records.pdf'): return 'service_records' if normalized.endswith('.work-order.pdf'): return 'work_order' return None def _cache_pdf_attachments_for_download(*, work_order, requested_by, attachments): cached_payload = [] ttl_hours = int(getattr(settings, 'GENERATED_PDF_TTL_HOURS', 24)) expires_at = timezone.now() + timedelta(hours=ttl_hours) for filename, content, content_type in attachments: if str(content_type or '').lower() != 'application/pdf': continue pdf_type = _detect_generated_pdf_type(filename) if not pdf_type: continue generated = GeneratedWorkOrderPdf.objects.create( work_order=work_order, requested_by=requested_by, pdf_type=pdf_type, status='ready', filename=filename, expires_at=expires_at, ) generated.file.save(filename, ContentFile(content), save=False) generated.save(update_fields=['file', 'updated_at']) cached_payload.append( { 'generated_pdf_id': str(generated.pk), 'pdf_type': pdf_type, 'filename': filename, 'download_url': f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/", 'expires_at': expires_at.isoformat(), } ) return cached_payload @shared_task def send_work_order_email_bundle_task( *, work_order_id, requested_by_id, recipients, subject='', message='', include_work_order_pdf=True, include_service_records_pdf=False, include_invoices_pdf=False, include_images=False, include_monthly_tasks=False, month_year=None, month_number=None, month_key='', service_note_id=None, dispatch_log_id=None, ): from django.contrib.auth import get_user_model from .models import WorkOrder, ServiceContextNote, EmailDispatchLog from .services import NotificationService from .views import _build_work_order_pdf, _build_work_order_service_records_pdf User = get_user_model() requested_by = User.objects.filter(pk=requested_by_id, is_active=True).first() dispatch_log = EmailDispatchLog.objects.filter(pk=dispatch_log_id).first() if dispatch_log_id else None work_order = WorkOrder.objects.select_related('vehicle', 'creator', 'vehicle__client').filter(pk=work_order_id).first() if work_order is None: if dispatch_log: dispatch_log.status = 'failed' dispatch_log.error_message = 'Putni nalog nije pronađen.' dispatch_log.save(update_fields=['status', 'error_message', 'updated_at']) if requested_by: NotificationService.create_notification( recipient=requested_by, title='Greška kod slanja emaila', message='Putni nalog za slanje emaila nije pronađen.', level='warning', send_email=False, metadata={ 'entity_type': 'work_order_email', 'stage': 'failed', 'work_order_id': str(work_order_id), }, ) return {'status': 'failed', 'error': 'work-order-not-found'} body_lines = [str(message or 'U prilogu je dokumentacija putnog naloga.')] if service_note_id: note = ServiceContextNote.objects.select_related('created_by').filter( pk=service_note_id, is_active=True, ).first() if note: creator_name = _user_display_name(note.created_by) or '-' note_date = note.note_date.isoformat() if note.note_date else '-' body_lines.append("") body_lines.append("Odabrana bilješka servisnog konteksta:") body_lines.append(f"- Autor: {creator_name}") body_lines.append(f"- Datum bilješke: {note_date}") body_lines.append(f"- Tekst: {note.note}") attachments = [] if include_work_order_pdf: attachments.append(( f"{_work_order_display_code(work_order)}.work-order.pdf", _build_work_order_pdf(work_order), 'application/pdf', )) if include_service_records_pdf: attachments.append(( f"{_work_order_display_code(work_order)}.work-order-service-records.pdf", _build_work_order_service_records_pdf(work_order), 'application/pdf', )) if include_invoices_pdf: attachments.append(( f"{_work_order_display_code(work_order)}.work-order-invoices.pdf", _build_work_order_invoices_pdf(work_order), 'application/pdf', )) if include_images: image_attachments = _build_image_attachments_for_work_order(work_order) attachments.extend(image_attachments) body_lines.append("") body_lines.append(f"Dodano slika/datoteka: {len(image_attachments)}.") monthly_tasks_count = 0 if include_monthly_tasks and month_year and month_number and month_key: monthly_attachment, monthly_tasks_count = _build_monthly_tasks_csv_attachment( work_order, year=month_year, month=month_number, month_key=month_key, ) attachments.append(monthly_attachment) body_lines.append("") body_lines.append(f"Priložen je popis mjesečnih radnih taskova za {month_key} (ukupno: {monthly_tasks_count}).") cached_pdf_payload = [] if attachments: cached_pdf_payload = _cache_pdf_attachments_for_download( work_order=work_order, requested_by=requested_by, attachments=attachments, ) from_email = _resolve_from_email() if not from_email: if dispatch_log: dispatch_log.status = 'failed' dispatch_log.error_message = 'SMTP pošiljatelj nije valjan.' dispatch_log.metadata = { **(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}), 'cached_pdfs': cached_pdf_payload, } dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at']) if requested_by: NotificationService.create_notification( recipient=requested_by, title='Greška kod slanja emaila', message='SMTP pošiljatelj nije valjan. Provjerite DEFAULT_FROM_EMAIL/EMAIL_HOST_USER postavke.', level='warning', send_email=False, metadata={ 'entity_type': 'work_order_email', 'stage': 'failed', 'work_order_id': str(work_order.pk), 'cached_pdfs': cached_pdf_payload, }, ) return {'status': 'failed', 'error': 'invalid-from-email'} total_attachment_bytes = sum(len(content) for _, content, _ in attachments) max_attachment_bytes = int(getattr(settings, 'WORK_ORDER_EMAIL_MAX_ATTACHMENT_BYTES', 17 * 1024 * 1024)) if total_attachment_bytes > max_attachment_bytes: if dispatch_log: dispatch_log.status = 'failed' dispatch_log.error_message = 'Ukupna veličina privitaka je prevelika.' dispatch_log.metadata = { **(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}), 'attachments_bytes': total_attachment_bytes, 'max_attachments_bytes': max_attachment_bytes, 'cached_pdfs': cached_pdf_payload, } dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at']) if requested_by: NotificationService.create_notification( recipient=requested_by, title='Email nije poslan — preveliki privitci', message='Ukupna veličina privitaka je prevelika za slanje. Smanjite broj/veličinu privitaka.', level='warning', send_email=False, metadata={ 'entity_type': 'work_order_email', 'stage': 'failed', 'work_order_id': str(work_order.pk), 'attachments_bytes': total_attachment_bytes, 'max_attachments_bytes': max_attachment_bytes, 'cached_pdfs': cached_pdf_payload, }, ) return {'status': 'failed', 'error': 'attachments-too-large'} if not attachments: if dispatch_log: dispatch_log.status = 'failed' dispatch_log.error_message = 'Nije odabran nijedan privitak.' dispatch_log.save(update_fields=['status', 'error_message', 'updated_at']) if requested_by: NotificationService.create_notification( recipient=requested_by, title='Greška kod slanja emaila', message='Nije odabran nijedan PDF/prilog za slanje.', level='warning', send_email=False, metadata={ 'entity_type': 'work_order_email', 'stage': 'failed', 'work_order_id': str(work_order_id), }, ) return {'status': 'failed', 'error': 'no-attachments'} email_message = EmailMessage( subject=subject or f"Putni nalog {_work_order_display_code(work_order)}", body=append_user_signature("\n".join(body_lines).strip(), requested_by), from_email=from_email, to=recipients or [], ) for filename, content, content_type in attachments: email_message.attach(filename, content, content_type) if dispatch_log: dispatch_log.attachments = [ { 'filename': filename, 'content_type': content_type, 'size_bytes': len(content), } for filename, content, content_type in attachments ] dispatch_log.message = email_message.body dispatch_log.save(update_fields=['attachments', 'message', 'updated_at']) try: email_message.send(fail_silently=False) except SMTPSenderRefused as exc: logger.exception("SMTP sender refused for work order email bundle: %s", exc) if dispatch_log: dispatch_log.status = 'failed' dispatch_log.error_message = f'SMTP sender refused ({exc.smtp_code}).' dispatch_log.metadata = { **(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}), 'smtp_code': exc.smtp_code, 'from_email': from_email, 'cached_pdfs': cached_pdf_payload, } dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at']) if requested_by: NotificationService.create_notification( recipient=requested_by, title='Greška kod slanja emaila', message='SMTP server je odbio pošiljatelja. Provjerite email konfiguraciju.', level='warning', send_email=False, metadata={ 'entity_type': 'work_order_email', 'stage': 'failed', 'work_order_id': str(work_order.pk), 'smtp_code': exc.smtp_code, 'from_email': from_email, 'cached_pdfs': cached_pdf_payload, }, ) return {'status': 'failed', 'error': 'smtp-sender-refused'} except Exception as exc: logger.exception("Greška kod slanja work order email bundle: %s", exc) if dispatch_log: dispatch_log.status = 'failed' dispatch_log.error_message = str(exc) dispatch_log.metadata = { **(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}), 'cached_pdfs': cached_pdf_payload, } dispatch_log.save(update_fields=['status', 'error_message', 'metadata', 'updated_at']) if requested_by: NotificationService.create_notification( recipient=requested_by, title='Greška kod slanja emaila', message=f'Slanje emaila za putni nalog {_work_order_display_code(work_order)} nije uspjelo.', level='warning', send_email=False, metadata={ 'entity_type': 'work_order_email', 'stage': 'failed', 'work_order_id': str(work_order.pk), 'recipients_count': len(recipients or []), 'cached_pdfs': cached_pdf_payload, }, ) return {'status': 'failed', 'error': str(exc)} if requested_by: NotificationService.create_notification( recipient=requested_by, title='Email uspješno poslan', message=f'Email za putni nalog {_work_order_display_code(work_order)} je uspješno poslan.', level='success', send_email=False, metadata={ 'entity_type': 'work_order_email', 'stage': 'completed', 'work_order_id': str(work_order.pk), 'recipients_count': len(recipients or []), 'attachments_count': len(attachments), 'monthly_tasks_count': monthly_tasks_count, }, ) if dispatch_log: dispatch_log.status = 'sent' dispatch_log.error_message = '' dispatch_log.sent_at = timezone.now() dispatch_log.metadata = { **(dispatch_log.metadata if isinstance(dispatch_log.metadata, dict) else {}), 'recipients_count': len(recipients or []), 'attachments_count': len(attachments), 'monthly_tasks_count': monthly_tasks_count, } dispatch_log.save(update_fields=['status', 'error_message', 'sent_at', 'metadata', 'updated_at']) return { 'status': 'ok', 'work_order_id': str(work_order.pk), 'recipients_count': len(recipients or []), 'attachments_count': len(attachments), } def _build_work_order_invoices_pdf(work_order): buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 margin = 28 header_h = 86 footer_h = 72 content_top = height - header_h - 10 content_bottom = footer_h + 52 client_name = getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-" manufacturer = str(work_order.vehicle.make or "-") model_line = str(work_order.vehicle.model or "-") serial_line = str(getattr(work_order.vehicle, "crane_serial_number", None) or "-") upgrade_hours = str(getattr(work_order.vehicle, "superstructure_working_hours", "-") or "-") chassis_hours = str(getattr(work_order.vehicle, "chassis_working_hours", "-") or "-") mileage = str(getattr(work_order.vehicle, "current_mileage", "-") or "-") generated_date = timezone.localtime(timezone.now()).strftime("%d.%m.%Y") display_code = _work_order_display_code(work_order) def draw_header_footer(page_num): draw_standard_header_footer( pdf, page_num=page_num, client_name=client_name, manufacturer=manufacturer, model=model_line, serial=serial_line, upgrade_hours=upgrade_hours, chassis_hours=chassis_hours, mileage=mileage, work_order_number=display_code, generated_date=generated_date, report_title="Računi putnog naloga", page_size=A4, margin=margin, header_h=header_h, footer_h=footer_h, ) page_num = 1 draw_header_footer(page_num) y = content_top y -= 8 pdf.setFont("Vera-Bold", 13) pdf.drawString(margin, y, f"Računi putnog naloga {display_code}") y -= 20 pdf.setFont("Vera", 10) pdf.drawString(margin, y, f"Datum naloga: {work_order.date}") invoices = work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at') if not invoices.exists(): y -= 20 pdf.drawString(margin, y, "Nema računa za ovaj putni nalog.") pdf.save() return buffer.getvalue() for index, invoice in enumerate(invoices, start=1): pdf.showPage() page_num += 1 draw_header_footer(page_num) y = content_top y -= 8 pdf.setFont("Vera-Bold", 13) pdf.drawString(margin, y, f"Račun #{index}") y -= 22 pdf.setFont("Vera", 10) pdf.drawString(margin, y, f"naziv_racuna: {invoice.naziv_racuna or '-'}") y -= 16 pdf.drawString(margin, y, f"lokacija: {invoice.lokacija or '-'}") y -= 16 pdf.drawString(margin, y, f"datum: {invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-'}") y -= 16 pdf.drawString(margin, y, f"opis: {(invoice.opis or '-')[:140]}") y -= 20 if not invoice.image: pdf.drawString(margin, y, "slika: Nema slike.") continue suffix = Path(invoice.image.name or '').suffix.lower() if suffix == '.pdf': pdf.drawString(margin, y, "prilog: PDF račun (otvorite datoteku računa za pregled).") continue try: invoice.image.open('rb') with Image.open(invoice.image) as source: image = source.convert('RGB') max_width = width - (2 * margin) max_height = y - content_bottom if max_height < 120: pdf.showPage() page_num += 1 draw_header_footer(page_num) y = content_top max_height = y - content_bottom # Kompresiraj u JPEG u memoriji radi manje veličine PDF-a compress_w = min(image.width, 1280) if image.width > compress_w: ratio_c = compress_w / float(image.width) image = image.resize((compress_w, max(1, int(image.height * ratio_c))), Image.LANCZOS) buf = BytesIO() image.save(buf, format='JPEG', quality=75, optimize=True) buf.seek(0) ratio = min(max_width / float(image.width), max_height / float(image.height), 1.0) draw_width = max(1, int(image.width * ratio)) draw_height = max(1, int(image.height * ratio)) image_reader = ImageReader(buf) pdf.drawImage( image_reader, margin, y - draw_height, width=draw_width, height=draw_height, preserveAspectRatio=True, mask='auto', ) except (UnidentifiedImageError, OSError): pdf.drawString(margin, y, "slika: Slika nije dostupna ili je oštećena.") finally: invoice.image.close() pdf.save() return buffer.getvalue() @shared_task def build_work_order_invoices_pdf_task(work_order_id): from .models import WorkOrder work_order = WorkOrder.objects.select_related('vehicle').filter(pk=work_order_id).first() if work_order is None: return {"error": "Putni nalog nije pronađen."} pdf_bytes = _build_work_order_invoices_pdf(work_order) pdf_b64 = base64.b64encode(pdf_bytes).decode('ascii') filename = f"{_work_order_display_code(work_order)}.work-order-invoices.pdf" return { "filename": filename, "pdf_base64": pdf_b64, } def _parse_decimal(value): if value in (None, ''): return Decimal('0.00') try: normalized = str(value).strip().replace('€', '').replace(' ', '').replace(',', '.') return Decimal(normalized) except (InvalidOperation, ValueError, TypeError): return Decimal('0.00') def _extract_invoice_text(invoice): if not invoice.image: return '' suffix = Path(invoice.image.name or '').suffix.lower() if suffix == '.pdf': invoice.image.open('rb') try: reader = PdfReader(invoice.image) return '\n'.join((page.extract_text() or '') for page in reader.pages) finally: invoice.image.close() invoice.image.open('rb') try: with Image.open(invoice.image) as source: image = source.convert('RGB') return pytesseract.image_to_string(image, lang='hrv+eng') finally: invoice.image.close() def _extract_invoice_number(text, fallback): if not text: return fallback patterns = [ r'(?i)(?:broj\s*računa|račun\s*broj|invoice\s*no\.?)\s*[:#]?\s*([A-Z0-9\/\-\._]+)', r'(?i)(?:broj)\s*[:#]?\s*([A-Z0-9\/\-\._]{4,})', ] for pattern in patterns: match = re.search(pattern, text) if match: candidate = (match.group(1) or '').strip() if candidate: return candidate return fallback def _extract_total_amount(text): if not text: return Decimal('0.00') def _line_candidates(line): number_pattern = r'(? Decimal('0.00')] if candidates: return max(candidates) all_candidates = [] for line in text.splitlines(): all_candidates.extend([value for value in _line_candidates(line) if value > Decimal('0.00')]) if all_candidates: return max(all_candidates) return Decimal('0.00') @shared_task def process_work_order_invoice_ocr(invoice_id): from .models import WorkOrderInvoice, WorkOrderAdditionalCostsTable, GeneratedWorkOrderPdf invoice = ( WorkOrderInvoice.objects .select_related('work_order') .filter(pk=invoice_id, is_active=True) .first() ) if invoice is None or invoice.work_order_id is None: return {"status": "skipped", "reason": "invoice-not-found"} try: text = _extract_invoice_text(invoice) except (UnidentifiedImageError, OSError, TesseractNotFoundError, ValueError, RuntimeError, PdfReadError): text = '' attachment_name = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else '' row = { 'naziv': (invoice.naziv_racuna or '').strip() or attachment_name or f'Račun {invoice.pk}', 'broj_racuna': _extract_invoice_number(text, fallback=str(invoice.pk)), 'ukupan_iznos': f"{_extract_total_amount(text):.2f}", 'prilog': attachment_name, 'source_invoice_id': str(invoice.pk), } with transaction.atomic(): table, _ = WorkOrderAdditionalCostsTable.objects.select_for_update().get_or_create( work_order=invoice.work_order, defaults={'data': {'rows': []}}, ) existing_rows = table.data.get('rows', []) if isinstance(table.data, dict) else [] existing_row = next( ( item for item in existing_rows if isinstance(item, dict) and str(item.get('source_invoice_id', '')) == str(invoice.pk) ), None, ) normalized_rows = [ item for item in existing_rows if isinstance(item, dict) and str(item.get('source_invoice_id', '')) != str(invoice.pk) ] if existing_row and row['ukupan_iznos'] == '0.00': existing_amount = str(existing_row.get('ukupan_iznos', '') or '').strip() if existing_amount: row['ukupan_iznos'] = existing_amount if existing_row and not row['broj_racuna']: row['broj_racuna'] = str(existing_row.get('broj_racuna', '') or '').strip() normalized_rows.append(row) total = sum((_parse_decimal(item.get('ukupan_iznos')) for item in normalized_rows if isinstance(item, dict)), Decimal('0.00')) table.data = {'rows': normalized_rows} table.total_for_payout = total.quantize(Decimal('0.01')) table.save(update_fields=['data', 'total_for_payout', 'updated_at']) GeneratedWorkOrderPdf.objects.filter( is_active=True, work_order=invoice.work_order, pdf_type__in=['work_order', 'invoices'], ).update( is_active=False, status='failed', error_message='PDF cache invalidiran zbog OCR ažuriranja dodatnih troškova.', ) return {"status": "ok", "invoice_id": str(invoice.pk)} @shared_task def cleanup_expired_generated_pdfs_task(): now = timezone.now() expired = GeneratedWorkOrderPdf.objects.filter( is_active=True, expires_at__isnull=False, expires_at__lte=now, ) deleted = 0 for item in expired: if item.file: item.file.delete(save=False) item.is_active = False item.status = 'failed' item.error_message = 'PDF cache istekao.' item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) deleted += 1 return {"deleted": deleted} @shared_task def cleanup_expired_generated_archives_task(): now = timezone.now() expired = GeneratedFleetArchive.objects.filter( is_active=True, expires_at__isnull=False, expires_at__lte=now, ) deleted = 0 for item in expired: if item.file: item.file.delete(save=False) item.is_active = False item.status = 'failed' item.error_message = 'ZIP arhiva je istekla.' item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) deleted += 1 return {"deleted": deleted} @shared_task def build_work_order_pdf_cached_task(generated_pdf_id): from .views import _build_work_order_pdf, _build_work_order_service_records_pdf from .services import NotificationService generated = ( GeneratedWorkOrderPdf.objects .select_related('work_order', 'work_order__vehicle', 'requested_by') .filter(pk=generated_pdf_id, is_active=True) .first() ) if generated is None: return {"error": "Generated PDF zapis nije pronađen."} work_order = generated.work_order try: if generated.pdf_type == 'invoices': payload = build_work_order_invoices_pdf_task(str(work_order.pk)) if not isinstance(payload, dict) or payload.get('error'): raise ValueError(payload.get('error') if isinstance(payload, dict) else 'Neuspješno generiranje PDF računa.') pdf_b64 = payload.get('pdf_base64') if not pdf_b64: raise ValueError('PDF sadržaj nije dostupan.') pdf_bytes = base64.b64decode(pdf_b64) elif generated.pdf_type == 'service_records': pdf_bytes = _build_work_order_service_records_pdf(work_order) else: pdf_bytes = _build_work_order_pdf(work_order) filename = generated.filename or f"{_work_order_display_code(work_order)}.{generated.pdf_type}.pdf" generated.file.save(filename, ContentFile(pdf_bytes), save=False) generated.status = 'ready' generated.error_message = '' generated.save(update_fields=['file', 'status', 'error_message', 'updated_at']) if generated.requested_by: NotificationService.create_notification( recipient=generated.requested_by, title="PDF spreman", message=f"PDF dokument je uspješno generiran za putni nalog {_work_order_display_code(work_order)}.", level="success", send_email=False, metadata={ "entity_type": "work_order_pdf", "work_order_id": str(work_order.pk), "vehicle_id": str(work_order.vehicle_id), "section": "work-orders", "pdf_type": generated.pdf_type, "stage": "completed", "generated_pdf_id": str(generated.pk), "download_url": f"fleet/work-orders/{work_order.pk}/generated-pdfs/{generated.pk}/download/", "filename": generated.filename or filename, "expires_at": generated.expires_at.isoformat() if generated.expires_at else None, }, ) return {"status": "ready", "generated_pdf_id": str(generated.pk)} except Exception as exc: generated.status = 'failed' generated.error_message = str(exc) generated.save(update_fields=['status', 'error_message', 'updated_at']) if generated.requested_by: NotificationService.create_notification( recipient=generated.requested_by, title="Greška kod PDF-a", message=f"Generiranje PDF dokumenta nije uspjelo za putni nalog {_work_order_display_code(work_order)}.", level="warning", send_email=False, metadata={ "entity_type": "work_order_pdf", "work_order_id": str(work_order.pk), "vehicle_id": str(work_order.vehicle_id), "section": "work-orders", "pdf_type": generated.pdf_type, "stage": "failed", "generated_pdf_id": str(generated.pk), }, ) logger.exception("Greška kod build_work_order_pdf_cached_task: %s", exc) return {"status": "failed", "error": str(exc)} @shared_task def build_monthly_archive_cached_task(generated_archive_id): from .services import NotificationService from .views import ( _build_monthly_service_tasks_archive_content, _build_monthly_work_orders_archive_content, _generated_archive_filename_for_user, ) generated = ( GeneratedFleetArchive.objects .select_related('requested_by') .filter(pk=generated_archive_id, is_active=True) .first() ) if generated is None: return {'error': 'Generated ZIP zapis nije pronađen.'} requested_by = generated.requested_by if requested_by is None: generated.status = 'failed' generated.error_message = 'Korisnik koji je zatražio ZIP arhivu nije dostupan.' generated.save(update_fields=['status', 'error_message', 'updated_at']) return {'status': 'failed', 'error': generated.error_message} try: if generated.archive_type == 'work_orders': archive_bytes = _build_monthly_work_orders_archive_content( user=requested_by, year=generated.year, month=generated.month, ) else: archive_bytes = _build_monthly_service_tasks_archive_content( user=requested_by, year=generated.year, month=generated.month, ) filename = generated.filename or _generated_archive_filename_for_user( requested_by, year=generated.year, month=generated.month, archive_type=generated.archive_type, ) generated.file.save(filename, ContentFile(archive_bytes), save=False) generated.status = 'ready' generated.error_message = '' generated.save(update_fields=['file', 'status', 'error_message', 'updated_at']) NotificationService.create_notification( recipient=requested_by, title='ZIP arhiva spremna', message=f"ZIP arhiva je spremna za preuzimanje ({generated.month:02d}.{generated.year}.).", level='success', send_email=False, metadata={ 'entity_type': 'fleet_archive', 'archive_type': generated.archive_type, 'stage': 'completed', 'year': generated.year, 'month': generated.month, 'generated_archive_id': str(generated.pk), 'download_url': f"fleet/reports/generated-archives/{generated.pk}/download/", 'filename': generated.filename or filename, 'expires_at': generated.expires_at.isoformat() if generated.expires_at else None, 'section': 'service-records', }, ) return {'status': 'ready', 'generated_archive_id': str(generated.pk)} except Exception as exc: generated.status = 'failed' generated.error_message = str(exc) generated.save(update_fields=['status', 'error_message', 'updated_at']) NotificationService.create_notification( recipient=requested_by, title='Greška kod ZIP arhive', message=f"Generiranje ZIP arhive nije uspjelo ({generated.month:02d}.{generated.year}.).", level='warning', send_email=False, metadata={ 'entity_type': 'fleet_archive', 'archive_type': generated.archive_type, 'stage': 'failed', 'year': generated.year, 'month': generated.month, 'generated_archive_id': str(generated.pk), 'section': 'service-records', }, ) logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc) return {'status': 'failed', 'error': str(exc)}