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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user