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:
@@ -7,6 +7,7 @@ from .models import (
|
|||||||
WorkOrder,
|
WorkOrder,
|
||||||
WorkOrderPhoto,
|
WorkOrderPhoto,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
|
WorkOrderAdditionalCostsTable,
|
||||||
GeneratedWorkOrderPdf,
|
GeneratedWorkOrderPdf,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
VehicleServicePhoto,
|
VehicleServicePhoto,
|
||||||
@@ -196,6 +197,14 @@ class WorkOrderInvoiceAdmin(admin.ModelAdmin):
|
|||||||
ordering = ('-datum', '-created_at')
|
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)
|
@admin.register(GeneratedWorkOrderPdf)
|
||||||
class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin):
|
class GeneratedWorkOrderPdfAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at')
|
list_display = ('id', 'work_order', 'pdf_type', 'status', 'requested_by', 'expires_at', 'is_active', 'created_at')
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
11
backend/modules/fleet/migrations/0030_merge_20260719_2057.py
Normal file
11
backend/modules/fleet/migrations/0030_merge_20260719_2057.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('fleet', '0029_alter_workorder_purpose'),
|
||||||
|
('fleet', '0029_workorderadditionalcoststable'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -377,6 +377,35 @@ class WorkOrderInvoice(BaseModel):
|
|||||||
return f"{self.naziv_racuna} ({self.work_order_id})"
|
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):
|
class WorkOrderPhoto(BaseModel):
|
||||||
work_order = models.ForeignKey(
|
work_order = models.ForeignKey(
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from .models import (
|
from .models import (
|
||||||
@@ -8,6 +9,7 @@ from .models import (
|
|||||||
Vehicle,
|
Vehicle,
|
||||||
VehicleNotification,
|
VehicleNotification,
|
||||||
WorkOrder,
|
WorkOrder,
|
||||||
|
WorkOrderAdditionalCostsTable,
|
||||||
WorkOrderPhoto,
|
WorkOrderPhoto,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
@@ -268,6 +270,61 @@ class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
|
|||||||
return value
|
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 WorkOrderPhotoSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WorkOrderPhoto
|
model = WorkOrderPhoto
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import logging
|
|||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import base64
|
import base64
|
||||||
|
import re
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
from PIL import Image, UnidentifiedImageError
|
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.pagesizes import A4
|
||||||
from reportlab.lib.utils import ImageReader
|
from reportlab.lib.utils import ImageReader
|
||||||
from reportlab.pdfgen import canvas
|
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'(?<!\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
|
@shared_task
|
||||||
def cleanup_expired_generated_pdfs_task():
|
def cleanup_expired_generated_pdfs_task():
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
import uuid
|
import uuid
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from datetime import timedelta
|
||||||
from PIL import Image
|
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'):
|
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)
|
file_response = self.client.get(image_url)
|
||||||
self.assertEqual(file_response.status_code, 200)
|
self.assertEqual(file_response.status_code, 200)
|
||||||
self.assertEqual(file_response['Content-Type'], 'application/pdf')
|
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')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from io import BytesIO
|
|||||||
import base64
|
import base64
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -35,6 +36,7 @@ from .models import (
|
|||||||
WorkOrderPhoto,
|
WorkOrderPhoto,
|
||||||
GeneratedWorkOrderPdf,
|
GeneratedWorkOrderPdf,
|
||||||
WorkOrderInvoice,
|
WorkOrderInvoice,
|
||||||
|
WorkOrderAdditionalCostsTable,
|
||||||
VehicleServiceRecord,
|
VehicleServiceRecord,
|
||||||
VehicleServicePhoto,
|
VehicleServicePhoto,
|
||||||
VehicleServiceAttachment,
|
VehicleServiceAttachment,
|
||||||
@@ -44,6 +46,7 @@ from .serializers import (
|
|||||||
VehicleSerializer,
|
VehicleSerializer,
|
||||||
WorkOrderSerializer,
|
WorkOrderSerializer,
|
||||||
WorkOrderInvoiceSerializer,
|
WorkOrderInvoiceSerializer,
|
||||||
|
WorkOrderAdditionalCostsTableSerializer,
|
||||||
WorkOrderPhotoSerializer,
|
WorkOrderPhotoSerializer,
|
||||||
VehicleServiceRecordSerializer,
|
VehicleServiceRecordSerializer,
|
||||||
VehicleNotificationSerializer,
|
VehicleNotificationSerializer,
|
||||||
@@ -60,6 +63,7 @@ from .tasks import (
|
|||||||
build_work_order_invoices_pdf_task,
|
build_work_order_invoices_pdf_task,
|
||||||
build_work_order_pdf_cached_task,
|
build_work_order_pdf_cached_task,
|
||||||
cleanup_expired_generated_pdfs_task,
|
cleanup_expired_generated_pdfs_task,
|
||||||
|
process_work_order_invoice_ocr,
|
||||||
)
|
)
|
||||||
|
|
||||||
register_unicode_fonts()
|
register_unicode_fonts()
|
||||||
@@ -197,6 +201,60 @@ def _cleanup_expired_generated_pdfs():
|
|||||||
item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at'])
|
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):
|
def _cached_pdf_file_response(generated_pdf, *, default_filename):
|
||||||
generated_pdf.file.open('rb')
|
generated_pdf.file.open('rb')
|
||||||
filename = generated_pdf.filename or default_filename
|
filename = generated_pdf.filename or default_filename
|
||||||
@@ -371,6 +429,15 @@ def _build_work_order_pdf(work_order):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return "0,00 €"
|
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):
|
def _paragraph(text, *, bold=False, size=9, align=0):
|
||||||
styles = getSampleStyleSheet()
|
styles = getSampleStyleSheet()
|
||||||
base = styles['BodyText'].clone('wo-p')
|
base = styles['BodyText'].clone('wo-p')
|
||||||
@@ -400,11 +467,24 @@ def _build_work_order_pdf(work_order):
|
|||||||
daily_rate = 0.0
|
daily_rate = 0.0
|
||||||
daily_total = daily_qty * daily_rate
|
daily_total = daily_qty * daily_rate
|
||||||
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
|
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'
|
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'
|
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]
|
additional_table = getattr(work_order, 'additional_costs_table', None)
|
||||||
attachments_text = ', '.join(invoice_names) if invoice_names else '-'
|
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 = (
|
assigned_servicer_vehicle = (
|
||||||
creator.assigned_vehicles
|
creator.assigned_vehicles
|
||||||
.filter(asset_type='vehicle', is_active=True)
|
.filter(asset_type='vehicle', is_active=True)
|
||||||
@@ -620,8 +700,20 @@ def _build_work_order_pdf(work_order):
|
|||||||
|
|
||||||
# 5) Dodatni troškovi
|
# 5) Dodatni troškovi
|
||||||
additional_rows = [["DODATNI TROŠKOVI", "", ""], ["NAZIV", "Broj računa", "Ukupan iznos"]]
|
additional_rows = [["DODATNI TROŠKOVI", "", ""], ["NAZIV", "Broj računa", "Ukupan iznos"]]
|
||||||
for inv in invoices[:5]:
|
if additional_rows_data:
|
||||||
additional_rows.append([inv.naziv_racuna or '-', str(inv.pk), "-"])
|
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:
|
while len(additional_rows) < 7:
|
||||||
additional_rows.append(["", "", ""])
|
additional_rows.append(["", "", ""])
|
||||||
y = draw_table(
|
y = draw_table(
|
||||||
@@ -633,6 +725,7 @@ def _build_work_order_pdf(work_order):
|
|||||||
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
('GRID', (0, 0), (-1, -1), 0.9, colors.black),
|
||||||
('FONTNAME', (0, 0), (2, 1), 'Vera-Bold'),
|
('FONTNAME', (0, 0), (2, 1), 'Vera-Bold'),
|
||||||
('FONTNAME', (0, 2), (-1, -1), 'Vera'),
|
('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),
|
('FONTSIZE', (0, 0), (-1, -1), 8.4),
|
||||||
('ALIGN', (0, 0), (2, 0), 'CENTER'),
|
('ALIGN', (0, 0), (2, 0), 'CENTER'),
|
||||||
('ALIGN', (2, 1), (2, -1), 'CENTER'),
|
('ALIGN', (2, 1), (2, -1), 'CENTER'),
|
||||||
@@ -1692,11 +1785,49 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
|
|||||||
'service_records': records_payload,
|
'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({
|
return Response({
|
||||||
'work_order_id': work_order.pk,
|
'work_order_id': work_order.pk,
|
||||||
'tasks': payload,
|
'tasks': payload,
|
||||||
|
'additional_costs_table': additional_costs_payload,
|
||||||
}, status=status.HTTP_200_OK)
|
}, 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')
|
@action(detail=True, methods=['post'], url_path='send-email')
|
||||||
def send_email(self, request, pk=None):
|
def send_email(self, request, pk=None):
|
||||||
work_order = self.get_object()
|
work_order = self.get_object()
|
||||||
@@ -1791,7 +1922,7 @@ class WorkOrderInvoiceViewSet(viewsets.ModelViewSet):
|
|||||||
def image(self, request, pk=None):
|
def image(self, request, pk=None):
|
||||||
invoice = self.get_object()
|
invoice = self.get_object()
|
||||||
if not invoice.image:
|
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()
|
suffix = Path(invoice.image.name or '').suffix.lower()
|
||||||
if suffix == '.pdf':
|
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()
|
allowed = _work_orders_queryset_for_user(self.request.user).filter(pk=work_order.pk).exists()
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise DRFValidationError({"work_order": "Nemate dozvolu za odabrani putni nalog."})
|
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):
|
def perform_destroy(self, instance):
|
||||||
instance.is_active = False
|
instance.is_active = False
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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 (
|
||||||
|
<ModalShell
|
||||||
|
onClose={onClose}
|
||||||
|
overlayClassName="z-[70] overflow-y-auto p-4 pt-16"
|
||||||
|
contentClassName="flex min-h-full items-start justify-center"
|
||||||
|
panelClassName="w-full max-w-5xl rounded-xl border border-border-hairline bg-canvas-elevated shadow-xl overflow-hidden max-h-[calc(100vh-2rem)]"
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-hairline px-5 py-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-text-main">DODATNI TROŠKOVI</h3>
|
||||||
|
<p className="text-xs text-text-muted">{workOrder?.display_code || workOrder?.id || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Zatvori"
|
||||||
|
className="ml-4 rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 overflow-y-auto px-5 py-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addRow}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
+ Dodaj redak
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto rounded-xl border border-gray-200 shadow-sm dark:border-gray-700">
|
||||||
|
<table className="min-w-[900px] w-full bg-white text-xs dark:bg-gray-900">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 bg-gray-50 text-left dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<th className="w-8 px-2 py-2.5 text-center text-gray-400">#</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">NAZIV</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Broj računa</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Ukupan iznos</th>
|
||||||
|
<th className="px-2 py-2.5 font-semibold text-gray-700 dark:text-gray-200">Prilog</th>
|
||||||
|
<th className="px-2 py-2.5 text-gray-400">Akcija</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={index} className="border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<td className="px-2 py-1.5 text-center text-[11px] text-gray-400">{index + 1}</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.naziv}
|
||||||
|
onInput={(event) => 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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.broj_racuna}
|
||||||
|
onInput={(event) => 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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.ukupan_iznos}
|
||||||
|
onInput={(event) => 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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={row.prilog}
|
||||||
|
onInput={(event) => 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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-1 py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeRow(index)}
|
||||||
|
className="rounded-md border border-red-200 px-2 py-1 text-[11px] font-medium text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Obriši
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="border-t-2 border-gray-300 bg-gray-100 text-xs font-bold dark:border-gray-600 dark:bg-gray-800">
|
||||||
|
<td colSpan={3} className="px-3 py-2 text-gray-700 dark:text-gray-200">UKUPNO ZA ISPLATU</td>
|
||||||
|
<td className="px-2 py-2 text-right text-gray-800 dark:text-gray-100">{total} €</td>
|
||||||
|
<td colSpan={2} />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 border-t border-border-hairline px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Odustani
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? 'Spremam…' : 'Spremi dodatne troškove'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,16 +4,19 @@ import {
|
|||||||
downloadWorkOrderPdf,
|
downloadWorkOrderPdf,
|
||||||
downloadWorkOrderInvoicesPdf,
|
downloadWorkOrderInvoicesPdf,
|
||||||
downloadWorkOrderServiceRecordsPdf,
|
downloadWorkOrderServiceRecordsPdf,
|
||||||
|
fetchWorkOrderAdditionalCostsTable,
|
||||||
fetchWorkOrderById,
|
fetchWorkOrderById,
|
||||||
fetchWorkOrderInvoices,
|
fetchWorkOrderInvoices,
|
||||||
fetchWorkOrderTaskServiceContext,
|
fetchWorkOrderTaskServiceContext,
|
||||||
updateTaskWorkHoursTable,
|
updateTaskWorkHoursTable,
|
||||||
|
updateWorkOrderAdditionalCostsTable,
|
||||||
} from '../../stores/fleetDashboardStore';
|
} from '../../stores/fleetDashboardStore';
|
||||||
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
import { $accessToken, $authReady, hydrateAuthFromStorage } from '../../stores/authStore';
|
||||||
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
import { fetchNotifications, connectNotifications } from '../../stores/notificationStore';
|
||||||
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
import { formatWorkOrderDisplayCode } from '../../lib/displayIds';
|
||||||
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
import { resolveMediaUrl, useAuthenticatedMediaSources } from './WorkOrderImageCarousel';
|
||||||
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
import TaskWorkHoursTableModal from './TaskWorkHoursTableModal';
|
||||||
|
import WorkOrderAdditionalCostsModal from './WorkOrderAdditionalCostsModal';
|
||||||
|
|
||||||
function readWorkOrderIdFromQuery() {
|
function readWorkOrderIdFromQuery() {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -71,6 +74,9 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [editingTask, setEditingTask] = useState(null);
|
const [editingTask, setEditingTask] = useState(null);
|
||||||
const [savingWorkHours, setSavingWorkHours] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
hydrateAuthFromStorage();
|
hydrateAuthFromStorage();
|
||||||
@@ -105,10 +111,11 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [contextPayload, order, invoiceItems] = await Promise.all([
|
const [contextPayload, order, invoiceItems, additionalCostsPayload] = await Promise.all([
|
||||||
fetchWorkOrderTaskServiceContext(workOrderId),
|
fetchWorkOrderTaskServiceContext(workOrderId),
|
||||||
fetchWorkOrderById(workOrderId),
|
fetchWorkOrderById(workOrderId),
|
||||||
fetchWorkOrderInvoices(workOrderId),
|
fetchWorkOrderInvoices(workOrderId),
|
||||||
|
fetchWorkOrderAdditionalCostsTable(workOrderId),
|
||||||
]);
|
]);
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -116,6 +123,7 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
setTaskContext(contextPayload);
|
setTaskContext(contextPayload);
|
||||||
setWorkOrder(order);
|
setWorkOrder(order);
|
||||||
setInvoices(invoiceItems);
|
setInvoices(invoiceItems);
|
||||||
|
setAdditionalCostsTable(additionalCostsPayload || contextPayload?.additional_costs_table || { data: { rows: [] }, total_for_payout: '0.00' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(err?.message || 'Neuspješno dohvaćanje podataka o računima.');
|
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 (
|
return (
|
||||||
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
|
<section className="mx-auto w-full max-w-5xl space-y-4 p-4 sm:p-6">
|
||||||
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||||
@@ -289,15 +309,30 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
<div className="rounded-xl border border-border-hairline bg-canvas-elevated p-4">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 className="text-lg font-semibold text-text-main">Računi</h2>
|
<h2 className="text-lg font-semibold text-text-main">Računi</h2>
|
||||||
<button
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => downloadWorkOrderInvoicesPdf(workOrderId)}
|
type="button"
|
||||||
disabled={!workOrderId}
|
onClick={() => setEditingAdditionalCosts(true)}
|
||||||
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
disabled={!workOrderId}
|
||||||
>
|
className="rounded-lg border border-border-hairline px-4 py-2 text-sm font-semibold text-text-main hover:bg-canvas-deep disabled:opacity-60"
|
||||||
Preuzmi račune putnog naloga
|
>
|
||||||
</button>
|
Uredi dodatne troškove
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadWorkOrderInvoicesPdf(workOrderId)}
|
||||||
|
disabled={!workOrderId}
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Preuzmi račune putnog naloga
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!loading && !error && (
|
||||||
|
<p className="mb-3 text-sm text-text-muted">
|
||||||
|
Ukupno dodatni troškovi: <span className="font-semibold text-text-main">{additionalCostsTable?.total_for_payout || '0.00'} €</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{loading && <p className="text-sm text-text-muted">Učitavanje računa...</p>}
|
{loading && <p className="text-sm text-text-muted">Učitavanje računa...</p>}
|
||||||
{!loading && error && (
|
{!loading && error && (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||||
@@ -337,6 +372,14 @@ export default function WorkOrderInvoicesPdfPage() {
|
|||||||
onClose={() => setEditingTask(null)}
|
onClose={() => setEditingTask(null)}
|
||||||
onSave={handleSaveWorkHoursTable}
|
onSave={handleSaveWorkHoursTable}
|
||||||
/>
|
/>
|
||||||
|
<WorkOrderAdditionalCostsModal
|
||||||
|
open={editingAdditionalCosts}
|
||||||
|
workOrder={workOrder}
|
||||||
|
tableData={additionalCostsTable}
|
||||||
|
saving={savingAdditionalCosts}
|
||||||
|
onClose={() => setEditingAdditionalCosts(false)}
|
||||||
|
onSave={handleSaveAdditionalCostsTable}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -712,10 +712,32 @@ export async function fetchWorkOrderById(workOrderId) {
|
|||||||
|
|
||||||
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
export async function fetchWorkOrderTaskServiceContext(workOrderId) {
|
||||||
if (!workOrderId) {
|
if (!workOrderId) {
|
||||||
return { tasks: [] };
|
return { tasks: [], additional_costs_table: null };
|
||||||
}
|
}
|
||||||
const payload = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/task-service-context/`);
|
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) {
|
export async function updateTaskWorkHoursTable(taskId, data) {
|
||||||
|
|||||||
Reference in New Issue
Block a user