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>
This commit is contained in:
mariomitte
2026-07-19 21:23:29 +02:00
parent d35eaeccf4
commit ae35a6041d
14 changed files with 836 additions and 19 deletions

View File

@@ -4,6 +4,7 @@ from io import BytesIO
import base64
from datetime import timedelta
from pathlib import Path
from decimal import Decimal, InvalidOperation
from PIL import Image, UnidentifiedImageError
from django.conf import settings
@@ -35,6 +36,7 @@ from .models import (
WorkOrderPhoto,
GeneratedWorkOrderPdf,
WorkOrderInvoice,
WorkOrderAdditionalCostsTable,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
@@ -44,6 +46,7 @@ from .serializers import (
VehicleSerializer,
WorkOrderSerializer,
WorkOrderInvoiceSerializer,
WorkOrderAdditionalCostsTableSerializer,
WorkOrderPhotoSerializer,
VehicleServiceRecordSerializer,
VehicleNotificationSerializer,
@@ -60,6 +63,7 @@ from .tasks import (
build_work_order_invoices_pdf_task,
build_work_order_pdf_cached_task,
cleanup_expired_generated_pdfs_task,
process_work_order_invoice_ocr,
)
register_unicode_fonts()
@@ -197,6 +201,60 @@ def _cleanup_expired_generated_pdfs():
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
def _parse_amount_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 _invalidate_work_order_pdf_cache(work_order, *, pdf_types=None):
cache_qs = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order=work_order,
)
if pdf_types:
cache_qs = cache_qs.filter(pdf_type__in=pdf_types)
for cached in cache_qs:
if cached.file:
cached.file.delete(save=False)
cached.is_active = False
cached.status = 'failed'
cached.error_message = 'PDF cache invalidiran zbog promjene podataka.'
cached.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
def _upsert_additional_cost_row_from_invoice(invoice):
if invoice is None or invoice.work_order_id is None:
return
table, _ = WorkOrderAdditionalCostsTable.objects.get_or_create(
work_order=invoice.work_order,
defaults={'data': {'rows': []}, 'total_for_payout': Decimal('0.00')},
)
rows = table.data.get('rows', []) if isinstance(table.data, dict) else []
normalized_rows = [
row for row in rows
if isinstance(row, dict) and str(row.get('source_invoice_id', '')) != str(invoice.pk)
]
normalized_rows.append({
'naziv': str(invoice.naziv_racuna or '').strip() or f'Račun {invoice.pk}',
'broj_racuna': str(invoice.pk),
'ukupan_iznos': '0.00',
'prilog': Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else '',
'source_invoice_id': str(invoice.pk),
})
total = sum(
(_parse_amount_decimal(row.get('ukupan_iznos')) for row in normalized_rows if isinstance(row, dict)),
Decimal('0.00'),
).quantize(Decimal('0.01'))
table.data = {'rows': normalized_rows}
table.total_for_payout = total
table.save(update_fields=['data', 'total_for_payout', 'updated_at'])
def _cached_pdf_file_response(generated_pdf, *, default_filename):
generated_pdf.file.open('rb')
filename = generated_pdf.filename or default_filename
@@ -371,6 +429,15 @@ def _build_work_order_pdf(work_order):
except (TypeError, ValueError):
return "0,00 €"
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 _paragraph(text, *, bold=False, size=9, align=0):
styles = getSampleStyleSheet()
base = styles['BodyText'].clone('wo-p')
@@ -400,11 +467,24 @@ def _build_work_order_pdf(work_order):
daily_rate = 0.0
daily_total = daily_qty * daily_rate
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
grand_total = daily_total + transport_total
place_label = (work_order.location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
origin_label = (work_order.origin_location or 'Zagreb').split(',')[0].strip() or 'Zagreb'
invoice_names = [inv.naziv_racuna for inv in invoices[:5] if inv.naziv_racuna]
attachments_text = ', '.join(invoice_names) if invoice_names else '-'
additional_table = getattr(work_order, 'additional_costs_table', None)
additional_rows_data = []
additional_total_decimal = Decimal('0.00')
if additional_table and isinstance(additional_table.data, dict):
additional_rows_data = additional_table.data.get('rows', []) if isinstance(additional_table.data.get('rows', []), list) else []
additional_total_decimal = _parse_decimal(additional_table.total_for_payout)
grand_total = daily_total + transport_total + float(additional_total_decimal)
attachment_names = [
str(row.get('prilog', '')).strip()
for row in additional_rows_data
if isinstance(row, dict) and str(row.get('prilog', '')).strip()
]
if not attachment_names:
attachment_names = [Path(inv.image.name).name for inv in invoices if inv.image and getattr(inv.image, 'name', '')]
attachments_text = ', '.join(attachment_names[:8]) if attachment_names else '-'
assigned_servicer_vehicle = (
creator.assigned_vehicles
.filter(asset_type='vehicle', is_active=True)
@@ -620,8 +700,20 @@ def _build_work_order_pdf(work_order):
# 5) Dodatni troškovi
additional_rows = [["DODATNI TROŠKOVI", "", ""], ["NAZIV", "Broj računa", "Ukupan iznos"]]
for inv in invoices[:5]:
additional_rows.append([inv.naziv_racuna or '-', str(inv.pk), "-"])
if additional_rows_data:
for row in additional_rows_data[:4]:
if not isinstance(row, dict):
continue
additional_rows.append([
str(row.get('naziv', '') or '-'),
str(row.get('broj_racuna', '') or '-'),
_fmt_eur(_parse_decimal(row.get('ukupan_iznos'))),
])
else:
for inv in invoices[:4]:
additional_rows.append([inv.naziv_racuna or '-', str(inv.pk), "-"])
additional_total_row_idx = len(additional_rows)
additional_rows.append(["UKUPNO", "", _fmt_eur(additional_total_decimal)])
while len(additional_rows) < 7:
additional_rows.append(["", "", ""])
y = draw_table(
@@ -633,6 +725,7 @@ def _build_work_order_pdf(work_order):
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
('FONTNAME', (0, 0), (2, 1), 'Vera-Bold'),
('FONTNAME', (0, 2), (-1, -1), 'Vera'),
('FONTNAME', (0, additional_total_row_idx), (2, additional_total_row_idx), 'Vera-Bold'),
('FONTSIZE', (0, 0), (-1, -1), 8.4),
('ALIGN', (0, 0), (2, 0), 'CENTER'),
('ALIGN', (2, 1), (2, -1), 'CENTER'),
@@ -1692,11 +1785,49 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
'service_records': records_payload,
})
additional_costs_table = getattr(work_order, 'additional_costs_table', None)
additional_costs_payload = (
WorkOrderAdditionalCostsTableSerializer(additional_costs_table).data
if additional_costs_table
else {'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'}
)
return Response({
'work_order_id': work_order.pk,
'tasks': payload,
'additional_costs_table': additional_costs_payload,
}, status=status.HTTP_200_OK)
@action(detail=True, methods=['get', 'put', 'patch'], url_path='additional-costs-table')
def additional_costs_table(self, request, pk=None):
work_order = self.get_object()
table = getattr(work_order, 'additional_costs_table', None)
if request.method.lower() == 'get':
if table:
return Response(WorkOrderAdditionalCostsTableSerializer(table).data, status=status.HTTP_200_OK)
return Response(
{'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'},
status=status.HTTP_200_OK,
)
if isinstance(request.data, dict) and 'data' in request.data:
payload_data = request.data.get('data')
else:
payload_data = request.data
if payload_data == {}:
payload_data = {'rows': []}
serializer = WorkOrderAdditionalCostsTableSerializer(
table,
data={'work_order': str(work_order.pk), 'data': payload_data},
partial=bool(table),
)
serializer.is_valid(raise_exception=True)
instance = serializer.save(work_order=work_order)
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
return Response(WorkOrderAdditionalCostsTableSerializer(instance).data, status=status.HTTP_200_OK)
@action(detail=True, methods=['post'], url_path='send-email')
def send_email(self, request, pk=None):
work_order = self.get_object()
@@ -1791,7 +1922,7 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
def image(self, request, pk=None):
invoice = self.get_object()
if not invoice.image:
raise DRFValidationError({"detail": "Slika računa nije dostupna."})
raise DRFValidationError({"detail": "Datoteka računa nije dostupna."})
suffix = Path(invoice.image.name or '').suffix.lower()
if suffix == '.pdf':
@@ -1851,7 +1982,13 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
allowed = _work_orders_queryset_for_user(self.request.user).filter(pk=work_order.pk).exists()
if not allowed:
raise DRFValidationError({"work_order": "Nemate dozvolu za odabrani putni nalog."})
serializer.save(created_by=self.request.user)
invoice = serializer.save(created_by=self.request.user)
_upsert_additional_cost_row_from_invoice(invoice)
_invalidate_work_order_pdf_cache(work_order, pdf_types=['work_order', 'invoices'])
try:
process_work_order_invoice_ocr.delay(str(invoice.pk))
except KombuOperationalError:
process_work_order_invoice_ocr.apply(args=[str(invoice.pk)])
def perform_destroy(self, instance):
instance.is_active = False