fix: sync multi-day work hours accounting
Aggregate task work-hour rows into travel-cost calculations and monthly servicer reporting, and keep the frontend calendar aligned with the task work-hours source.
This commit is contained in:
@@ -9,7 +9,7 @@ import threading
|
||||
import zipfile
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from django.contrib.auth import get_user_model
|
||||
@@ -162,6 +162,110 @@ def _work_order_related_tasks_queryset(work_order):
|
||||
'assigned_to', 'vehicle', 'work_order', 'work_hours_table'
|
||||
).order_by('-created_at')
|
||||
|
||||
|
||||
def _parse_report_date_value(value):
|
||||
text = str(value or '').strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ('%Y-%m-%d', '%d.%m.%Y.', '%d.%m.%Y'):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_report_time_value(value):
|
||||
text = str(value or '').strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ('%H:%M', '%H:%M:%S'):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).time()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_report_decimal_value(value):
|
||||
if value in (None, ''):
|
||||
return Decimal('0.00')
|
||||
try:
|
||||
return Decimal(str(value).strip().replace(',', '.'))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return Decimal('0.00')
|
||||
|
||||
|
||||
def _task_work_hours_entries(task):
|
||||
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
|
||||
rows = table_data.get('rows', []) if isinstance(table_data, dict) else []
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
|
||||
entries = []
|
||||
work_order = getattr(task, 'work_order', None)
|
||||
fallback_date = getattr(task, 'scheduled_date', None)
|
||||
task_vehicle = getattr(task, 'vehicle', None)
|
||||
task_client = getattr(task_vehicle, 'client', None)
|
||||
|
||||
for raw_row in rows:
|
||||
if not isinstance(raw_row, dict):
|
||||
continue
|
||||
row_date = _parse_report_date_value(raw_row.get('date')) or fallback_date
|
||||
if row_date is None:
|
||||
continue
|
||||
|
||||
start_text = str(raw_row.get('work_time_from') or raw_row.get('travel_time_from') or '').strip()
|
||||
end_text = str(raw_row.get('work_time_to') or raw_row.get('travel_time_to') or '').strip()
|
||||
start_time = _parse_report_time_value(start_text)
|
||||
end_time = _parse_report_time_value(end_text)
|
||||
start_dt = datetime.combine(row_date, start_time) if start_time else None
|
||||
end_dt = datetime.combine(row_date, end_time) if end_time else None
|
||||
if start_dt and end_dt and end_dt <= start_dt:
|
||||
end_dt += timedelta(days=1)
|
||||
|
||||
work_hours = _parse_report_decimal_value(raw_row.get('work_hours'))
|
||||
travel_hours = _parse_report_decimal_value(raw_row.get('travel_hours'))
|
||||
entries.append({
|
||||
'date': row_date,
|
||||
'date_label': row_date.strftime('%d.%m.%Y.'),
|
||||
'start_dt': start_dt,
|
||||
'end_dt': end_dt,
|
||||
'work_hours': work_hours,
|
||||
'travel_hours': travel_hours,
|
||||
'total_hours': work_hours + travel_hours,
|
||||
'title': str(getattr(task, 'title', '') or '').strip(),
|
||||
'serial': str(getattr(task_vehicle, 'crane_serial_number', '') or '').strip(),
|
||||
'client': str(getattr(task_client, 'name', '') or '').strip(),
|
||||
'location': str(getattr(work_order, 'location', '') or '').strip(),
|
||||
'work_order_label': str(getattr(work_order, 'display_code', '') or '').strip(),
|
||||
})
|
||||
|
||||
if entries:
|
||||
return entries
|
||||
|
||||
if work_order and work_order.travel_start_at and work_order.travel_end_at and work_order.travel_end_at > work_order.travel_start_at:
|
||||
start_dt = timezone.localtime(work_order.travel_start_at)
|
||||
end_dt = timezone.localtime(work_order.travel_end_at)
|
||||
fallback_date = fallback_date or start_dt.date()
|
||||
travel_hours = Decimal(str((end_dt - start_dt).total_seconds() / 3600.0)).quantize(Decimal('0.01'))
|
||||
return [{
|
||||
'date': fallback_date,
|
||||
'date_label': fallback_date.strftime('%d.%m.%Y.') if fallback_date else '-',
|
||||
'start_dt': start_dt,
|
||||
'end_dt': end_dt,
|
||||
'work_hours': Decimal('0.00'),
|
||||
'travel_hours': travel_hours,
|
||||
'total_hours': travel_hours,
|
||||
'title': str(getattr(task, 'title', '') or '').strip(),
|
||||
'serial': str(getattr(task_vehicle, 'crane_serial_number', '') or '').strip(),
|
||||
'client': str(getattr(task_client, 'name', '') or '').strip(),
|
||||
'location': str(getattr(work_order, 'location', '') or '').strip(),
|
||||
'work_order_label': str(getattr(work_order, 'display_code', '') or '').strip(),
|
||||
}]
|
||||
|
||||
return []
|
||||
|
||||
def _can_access_service_record(user, service_record):
|
||||
service_record_id = getattr(service_record, 'pk', service_record)
|
||||
return _service_records_queryset_for_user(user).filter(pk=service_record_id).exists()
|
||||
@@ -600,10 +704,32 @@ 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)
|
||||
daily_qty = round(travel_hours / 8.0, 1) if travel_hours > 0 else 0.0
|
||||
|
||||
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)
|
||||
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 = round(travel_hours / 24.0, 1) if travel_hours > 0 else 0.0
|
||||
daily_rate = 0.0
|
||||
daily_total = daily_qty * daily_rate
|
||||
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
|
||||
@@ -785,9 +911,9 @@ def _build_work_order_pdf(work_order):
|
||||
["OBRAČUN PUTNIH TROŠKOVA", "", "", "", "", "", "", ""],
|
||||
["ODLAZAK Datum", "ODLAZAK Vrijeme", "POVRATAK Datum", "POVRATAK Vrijeme", "Broj sati", "Količina dnevnica", "Iznos dnevnice", "Ukupan iznos"],
|
||||
[
|
||||
_fmt_date(travel_start.date() if travel_start else work_order.date),
|
||||
_fmt_date(trip_start_date or work_order.date),
|
||||
_fmt_time(travel_start),
|
||||
_fmt_date(travel_end.date() if travel_end else work_order.date),
|
||||
_fmt_date(trip_end_date or work_order.date),
|
||||
_fmt_time(travel_end),
|
||||
str(travel_hours).replace('.', ','),
|
||||
str(daily_qty).replace('.', ','),
|
||||
@@ -2348,6 +2474,7 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
||||
'work_order',
|
||||
'work_order__vehicle',
|
||||
'work_order__vehicle__client',
|
||||
'work_hours_table',
|
||||
)
|
||||
.order_by('scheduled_date', 'created_at')
|
||||
)
|
||||
@@ -2358,9 +2485,10 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
||||
entry_date__month=month,
|
||||
).order_by('entry_date')
|
||||
|
||||
tasks_by_date = OrderedDict()
|
||||
entries_by_date = OrderedDict()
|
||||
for task in tasks_qs:
|
||||
tasks_by_date.setdefault(task.scheduled_date, []).append(task)
|
||||
for entry in _task_work_hours_entries(task):
|
||||
entries_by_date.setdefault(entry['date'], []).append(entry)
|
||||
|
||||
manual_by_date = {
|
||||
entry.entry_date: entry
|
||||
@@ -2371,8 +2499,8 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
||||
days_in_month = monthrange(year, month)[1]
|
||||
for day in range(1, days_in_month + 1):
|
||||
current_date = _date(year, month, day)
|
||||
day_tasks = tasks_by_date.get(current_date, [])
|
||||
if day_tasks:
|
||||
day_entries = entries_by_date.get(current_date, [])
|
||||
if day_entries:
|
||||
titles = OrderedDict()
|
||||
serials = OrderedDict()
|
||||
clients = OrderedDict()
|
||||
@@ -2380,53 +2508,31 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
||||
work_orders = OrderedDict()
|
||||
start_values = []
|
||||
end_values = []
|
||||
counted_work_order_ids = set()
|
||||
total_hours = 0.0
|
||||
total_hours = Decimal('0.00')
|
||||
|
||||
for task in day_tasks:
|
||||
title = str(task.title or '').strip()
|
||||
if title:
|
||||
titles[title] = title
|
||||
|
||||
vehicle = getattr(task, 'vehicle', None)
|
||||
work_order = getattr(task, 'work_order', None)
|
||||
if work_order and getattr(work_order, 'vehicle', None):
|
||||
vehicle = work_order.vehicle
|
||||
|
||||
serial_value = str(getattr(vehicle, 'crane_serial_number', '') or '').strip()
|
||||
if serial_value:
|
||||
serials[serial_value] = serial_value
|
||||
|
||||
client_name = str(getattr(getattr(vehicle, 'client', None), 'name', '') or '').strip()
|
||||
if client_name:
|
||||
clients[client_name] = client_name
|
||||
|
||||
location = str(getattr(work_order, 'location', '') or '').strip()
|
||||
if location:
|
||||
locations[location] = location
|
||||
|
||||
display_code = str(getattr(work_order, 'display_code', '') or '').strip()
|
||||
if display_code:
|
||||
work_orders[display_code] = display_code
|
||||
|
||||
if work_order and work_order.travel_start_at and work_order.travel_end_at and work_order.travel_end_at > work_order.travel_start_at and str(work_order.id) not in counted_work_order_ids:
|
||||
counted_work_order_ids.add(str(work_order.id))
|
||||
start_values.append((work_order.id, timezone.localtime(work_order.travel_start_at)))
|
||||
end_values.append((work_order.id, timezone.localtime(work_order.travel_end_at)))
|
||||
total_hours += (work_order.travel_end_at - work_order.travel_start_at).total_seconds() / 3600.0
|
||||
for entry in day_entries:
|
||||
if entry.get('title'):
|
||||
titles[entry['title']] = entry['title']
|
||||
if entry.get('serial'):
|
||||
serials[entry['serial']] = entry['serial']
|
||||
if entry.get('client'):
|
||||
clients[entry['client']] = entry['client']
|
||||
if entry.get('location'):
|
||||
locations[entry['location']] = entry['location']
|
||||
if entry.get('work_order_label'):
|
||||
work_orders[entry['work_order_label']] = entry['work_order_label']
|
||||
if entry.get('start_dt'):
|
||||
start_values.append(entry['start_dt'])
|
||||
if entry.get('end_dt'):
|
||||
end_values.append(entry['end_dt'])
|
||||
total_hours += entry.get('total_hours', Decimal('0.00'))
|
||||
|
||||
start_label = '-'
|
||||
end_label = '-'
|
||||
if start_values:
|
||||
start_label = min(value for _, value in start_values).strftime('%H:%M')
|
||||
start_label = min(start_values).strftime('%H:%M')
|
||||
if end_values:
|
||||
end_label = max(value for _, value in end_values).strftime('%H:%M')
|
||||
|
||||
regular_hours = '-'
|
||||
overtime_hours = '0'
|
||||
if total_hours > 0:
|
||||
regular_hours = _format_report_hours(min(total_hours, 8.0), default='0')
|
||||
overtime_hours = _format_report_hours(max(total_hours - 8.0, 0.0), default='0')
|
||||
end_label = max(end_values).strftime('%H:%M')
|
||||
|
||||
rows.append({
|
||||
'date': current_date,
|
||||
@@ -2437,8 +2543,8 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
||||
'mjesto_rada': ', '.join(locations.values()) or '-',
|
||||
'pocetak_rada': start_label,
|
||||
'kraj_rada': end_label,
|
||||
'redovan_rad': regular_hours,
|
||||
'prekovremeni': overtime_hours,
|
||||
'redovan_rad': '8',
|
||||
'prekovremeni': _format_report_hours(total_hours, default='0'),
|
||||
'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-',
|
||||
'source': 'task',
|
||||
})
|
||||
@@ -2471,7 +2577,7 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
||||
'mjesto_rada': '-',
|
||||
'pocetak_rada': '-',
|
||||
'kraj_rada': '-',
|
||||
'redovan_rad': '-',
|
||||
'redovan_rad': '0',
|
||||
'prekovremeni': '0',
|
||||
'radni_nalog': '-',
|
||||
'source': 'empty',
|
||||
|
||||
Reference in New Issue
Block a user