Files
004.erp.generic/backend/modules/fleet/tasks.py
mariomitte ae35a6041d feat: dodaj dodatne troškove s OCR sinkronizacijom
Dodana je tablica DODATNI TROŠKOVI za putni nalog (backend model + API +
frontend modal) s izračunom ukupnog iznosa za isplatu.

Upload računa sada odmah upisuje redak u dodatne troškove, a asinkroni OCR
(Celery) naknadno pokušava popuniti broj računa i iznos iz slike/PDF-a.

Uvedena je invalidacija PDF cache-a nakon promjena računa/dodatnih troškova
kako bi generirani PDF uvijek prikazivao najnovije podatke.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 21:23:29 +02:00

474 lines
18 KiB
Python

# backend/modules/fleet/tasks.py
from celery import shared_task
from django.core.mail import send_mail
from django.conf import settings
from django.utils import timezone
from django.core.files.base import ContentFile
import logging
from io import BytesIO
from pathlib import Path
import base64
import re
from decimal import Decimal, InvalidOperation
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 _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)}