fix: add editable work order travel expenses
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user