feat: dodaj dodatne troškove s OCR sinkronizacijom

Dodana je tablica DODATNI TROŠKOVI za putni nalog (backend model + API +
frontend modal) s izračunom ukupnog iznosa za isplatu.

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-19 21:23:29 +02:00
parent d35eaeccf4
commit ae35a6041d
14 changed files with 836 additions and 19 deletions

View File

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