Prebaci slanje emaila putnog naloga u pozadinski task bez blokiranja UI-a i dodaj fallback kad queue nije dostupna. Kod greške slanja spremi generirane PDF-ove u cache i pošalji notifikaciju s download linkovima. Dodaj EmailDispatchLog model i Django admin pregled za praćenje kome je poslano, što je poslano, status i detalje privitaka. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
926 lines
36 KiB
Python
926 lines
36 KiB
Python
# 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
|
|
from .pdf_layout import register_unicode_fonts, draw_standard_header_footer
|
|
|
|
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="\n".join(body_lines).strip(),
|
|
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.save(update_fields=['attachments', '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
|
|
|
|
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(image)
|
|
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'(?<!\d)(\d{1,3}(?:[.\s]\d{3})*(?:,\d{2})|\d+(?:[.,]\d{2}))(?!\d)'
|
|
return [_parse_decimal(match) for match in re.findall(number_pattern, line)]
|
|
|
|
preferred_lines = []
|
|
for line in text.splitlines():
|
|
lower = line.lower()
|
|
if any(token in lower for token in ('ukupno', 'za platiti', 'iznos', 'total')):
|
|
preferred_lines.append(line)
|
|
|
|
for line in preferred_lines:
|
|
candidates = [value for value in _line_candidates(line) if value > 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 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)} |