fix: add editable work order travel expenses
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

Introduce a dedicated travel-expenses table for work orders so Broj sati, Količina dnevnica, and Iznos dnevnice can be reviewed and edited before PDF generation. The PDF now uses the stored travel-expenses values, with default HR rate and cache invalidation on updates.
This commit is contained in:
mariomitte
2026-08-08 03:52:41 +02:00
parent a9ab83c202
commit 8577aaa01b
8 changed files with 697 additions and 35 deletions

View File

@@ -0,0 +1,34 @@
from decimal import Decimal
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fleet', '0035_generatedfleetarchive'),
]
operations = [
migrations.CreateModel(
name='WorkOrderTravelExpensesTable',
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)),
('broj_sati', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Broj sati')),
('kolicina_dnevnica', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=10, verbose_name='Količina dnevnica')),
('iznos_dnevnica', models.DecimalField(decimal_places=2, default=Decimal('30.00'), max_digits=10, verbose_name='Iznos dnevnice')),
('daily_rate_country', models.CharField(choices=[('HR', 'Hrvatska'), ('BIH', 'BiH'), ('SI', 'Slovenija'), ('CG', 'Crna Gora')], default='HR', max_length=8, verbose_name='Država dnevnice')),
('total_for_payout', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=12, verbose_name='Ukupan iznos')),
('work_order', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='travel_expenses_table', to='fleet.workorder', verbose_name='Putni nalog')),
],
options={
'verbose_name': 'Tablica obračuna putnih troškova',
'verbose_name_plural': 'Tablice obračuna putnih troškova',
'ordering': ['-updated_at'],
},
),
]

View File

@@ -417,6 +417,63 @@ class WorkOrderAdditionalCostsTable(BaseModel):
return f"Additional costs table for work order {self.work_order_id}"
class WorkOrderTravelExpensesTable(BaseModel):
RATE_CHOICES = [
('HR', _("Hrvatska")),
('BIH', _("BiH")),
('SI', _("Slovenija")),
('CG', _("Crna Gora")),
]
DEFAULT_RATE_COUNTRY = 'HR'
DEFAULT_RATE_AMOUNT = Decimal('30.00')
work_order = models.OneToOneField(
WorkOrder,
on_delete=models.CASCADE,
related_name='travel_expenses_table',
verbose_name=_("Putni nalog"),
)
broj_sati = models.DecimalField(
max_digits=10,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Broj sati"),
)
kolicina_dnevnica = models.DecimalField(
max_digits=10,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Količina dnevnica"),
)
iznos_dnevnica = models.DecimalField(
max_digits=10,
decimal_places=2,
default=DEFAULT_RATE_AMOUNT,
verbose_name=_("Iznos dnevnice"),
)
daily_rate_country = models.CharField(
max_length=8,
choices=RATE_CHOICES,
default=DEFAULT_RATE_COUNTRY,
verbose_name=_("Država dnevnice"),
)
total_for_payout = models.DecimalField(
max_digits=12,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_("Ukupan iznos"),
)
class Meta:
ordering = ['-updated_at']
verbose_name = _("Tablica obračuna putnih troškova")
verbose_name_plural = _("Tablice obračuna putnih troškova")
def __str__(self):
return f"Travel expenses table for work order {self.work_order_id}"
class WorkOrderPhoto(BaseModel):
work_order = models.ForeignKey(
WorkOrder,

View File

@@ -14,6 +14,7 @@ from .models import (
VehicleNotification,
WorkOrder,
WorkOrderAdditionalCostsTable,
WorkOrderTravelExpensesTable,
WorkOrderPhoto,
WorkOrderInvoice,
VehicleServiceRecord,
@@ -501,6 +502,105 @@ class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer):
return super().update(instance, validated_data)
class WorkOrderTravelExpensesTableSerializer(serializers.ModelSerializer):
RATE_DEFAULTS = {
'HR': Decimal('30.00'),
'BIH': Decimal('50.00'),
'SI': Decimal('80.00'),
'CG': Decimal('50.00'),
}
class Meta:
model = WorkOrderTravelExpensesTable
fields = [
'id',
'work_order',
'broj_sati',
'kolicina_dnevnica',
'iznos_dnevnica',
'daily_rate_country',
'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 None
try:
normalized = str(value).strip().replace('', '').replace(' ', '').replace(',', '.')
return Decimal(normalized)
except (InvalidOperation, ValueError, TypeError):
raise serializers.ValidationError('Neispravna decimalna vrijednost.')
def validate_daily_rate_country(self, value):
normalized = str(value or '').strip().upper()
if not normalized:
return WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY
valid_choices = {choice for choice, _label in WorkOrderTravelExpensesTable.RATE_CHOICES}
if normalized not in valid_choices:
raise serializers.ValidationError('Neispravna država dnevnice.')
return normalized
def validate(self, attrs):
instance = getattr(self, 'instance', None)
country = attrs.get(
'daily_rate_country',
getattr(instance, 'daily_rate_country', WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY),
)
if 'iznos_dnevnica' not in attrs or attrs.get('iznos_dnevnica') in (None, ''):
attrs['iznos_dnevnica'] = self.RATE_DEFAULTS.get(
country,
WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT,
)
if 'broj_sati' in attrs:
attrs['broj_sati'] = self._parse_decimal(attrs['broj_sati'])
if 'kolicina_dnevnica' in attrs:
attrs['kolicina_dnevnica'] = self._parse_decimal(attrs['kolicina_dnevnica'])
if 'iznos_dnevnica' in attrs:
attrs['iznos_dnevnica'] = self._parse_decimal(attrs['iznos_dnevnica'])
attrs['broj_sati'] = attrs.get(
'broj_sati',
getattr(instance, 'broj_sati', Decimal('0.00')),
) or Decimal('0.00')
attrs['kolicina_dnevnica'] = attrs.get(
'kolicina_dnevnica',
getattr(instance, 'kolicina_dnevnica', Decimal('0.00')),
) or Decimal('0.00')
attrs['iznos_dnevnica'] = attrs.get(
'iznos_dnevnica',
getattr(instance, 'iznos_dnevnica', self.RATE_DEFAULTS.get(country, WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT)),
) or self.RATE_DEFAULTS.get(country, WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT)
attrs['daily_rate_country'] = country
return attrs
def _calculate_total_for_payout(self, validated_data):
quantity = validated_data.get('kolicina_dnevnica', Decimal('0.00'))
rate = validated_data.get('iznos_dnevnica', Decimal('0.00'))
if not isinstance(quantity, Decimal):
quantity = self._parse_decimal(quantity) or Decimal('0.00')
if not isinstance(rate, Decimal):
rate = self._parse_decimal(rate) or Decimal('0.00')
return (quantity * rate).quantize(Decimal('0.01'))
def create(self, validated_data):
validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data)
return super().create(validated_data)
def update(self, instance, validated_data):
merged = {
'kolicina_dnevnica': validated_data.get('kolicina_dnevnica', instance.kolicina_dnevnica),
'iznos_dnevnica': validated_data.get('iznos_dnevnica', instance.iznos_dnevnica),
}
validated_data['total_for_payout'] = self._calculate_total_for_payout(merged)
return super().update(instance, validated_data)
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrderPhoto

View File

@@ -274,6 +274,30 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertFalse(cached.is_active)
self.assertEqual(cached.status, 'failed')
def test_travel_expenses_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='MT150726.work-order.pdf',
expires_at=timezone.now() + timedelta(hours=1),
)
response = self.client.put(
f"/api/fleet/work-orders/{self.work_order.pk}/travel-expenses-table/",
data={
'broj_sati': '9.5',
'kolicina_dnevnica': '0.5',
'iznos_dnevnica': '30',
'daily_rate_country': 'HR',
},
format='json',
)
self.assertEqual(response.status_code, 200, response.content)
cached.refresh_from_db()
self.assertFalse(cached.is_active)
self.assertEqual(cached.status, 'failed')
def test_service_records_docx_contains_embedded_service_photos(self):
VehicleServicePhoto.objects.create(
service_record=self.service_record,
@@ -491,6 +515,19 @@ class WorkOrderImagesEndpointTests(TestCase):
},
)
save_response = self.client.put(
f'/api/fleet/work-orders/{self.work_order.pk}/travel-expenses-table/',
data={
'broj_sati': '9.5',
'kolicina_dnevnica': '0.5',
'iznos_dnevnica': '80',
'daily_rate_country': 'SI',
},
format='json',
)
self.assertEqual(save_response.status_code, 200, save_response.content)
self.assertEqual(save_response.data['total_for_payout'], '40.00')
response = self.client.get(f'/api/fleet/work-orders/{self.work_order.pk}/pdf/')
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/pdf')
@@ -501,6 +538,39 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertIn('25.12.2033.', text)
self.assertIn('9,5', text)
self.assertIn('0,5', text)
self.assertIn('80.00 €', text)
self.assertIn('40.00 €', text)
def test_work_order_travel_expenses_table_defaults_to_hr_rate_and_calculates_total(self):
TaskWorkHoursTable.objects.create(
task=self.task,
data={
'rows': [
{
'date': '24.12.2033',
'work_time_from': '08:00',
'work_time_to': '16:00',
'travel_hours': '1',
'work_hours': '6',
},
{
'date': '25.12.2033',
'work_time_from': '09:00',
'work_time_to': '15:00',
'travel_hours': '0,5',
'work_hours': '2',
},
]
},
)
response = self.client.get(f'/api/fleet/work-orders/{self.work_order.pk}/travel-expenses-table/')
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response.data['broj_sati'], '9.50')
self.assertEqual(response.data['kolicina_dnevnica'], '0.50')
self.assertEqual(response.data['daily_rate_country'], 'HR')
self.assertEqual(response.data['iznos_dnevnica'], '30.00')
self.assertEqual(response.data['total_for_payout'], '15.00')
def test_calculate_daily_quantity_from_hours_follows_business_rules(self):
self.assertEqual(_calculate_daily_quantity_from_hours(0), 0.0)

View File

@@ -53,6 +53,7 @@ from .models import (
GeneratedFleetArchive,
WorkOrderInvoice,
WorkOrderAdditionalCostsTable,
WorkOrderTravelExpensesTable,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
@@ -63,6 +64,7 @@ from .serializers import (
WorkOrderSerializer,
WorkOrderInvoiceSerializer,
WorkOrderAdditionalCostsTableSerializer,
WorkOrderTravelExpensesTableSerializer,
WorkOrderPhotoSerializer,
VehicleServiceRecordSerializer,
VehicleNotificationSerializer,
@@ -276,6 +278,84 @@ def _task_work_hours_entries(task):
}]
return []
return []
def _format_decimal_display(value, *, default='0'):
if value in (None, ''):
return default
try:
normalized = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return str(value)
text = format(normalized.normalize(), 'f')
if '.' in text:
text = text.rstrip('0').rstrip('.')
return text.replace('.', ',') or default
def _format_decimal_fixed(value, *, default='0.00', places=2):
if value in (None, ''):
return default
try:
normalized = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return str(value)
quantizer = Decimal('1').scaleb(-places)
return format(normalized.quantize(quantizer), 'f')
def _work_order_travel_expenses_context(work_order):
related_tasks = list(_work_order_related_tasks_queryset(work_order))
trip_entries = []
for task in related_tasks:
trip_entries.extend(_task_work_hours_entries(task))
travel_start = getattr(work_order, 'travel_start_at', None)
travel_end = getattr(work_order, 'travel_end_at', None)
trip_start_date = travel_start.date() if travel_start else getattr(work_order, 'date', None)
trip_end_date = travel_end.date() if travel_end else getattr(work_order, 'date', None)
total_hours = sum((entry['total_hours'] for entry in trip_entries), Decimal('0.00'))
if trip_entries:
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
start_candidates = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')]
end_candidates = [entry['end_dt'] for entry in trip_entries if entry.get('end_dt')]
if entry_dates:
trip_start_date = min(entry_dates)
trip_end_date = max(entry_dates)
if start_candidates:
travel_start = min(start_candidates)
if end_candidates:
travel_end = max(end_candidates)
table = getattr(work_order, 'travel_expenses_table', None)
if table:
broj_sati = Decimal(str(table.broj_sati or '0'))
kolicina_dnevnica = Decimal(str(table.kolicina_dnevnica or '0'))
iznos_dnevnica = Decimal(str(table.iznos_dnevnica or '0'))
daily_rate_country = str(table.daily_rate_country or WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY)
total_for_payout = Decimal(str(table.total_for_payout or '0')).quantize(Decimal('0.01'))
else:
broj_sati = total_hours.quantize(Decimal('0.01'))
kolicina_dnevnica = Decimal(str(_calculate_daily_quantity_from_hours(broj_sati)))
daily_rate_country = WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY
iznos_dnevnica = WorkOrderTravelExpensesTable.DEFAULT_RATE_AMOUNT
total_for_payout = (kolicina_dnevnica * iznos_dnevnica).quantize(Decimal('0.01'))
return {
'related_tasks': related_tasks,
'trip_entries': trip_entries,
'travel_start': travel_start,
'travel_end': travel_end,
'trip_start_date': trip_start_date,
'trip_end_date': trip_end_date,
'broj_sati': broj_sati,
'kolicina_dnevnica': kolicina_dnevnica,
'iznos_dnevnica': iznos_dnevnica,
'daily_rate_country': daily_rate_country,
'total_for_payout': total_for_payout,
}
def _can_access_service_record(user, service_record):
service_record_id = getattr(service_record, 'pk', service_record)
@@ -715,35 +795,18 @@ def _build_work_order_pdf(work_order):
)
creator_residence = (getattr(creator, 'residence', None) or '').strip() or "-"
creator_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation
related_tasks = list(_work_order_related_tasks_queryset(work_order))
trip_entries = []
for task in related_tasks:
trip_entries.extend(_task_work_hours_entries(task))
travel_start = work_order.travel_start_at
travel_end = work_order.travel_end_at
trip_start_date = travel_start.date() if travel_start else getattr(work_order, 'date', None)
trip_end_date = travel_end.date() if travel_end else getattr(work_order, 'date', None)
travel_hours = _hours_between(travel_start, travel_end)
if trip_entries:
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
start_candidates = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')]
end_candidates = [entry['end_dt'] for entry in trip_entries if entry.get('end_dt')]
total_hours = sum((entry['total_hours'] for entry in trip_entries), Decimal('0.00'))
travel_hours = float(total_hours.quantize(Decimal('0.01')))
if entry_dates:
trip_start_date = min(entry_dates)
trip_end_date = max(entry_dates)
if start_candidates:
travel_start = min(start_candidates)
if end_candidates:
travel_end = max(end_candidates)
daily_qty = _calculate_daily_quantity_from_hours(travel_hours)
daily_rate = 0.0
daily_total = daily_qty * daily_rate
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
travel_context = _work_order_travel_expenses_context(work_order)
related_tasks = travel_context['related_tasks']
trip_entries = travel_context['trip_entries']
travel_start = travel_context['travel_start']
travel_end = travel_context['travel_end']
trip_start_date = travel_context['trip_start_date']
trip_end_date = travel_context['trip_end_date']
travel_hours = travel_context['broj_sati']
daily_qty = travel_context['kolicina_dnevnica']
daily_rate = travel_context['iznos_dnevnica']
daily_total = travel_context['total_for_payout']
transport_total = _parse_decimal(work_order.servicer_vehicle_fuel_cost)
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'
additional_table = getattr(work_order, 'additional_costs_table', None)
@@ -752,7 +815,7 @@ def _build_work_order_pdf(work_order):
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)
grand_total = daily_total + transport_total + additional_total_decimal
attachment_names = [
str(row.get('prilog', '')).strip()
@@ -926,8 +989,8 @@ def _build_work_order_pdf(work_order):
_fmt_time(travel_start),
_fmt_date(trip_end_date or work_order.date),
_fmt_time(travel_end),
str(travel_hours).replace('.', ','),
str(daily_qty).replace('.', ','),
_format_decimal_display(travel_hours),
_format_decimal_display(daily_qty),
_fmt_eur(daily_rate),
_fmt_eur(daily_total),
],
@@ -3720,11 +3783,26 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
if additional_costs_table
else {'work_order': str(work_order.pk), 'data': {'rows': []}, 'total_for_payout': '0.00'}
)
travel_expenses_context = _work_order_travel_expenses_context(work_order)
travel_expenses_table = getattr(work_order, 'travel_expenses_table', None)
travel_expenses_payload = (
WorkOrderTravelExpensesTableSerializer(travel_expenses_table).data
if travel_expenses_table
else {
'work_order': str(work_order.pk),
'broj_sati': _format_decimal_fixed(travel_expenses_context['broj_sati']),
'kolicina_dnevnica': _format_decimal_fixed(travel_expenses_context['kolicina_dnevnica']),
'iznos_dnevnica': _format_decimal_fixed(travel_expenses_context['iznos_dnevnica']),
'daily_rate_country': WorkOrderTravelExpensesTable.DEFAULT_RATE_COUNTRY,
'total_for_payout': _format_decimal_fixed(travel_expenses_context['total_for_payout']),
}
)
return Response({
'work_order_id': work_order.pk,
'tasks': payload,
'additional_costs_table': additional_costs_payload,
'travel_expenses_table': travel_expenses_payload,
}, status=status.HTTP_200_OK)
@action(detail=True, methods=['get', 'put', 'patch'], url_path='additional-costs-table')
@@ -3757,6 +3835,40 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
_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=['get', 'put', 'patch'], url_path='travel-expenses-table')
def travel_expenses_table(self, request, pk=None):
work_order = self.get_object()
table = getattr(work_order, 'travel_expenses_table', None)
fallback = _work_order_travel_expenses_context(work_order)
if request.method.lower() == 'get':
if table:
return Response(WorkOrderTravelExpensesTableSerializer(table).data, status=status.HTTP_200_OK)
return Response({
'work_order': str(work_order.pk),
'broj_sati': _format_decimal_fixed(fallback['broj_sati']),
'kolicina_dnevnica': _format_decimal_fixed(fallback['kolicina_dnevnica']),
'iznos_dnevnica': _format_decimal_fixed(fallback['iznos_dnevnica']),
'daily_rate_country': fallback['daily_rate_country'],
'total_for_payout': _format_decimal_fixed(fallback['total_for_payout']),
}, status=status.HTTP_200_OK)
serializer = WorkOrderTravelExpensesTableSerializer(
table,
data={
'work_order': str(work_order.pk),
'broj_sati': request.data.get('broj_sati', fallback['broj_sati']),
'kolicina_dnevnica': request.data.get('kolicina_dnevnica', fallback['kolicina_dnevnica']),
'iznos_dnevnica': request.data.get('iznos_dnevnica', fallback['iznos_dnevnica']),
'daily_rate_country': request.data.get('daily_rate_country', fallback['daily_rate_country']),
},
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'])
return Response(WorkOrderTravelExpensesTableSerializer(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()