fix: sync multi-day work hours accounting
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

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:
mariomitte
2026-08-08 01:32:17 +02:00
parent 38f1786469
commit 181cab1f45
5 changed files with 362 additions and 87 deletions

View File

@@ -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',