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

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