2 Commits

Author SHA1 Message Date
mariomitte
61996bd1a0 fix: premjesti gumb tablice sati u zaglavlje servisnih zapisa
Some checks failed
ERP CI/CD Pipeline / test (push) Has been cancelled
ERP CI/CD Pipeline / Deploy (server git pull + compose) (push) Has been cancelled
U /putni-nalozi/racuni gumb 'Uredi tablicu radnih sati' premješten je
iz kartica pojedinih taskova u zaglavlje grupe Servisni zapisi, pokraj
gumba za preuzimanje PDF-a, po istom UX obrascu kao dodatni troškovi.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 21:41:00 +02:00
mariomitte
ae35a6041d feat: dodaj dodatne troškove s OCR sinkronizacijom
Dodana je tablica DODATNI TROŠKOVI za putni nalog (backend model + API +
frontend modal) s izračunom ukupnog iznosa za isplatu.

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 21:23:29 +02:00
14 changed files with 858 additions and 36 deletions

View File

@@ -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')

View File

@@ -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'),
),
]

View File

@@ -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'],
},
),
]

View File

@@ -0,0 +1,11 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('fleet', '0029_alter_workorder_purpose'),
('fleet', '0029_workorderadditionalcoststable'),
]
operations = []

View File

@@ -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'),
),
]

View File

@@ -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,

View File

@@ -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

View File

@@ -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'(?<!\d)(\d{1,3}(?:[.\s]\d{3})*(?:,\d{2})|\d+(?:[.,]\d{2}))(?!\d)'
return [_parse_decimal(match) for match in re.findall(number_pattern, line)]
preferred_lines = []
for line in text.splitlines():
lower = line.lower()
if any(token in lower for token in ('ukupno', 'za platiti', 'iznos', 'total')):
preferred_lines.append(line)
for line in preferred_lines:
candidates = [value for value in _line_candidates(line) if value > Decimal('0.00')]
if candidates:
return max(candidates)
all_candidates = []
for line in text.splitlines():
all_candidates.extend([value for value in _line_candidates(line) if value > Decimal('0.00')])
if all_candidates:
return max(all_candidates)
return Decimal('0.00')
@shared_task
def process_work_order_invoice_ocr(invoice_id):
from .models import WorkOrderInvoice, WorkOrderAdditionalCostsTable, GeneratedWorkOrderPdf
invoice = (
WorkOrderInvoice.objects
.select_related('work_order')
.filter(pk=invoice_id, is_active=True)
.first()
)
if invoice is None or invoice.work_order_id is None:
return {"status": "skipped", "reason": "invoice-not-found"}
try:
text = _extract_invoice_text(invoice)
except (UnidentifiedImageError, OSError, TesseractNotFoundError, ValueError, RuntimeError, PdfReadError):
text = ''
attachment_name = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else ''
row = {
'naziv': (invoice.naziv_racuna or '').strip() or attachment_name or f'Račun {invoice.pk}',
'broj_racuna': _extract_invoice_number(text, fallback=str(invoice.pk)),
'ukupan_iznos': f"{_extract_total_amount(text):.2f}",
'prilog': attachment_name,
'source_invoice_id': str(invoice.pk),
}
with transaction.atomic():
table, _ = WorkOrderAdditionalCostsTable.objects.select_for_update().get_or_create(
work_order=invoice.work_order,
defaults={'data': {'rows': []}},
)
existing_rows = table.data.get('rows', []) if isinstance(table.data, dict) else []
existing_row = next(
(
item for item in existing_rows
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) == str(invoice.pk)
),
None,
)
normalized_rows = [
item for item in existing_rows
if isinstance(item, dict) and str(item.get('source_invoice_id', '')) != str(invoice.pk)
]
if existing_row and row['ukupan_iznos'] == '0.00':
existing_amount = str(existing_row.get('ukupan_iznos', '') or '').strip()
if existing_amount:
row['ukupan_iznos'] = existing_amount
if existing_row and not row['broj_racuna']:
row['broj_racuna'] = str(existing_row.get('broj_racuna', '') or '').strip()
normalized_rows.append(row)
total = sum((_parse_decimal(item.get('ukupan_iznos')) for item in normalized_rows if isinstance(item, dict)), Decimal('0.00'))
table.data = {'rows': normalized_rows}
table.total_for_payout = total.quantize(Decimal('0.01'))
table.save(update_fields=['data', 'total_for_payout', 'updated_at'])
GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order=invoice.work_order,
pdf_type__in=['work_order', 'invoices'],
).update(
is_active=False,
status='failed',
error_message='PDF cache invalidiran zbog OCR ažuriranja dodatnih troškova.',
)
return {"status": "ok", "invoice_id": str(invoice.pk)}
@shared_task
def cleanup_expired_generated_pdfs_task():
now = timezone.now()

View File

@@ -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')

View File

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

View File

@@ -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),
),
]

View File

@@ -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>
);
}

View File

@@ -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 (
<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">
@@ -211,14 +231,28 @@ export default function WorkOrderInvoicesPdfPage() {
<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">
<h2 className="text-lg font-semibold text-text-main">Servisni zapisi</h2>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(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 PDF servisnih zapisa
</button>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
const firstTask = Array.isArray(taskContext.tasks) ? taskContext.tasks[0] : null;
if (!firstTask) return;
setEditingTask(firstTask);
}}
disabled={!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0}
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"
>
Uredi tablicu radnih sati
</button>
<button
type="button"
onClick={() => downloadWorkOrderServiceRecordsPdf(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 PDF servisnih zapisa
</button>
</div>
</div>
{loading && <p className="text-sm text-text-muted">Učitavanje task konteksta...</p>}
{!loading && !error && (!Array.isArray(taskContext.tasks) || taskContext.tasks.length === 0) && (
@@ -234,15 +268,6 @@ export default function WorkOrderInvoicesPdfPage() {
<div><span className="font-semibold">Dodijeljeno:</span> {task.assigned_to_name || '-'}</div>
<div><span className="font-semibold">Dizalica:</span> {task.vehicle_registration || '-'}</div>
</div>
<div className="mt-2">
<button
type="button"
onClick={() => setEditingTask(task)}
className="rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Uredi tablicu radnih sati
</button>
</div>
<div className="mt-3 space-y-3">
{!Array.isArray(task.service_records) || task.service_records.length === 0 ? (
<p className="text-xs text-text-muted">Nema povezanih servisnih zapisa.</p>
@@ -289,15 +314,30 @@ export default function WorkOrderInvoicesPdfPage() {
<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">
<h2 className="text-lg font-semibold text-text-main">Računi</h2>
<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 className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setEditingAdditionalCosts(true)}
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"
>
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>
{!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 && error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
@@ -337,6 +377,14 @@ export default function WorkOrderInvoicesPdfPage() {
onClose={() => setEditingTask(null)}
onSave={handleSaveWorkHoursTable}
/>
<WorkOrderAdditionalCostsModal
open={editingAdditionalCosts}
workOrder={workOrder}
tableData={additionalCostsTable}
saving={savingAdditionalCosts}
onClose={() => setEditingAdditionalCosts(false)}
onSave={handleSaveAdditionalCostsTable}
/>
</section>
);
}

View File

@@ -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) {