From ae35a6041df62a9456dbd650a9678f25dfdb5c9b Mon Sep 17 00:00:00 2001 From: mariomitte Date: Sun, 19 Jul 2026 21:23:29 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20dodaj=20dodatne=20tro=C5=A1kove=20s=20O?= =?UTF-8?q?CR=20sinkronizacijom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- backend/modules/fleet/admin.py | 9 + .../0029_alter_workorder_purpose.py | 18 ++ .../0029_workorderadditionalcoststable.py | 31 +++ .../migrations/0030_merge_20260719_2057.py | 11 ++ ...dditionalcoststable_created_at_and_more.py | 34 ++++ backend/modules/fleet/models.py | 29 +++ backend/modules/fleet/serializers.py | 57 ++++++ backend/modules/fleet/tasks.py | 149 ++++++++++++++ .../tests/test_work_order_images_endpoint.py | 75 ++++++- backend/modules/fleet/views.py | 151 +++++++++++++- .../0010_alter_tasktemplate_status.py | 18 ++ .../WorkOrderAdditionalCostsModal.jsx | 186 ++++++++++++++++++ .../dashboard/WorkOrderInvoicesPdfPage.jsx | 61 +++++- frontend/src/stores/fleetDashboardStore.js | 26 ++- 14 files changed, 836 insertions(+), 19 deletions(-) create mode 100644 backend/modules/fleet/migrations/0029_alter_workorder_purpose.py create mode 100644 backend/modules/fleet/migrations/0029_workorderadditionalcoststable.py create mode 100644 backend/modules/fleet/migrations/0030_merge_20260719_2057.py create mode 100644 backend/modules/fleet/migrations/0031_alter_workorderadditionalcoststable_created_at_and_more.py create mode 100644 backend/modules/task_management/migrations/0010_alter_tasktemplate_status.py create mode 100644 frontend/src/components/dashboard/WorkOrderAdditionalCostsModal.jsx diff --git a/backend/modules/fleet/admin.py b/backend/modules/fleet/admin.py index 003365b..45015db 100644 --- a/backend/modules/fleet/admin.py +++ b/backend/modules/fleet/admin.py @@ -7,6 +7,7 @@ from .models import ( WorkOrder, WorkOrderPhoto, WorkOrderInvoice, + WorkOrderAdditionalCostsTable, GeneratedWorkOrderPdf, VehicleServiceRecord, VehicleServicePhoto, @@ -196,6 +197,14 @@ class WorkOrderInvoiceAdmin(admin.ModelAdmin): ordering = ('-datum', '-created_at') +@admin.register(WorkOrderAdditionalCostsTable) +class WorkOrderAdditionalCostsTableAdmin(admin.ModelAdmin): + list_display = ('id', 'work_order', 'total_for_payout', 'updated_at') + search_fields = ('work_order__display_code', 'work_order__vehicle__registration_number') + readonly_fields = ('created_at', 'updated_at') + ordering = ('-updated_at',) + + @admin.register(GeneratedWorkOrderPdf) class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin): list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at') diff --git a/backend/modules/fleet/migrations/0029_alter_workorder_purpose.py b/backend/modules/fleet/migrations/0029_alter_workorder_purpose.py new file mode 100644 index 0000000..069cc90 --- /dev/null +++ b/backend/modules/fleet/migrations/0029_alter_workorder_purpose.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-19 17:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0028_alter_workorderinvoice_image'), + ] + + operations = [ + migrations.AlterField( + model_name='workorder', + name='purpose', + field=models.CharField(blank=True, choices=[('defektaza', 'Defektaža'), ('kontrola', 'Kontrola'), ('redovni_pregled', 'Redovni pregled'), ('popravak', 'Popravak')], max_length=50, verbose_name='Svrha'), + ), + ] diff --git a/backend/modules/fleet/migrations/0029_workorderadditionalcoststable.py b/backend/modules/fleet/migrations/0029_workorderadditionalcoststable.py new file mode 100644 index 0000000..8ccfba0 --- /dev/null +++ b/backend/modules/fleet/migrations/0029_workorderadditionalcoststable.py @@ -0,0 +1,31 @@ +from decimal import Decimal + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0028_alter_workorderinvoice_image'), + ] + + operations = [ + migrations.CreateModel( + name='WorkOrderAdditionalCostsTable', + fields=[ + ('id', models.UUIDField(editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_active', models.BooleanField(default=True)), + ('data', models.JSONField(blank=True, default=None, null=True, verbose_name='Podaci tablice dodatnih troškova')), + ('total_for_payout', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12, verbose_name='Ukupno za isplatu')), + ('work_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='additional_costs_table', to='fleet.workorder', verbose_name='Putni nalog')), + ], + options={ + 'verbose_name': 'Tablica dodatnih troškova putnog naloga', + 'verbose_name_plural': 'Tablice dodatnih troškova putnog naloga', + 'ordering': ['-updated_at'], + }, + ), + ] diff --git a/backend/modules/fleet/migrations/0030_merge_20260719_2057.py b/backend/modules/fleet/migrations/0030_merge_20260719_2057.py new file mode 100644 index 0000000..4413045 --- /dev/null +++ b/backend/modules/fleet/migrations/0030_merge_20260719_2057.py @@ -0,0 +1,11 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0029_alter_workorder_purpose'), + ('fleet', '0029_workorderadditionalcoststable'), + ] + + operations = [] diff --git a/backend/modules/fleet/migrations/0031_alter_workorderadditionalcoststable_created_at_and_more.py b/backend/modules/fleet/migrations/0031_alter_workorderadditionalcoststable_created_at_and_more.py new file mode 100644 index 0000000..945cbb0 --- /dev/null +++ b/backend/modules/fleet/migrations/0031_alter_workorderadditionalcoststable_created_at_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.15 on 2026-07-19 19:01 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fleet', '0030_merge_20260719_2057'), + ] + + operations = [ + migrations.AlterField( + model_name='workorderadditionalcoststable', + name='created_at', + field=models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja'), + ), + migrations.AlterField( + model_name='workorderadditionalcoststable', + name='id', + field=models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='workorderadditionalcoststable', + name='is_active', + field=models.BooleanField(default=True, verbose_name='Aktivan zapis'), + ), + migrations.AlterField( + model_name='workorderadditionalcoststable', + name='updated_at', + field=models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene'), + ), + ] diff --git a/backend/modules/fleet/models.py b/backend/modules/fleet/models.py index 9b1dbb1..db55c91 100644 --- a/backend/modules/fleet/models.py +++ b/backend/modules/fleet/models.py @@ -377,6 +377,35 @@ class WorkOrderInvoice(BaseModel): return f"{self.naziv_racuna} ({self.work_order_id})" +class WorkOrderAdditionalCostsTable(BaseModel): + work_order = models.OneToOneField( + WorkOrder, + on_delete=models.CASCADE, + related_name='additional_costs_table', + verbose_name=_("Putni nalog"), + ) + data = models.JSONField( + null=True, + blank=True, + default=None, + verbose_name=_("Podaci tablice dodatnih troškova"), + ) + total_for_payout = models.DecimalField( + max_digits=12, + decimal_places=2, + default=Decimal('0.00'), + verbose_name=_("Ukupno za isplatu"), + ) + + class Meta: + ordering = ['-updated_at'] + verbose_name = _("Tablica dodatnih troškova putnog naloga") + verbose_name_plural = _("Tablice dodatnih troškova putnog naloga") + + def __str__(self): + return f"Additional costs table for work order {self.work_order_id}" + + class WorkOrderPhoto(BaseModel): work_order = models.ForeignKey( WorkOrder, diff --git a/backend/modules/fleet/serializers.py b/backend/modules/fleet/serializers.py index 14bf8c4..b8ad785 100644 --- a/backend/modules/fleet/serializers.py +++ b/backend/modules/fleet/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers import re from pathlib import Path +from decimal import Decimal, InvalidOperation from django.urls import reverse from django.utils import timezone from .models import ( @@ -8,6 +9,7 @@ from .models import ( Vehicle, VehicleNotification, WorkOrder, + WorkOrderAdditionalCostsTable, WorkOrderPhoto, WorkOrderInvoice, VehicleServiceRecord, @@ -268,6 +270,61 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer): return value +class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer): + class Meta: + model = WorkOrderAdditionalCostsTable + fields = ['id', 'work_order', 'data', 'total_for_payout', 'created_at', 'updated_at'] + read_only_fields = ['id', 'total_for_payout', 'created_at', 'updated_at'] + + @staticmethod + 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 _normalize_row(self, row): + return { + 'naziv': str(row.get('naziv', '') or '').strip(), + 'broj_racuna': str(row.get('broj_racuna', '') or '').strip(), + 'ukupan_iznos': str(row.get('ukupan_iznos', '') or '').strip(), + 'prilog': str(row.get('prilog', '') or '').strip(), + 'source_invoice_id': str(row.get('source_invoice_id', '') or '').strip(), + } + + def validate_data(self, value): + if value is None: + return {'rows': []} + if not isinstance(value, dict): + raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt.") + rows = value.get('rows', []) + if not isinstance(rows, list): + raise serializers.ValidationError({"rows": "Polje 'rows' mora biti lista redaka."}) + normalized_rows = [] + for row in rows[:100]: + if not isinstance(row, dict): + raise serializers.ValidationError({"rows": "Svaki redak mora biti JSON objekt."}) + normalized_rows.append(self._normalize_row(row)) + return {'rows': normalized_rows} + + def _calculate_total_for_payout(self, data): + rows = data.get('rows', []) if isinstance(data, dict) else [] + total = sum((self._parse_decimal(row.get('ukupan_iznos')) for row in rows), Decimal('0.00')) + return total.quantize(Decimal('0.01')) + + def create(self, validated_data): + validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data.get('data')) + return super().create(validated_data) + + def update(self, instance, validated_data): + data = validated_data.get('data', instance.data or {'rows': []}) + validated_data['total_for_payout'] = self._calculate_total_for_payout(data) + return super().update(instance, validated_data) + + class WorkOrderPhotoSerializer(serializers.ModelSerializer): class Meta: model = WorkOrderPhoto diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py index c3d7701..517aeb1 100644 --- a/backend/modules/fleet/tasks.py +++ b/backend/modules/fleet/tasks.py @@ -8,7 +8,14 @@ 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 @@ -223,6 +230,148 @@ def build_work_order_invoices_pdf_task(work_order_id): } +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() diff --git a/backend/modules/fleet/tests/test_work_order_images_endpoint.py b/backend/modules/fleet/tests/test_work_order_images_endpoint.py index 71f4e0e..57a809c 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -2,12 +2,14 @@ from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.urls import reverse +from django.utils import timezone from rest_framework.test import APIClient import uuid from io import BytesIO +from datetime import timedelta from PIL import Image -from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto +from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, GeneratedWorkOrderPdf def create_test_image(filename='test.jpg', size=(40, 40), color='red'): @@ -167,3 +169,74 @@ class WorkOrderImagesEndpointTests(TestCase): file_response = self.client.get(image_url) self.assertEqual(file_response.status_code, 200) self.assertEqual(file_response['Content-Type'], 'application/pdf') + + def test_additional_costs_table_can_be_saved_and_total_is_computed(self): + url = f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/" + response = self.client.put( + url, + data={ + "rows": [ + {"naziv": "Cestarina", "broj_racuna": "R-100", "ukupan_iznos": "12,50", "prilog": "racun-1.pdf"}, + {"naziv": "Gorivo", "broj_racuna": "R-101", "ukupan_iznos": "7.50", "prilog": "racun-2.jpg"}, + ] + }, + format='json', + ) + self.assertEqual(response.status_code, 200, response.content) + payload = response.json() + self.assertEqual(payload.get('total_for_payout'), '20.00') + rows = payload.get('data', {}).get('rows', []) + self.assertEqual(len(rows), 2) + + def test_task_service_context_contains_additional_costs_table(self): + table_url = f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/" + save_response = self.client.put( + table_url, + data={"rows": [{"naziv": "Parking", "broj_racuna": "P-1", "ukupan_iznos": "5.00", "prilog": "parking.pdf"}]}, + format='json', + ) + self.assertEqual(save_response.status_code, 200, save_response.content) + + context_response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/task-service-context/") + self.assertEqual(context_response.status_code, 200, context_response.content) + payload = context_response.json() + self.assertIn('additional_costs_table', payload) + self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00') + + def test_invoice_upload_immediately_appears_in_additional_costs_table(self): + create_response = self.client.post( + reverse('work-order-invoice-list'), + data={ + 'work_order': str(self.work_order.pk), + 'naziv_racuna': 'Test2', + 'datum': '2026-07-19', + 'image': create_test_image('test2.jpg'), + }, + format='multipart', + ) + self.assertEqual(create_response.status_code, 201, create_response.content) + + table_response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/") + self.assertEqual(table_response.status_code, 200, table_response.content) + rows = table_response.json().get('data', {}).get('rows', []) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].get('naziv'), 'Test2') + + def test_additional_costs_update_invalidates_work_order_pdf_cache(self): + cached = GeneratedWorkOrderPdf.objects.create( + work_order=self.work_order, + requested_by=self.user, + pdf_type='work_order', + status='ready', + filename='MT010101.work-order.pdf', + expires_at=timezone.now() + timedelta(hours=1), + ) + response = self.client.put( + f"/api/fleet/work-orders/{self.work_order.pk}/additional-costs-table/", + data={"rows": [{"naziv": "Test", "broj_racuna": "R-1", "ukupan_iznos": "10.00", "prilog": "r1.pdf"}]}, + format='json', + ) + self.assertEqual(response.status_code, 200, response.content) + cached.refresh_from_db() + self.assertFalse(cached.is_active) + self.assertEqual(cached.status, 'failed') diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index c5df354..14d7287 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -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 diff --git a/backend/modules/task_management/migrations/0010_alter_tasktemplate_status.py b/backend/modules/task_management/migrations/0010_alter_tasktemplate_status.py new file mode 100644 index 0000000..a12892f --- /dev/null +++ b/backend/modules/task_management/migrations/0010_alter_tasktemplate_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.15 on 2026-07-19 17:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('task_management', '0009_task_scheduled_date'), + ] + + operations = [ + migrations.AlterField( + model_name='tasktemplate', + name='status', + field=models.CharField(choices=[('aktivan', 'Aktivan'), ('servis', 'U tijeku'), ('spreman_za_zavrsetak', 'Spreman za završetak'), ('zavrsen', 'Završen'), ('neaktivan', 'Otkazano')], default='aktivan', max_length=20), + ), + ] diff --git a/frontend/src/components/dashboard/WorkOrderAdditionalCostsModal.jsx b/frontend/src/components/dashboard/WorkOrderAdditionalCostsModal.jsx new file mode 100644 index 0000000..85ca74a --- /dev/null +++ b/frontend/src/components/dashboard/WorkOrderAdditionalCostsModal.jsx @@ -0,0 +1,186 @@ +import { useEffect, useMemo, useState } from 'preact/hooks'; +import ModalShell from '../ui/ModalShell'; + +const EMPTY_ROW = { + naziv: '', + broj_racuna: '', + ukupan_iznos: '', + prilog: '', + source_invoice_id: '', +}; + +function parseAmount(value) { + const normalized = String(value || '').trim().replace('€', '').replace(/\s+/g, '').replace(',', '.'); + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : 0; +} + +export default function WorkOrderAdditionalCostsModal({ + open, + workOrder, + tableData, + saving = false, + onClose, + onSave, +}) { + const [rows, setRows] = useState([]); + + useEffect(() => { + if (!open) { + setRows([]); + return; + } + const incoming = Array.isArray(tableData?.data?.rows) ? tableData.data.rows : []; + setRows(incoming.length > 0 ? incoming.map((row) => ({ ...EMPTY_ROW, ...(row || {}) })) : [{ ...EMPTY_ROW }]); + }, [open, tableData]); + + const total = useMemo(() => { + const value = rows.reduce((sum, row) => sum + parseAmount(row.ukupan_iznos), 0); + return value.toFixed(2); + }, [rows]); + + if (!open) return null; + + const updateRowField = (index, field, value) => { + setRows((prev) => prev.map((row, i) => (i === index ? { ...row, [field]: value } : row))); + }; + + const addRow = () => setRows((prev) => [...prev, { ...EMPTY_ROW }]); + const removeRow = (index) => setRows((prev) => prev.filter((_, i) => i !== index)); + + const handleSave = async () => { + const payloadRows = rows.map((row) => ({ + naziv: String(row.naziv || '').trim(), + broj_racuna: String(row.broj_racuna || '').trim(), + ukupan_iznos: String(row.ukupan_iznos || '').trim(), + prilog: String(row.prilog || '').trim(), + source_invoice_id: String(row.source_invoice_id || '').trim(), + })); + await onSave?.({ rows: payloadRows }); + }; + + return ( + +
+
+
+

DODATNI TROŠKOVI

+

{workOrder?.display_code || workOrder?.id || '-'}

+
+ +
+ +
+
+ +
+ +
+ + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + ))} + + + + + +
#NAZIVBroj računaUkupan iznosPrilogAkcija
{index + 1} + updateRowField(index, 'naziv', event.currentTarget.value)} + className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900" + /> + + updateRowField(index, 'broj_racuna', event.currentTarget.value)} + className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900" + /> + + updateRowField(index, 'ukupan_iznos', event.currentTarget.value)} + className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900" + /> + + updateRowField(index, 'prilog', event.currentTarget.value)} + className="block w-full rounded-lg border border-gray-300 bg-gray-50 px-2.5 py-2 text-xs text-gray-900" + /> + + +
UKUPNO ZA ISPLATU{total} € +
+
+
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx index 50a79fc..edb9df8 100644 --- a/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx +++ b/frontend/src/components/dashboard/WorkOrderInvoicesPdfPage.jsx @@ -4,16 +4,19 @@ import { downloadWorkOrderPdf, downloadWorkOrderInvoicesPdf, downloadWorkOrderServiceRecordsPdf, + fetchWorkOrderAdditionalCostsTable, fetchWorkOrderById, fetchWorkOrderInvoices, fetchWorkOrderTaskServiceContext, updateTaskWorkHoursTable, + updateWorkOrderAdditionalCostsTable, } from '../../stores/fleetDashboardStore'; import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore'; import { fetchNotifications, connectNotifications } from '../../stores/notificationStore'; import { formatWorkOrderDisplayCode } from '../../lib/displayIds'; import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel'; import TaskWorkHoursTableModal from './TaskWorkHoursTableModal'; +import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal'; function readWorkOrderIdFromQuery() { if (typeof window === 'undefined') { @@ -71,6 +74,9 @@ export default function WorkOrderInvoicesPdfPage() { const [error, setError] = useState(''); const [editingTask, setEditingTask] = useState(null); const [savingWorkHours, setSavingWorkHours] = useState(false); + const [additionalCostsTable, setAdditionalCostsTable] = useState({ data: { rows: [] }, total_for_payout: '0.00' }); + const [editingAdditionalCosts, setEditingAdditionalCosts] = useState(false); + const [savingAdditionalCosts, setSavingAdditionalCosts] = useState(false); useEffect(() => { hydrateAuthFromStorage(); @@ -105,10 +111,11 @@ export default function WorkOrderInvoicesPdfPage() { (async () => { try { - const [contextPayload, order, invoiceItems] = await Promise.all([ + const [contextPayload, order, invoiceItems, additionalCostsPayload] = await Promise.all([ fetchWorkOrderTaskServiceContext(workOrderId), fetchWorkOrderById(workOrderId), fetchWorkOrderInvoices(workOrderId), + fetchWorkOrderAdditionalCostsTable(workOrderId), ]); if (cancelled) { return; @@ -116,6 +123,7 @@ export default function WorkOrderInvoicesPdfPage() { setTaskContext(contextPayload); setWorkOrder(order); setInvoices(invoiceItems); + setAdditionalCostsTable(additionalCostsPayload || contextPayload?.additional_costs_table || { data: { rows: [] }, total_for_payout: '0.00' }); } catch (err) { if (!cancelled) { setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.'); @@ -187,6 +195,18 @@ export default function WorkOrderInvoicesPdfPage() { } }; + const handleSaveAdditionalCostsTable = async (tableData) => { + if (!workOrderId) return; + setSavingAdditionalCosts(true); + try { + const response = await updateWorkOrderAdditionalCostsTable(workOrderId, tableData); + setAdditionalCostsTable(response || { data: { rows: [] }, total_for_payout: '0.00' }); + setEditingAdditionalCosts(false); + } finally { + setSavingAdditionalCosts(false); + } + }; + return (
@@ -289,15 +309,30 @@ export default function WorkOrderInvoicesPdfPage() {

Računi

- +
+ + +
+ {!loading && !error && ( +

+ Ukupno dodatni troškovi: {additionalCostsTable?.total_for_payout || '0.00'} € +

+ )} {loading &&

Učitavanje računa...

} {!loading && error && (
@@ -337,6 +372,14 @@ export default function WorkOrderInvoicesPdfPage() { onClose={() => setEditingTask(null)} onSave={handleSaveWorkHoursTable} /> + setEditingAdditionalCosts(false)} + onSave={handleSaveAdditionalCostsTable} + />
); } diff --git a/frontend/src/stores/fleetDashboardStore.js b/frontend/src/stores/fleetDashboardStore.js index 24e2b45..a692a14 100644 --- a/frontend/src/stores/fleetDashboardStore.js +++ b/frontend/src/stores/fleetDashboardStore.js @@ -712,10 +712,32 @@ export async function fetchWorkOrderById(workOrderId) { export async function fetchWorkOrderTaskServiceContext(workOrderId) { if (!workOrderId) { - return { tasks: [] }; + return { tasks: [], additional_costs_table: null }; } const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`); - return payload && typeof payload === 'object' ? payload : { tasks: [] }; + return payload && typeof payload === 'object' ? payload : { tasks: [], additional_costs_table: null }; +} + +export async function fetchWorkOrderAdditionalCostsTable(workOrderId) { + if (!workOrderId) { + return { data: { rows: [] }, total_for_payout: '0.00' }; + } + const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/additional-costs-table/`); + return payload && typeof payload === 'object' + ? payload + : { data: { rows: [] }, total_for_payout: '0.00' }; +} + +export async function updateWorkOrderAdditionalCostsTable(workOrderId, data) { + if (!workOrderId) { + throw new Error('Work order ID je obavezan.'); + } + const payload = await api.put( + `fleet/work-orders/${encodeURIComponent(workOrderId)}/additional-costs-table/`, + data ?? { rows: [] }, + ); + showToast('Dodatni troškovi su uspješno spremljeni.', 'success'); + return payload; } export async function updateTaskWorkHoursTable(taskId, data) {