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:
@@ -24,8 +24,9 @@ from modules.fleet.models import (
|
|||||||
GeneratedFleetArchive,
|
GeneratedFleetArchive,
|
||||||
VehicleNotification,
|
VehicleNotification,
|
||||||
)
|
)
|
||||||
from modules.task_management.models import Task
|
from modules.task_management.models import Task, TaskWorkHoursTable
|
||||||
from modules.fleet.tasks import build_monthly_archive_cached_task
|
from modules.fleet.tasks import build_monthly_archive_cached_task
|
||||||
|
from modules.fleet.views import _build_monthly_servicer_report_rows
|
||||||
|
|
||||||
|
|
||||||
def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
|
def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
|
||||||
@@ -467,6 +468,73 @@ class WorkOrderImagesEndpointTests(TestCase):
|
|||||||
self.assertIn('MT150726.work-order.pdf', names)
|
self.assertIn('MT150726.work-order.pdf', names)
|
||||||
self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names)
|
self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names)
|
||||||
|
|
||||||
|
def test_work_order_pdf_travel_expense_uses_task_work_hours_table(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': '2',
|
||||||
|
'work_hours': '6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '25.12.2033',
|
||||||
|
'work_time_from': '09:00',
|
||||||
|
'work_time_to': '15:00',
|
||||||
|
'travel_hours': '1,5',
|
||||||
|
'work_hours': '4',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
reader = PdfReader(BytesIO(response.content))
|
||||||
|
text = '\n'.join((page.extract_text() or '') for page in reader.pages)
|
||||||
|
self.assertIn('24.12.2033.', text)
|
||||||
|
self.assertIn('25.12.2033.', text)
|
||||||
|
self.assertIn('13,5', text)
|
||||||
|
self.assertIn('0,6', text)
|
||||||
|
|
||||||
|
def test_monthly_servicer_report_rows_use_task_work_hours_table_hours(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': '2',
|
||||||
|
'work_hours': '6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'date': '25.12.2033',
|
||||||
|
'work_time_from': '09:00',
|
||||||
|
'work_time_to': '15:00',
|
||||||
|
'travel_hours': '1,5',
|
||||||
|
'work_hours': '4',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = _build_monthly_servicer_report_rows(self.user, 2033, 12)
|
||||||
|
row_by_date = {row['date']: row for row in rows if row['source'] == 'task'}
|
||||||
|
self.assertIn(date(2033, 12, 24), row_by_date)
|
||||||
|
self.assertIn(date(2033, 12, 25), row_by_date)
|
||||||
|
self.assertEqual(row_by_date[date(2033, 12, 24)]['redovan_rad'], '8')
|
||||||
|
self.assertEqual(row_by_date[date(2033, 12, 24)]['prekovremeni'], '8')
|
||||||
|
self.assertEqual(row_by_date[date(2033, 12, 25)]['redovan_rad'], '8')
|
||||||
|
self.assertEqual(row_by_date[date(2033, 12, 25)]['prekovremeni'], '5.5')
|
||||||
|
|
||||||
|
|
||||||
def test_monthly_service_tasks_archive_request_creates_ready_download_with_notification(self):
|
def test_monthly_service_tasks_archive_request_creates_ready_download_with_notification(self):
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
'/api/fleet/reports/monthly-service-tasks-archive-request/',
|
'/api/fleet/reports/monthly-service-tasks-archive-request/',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import threading
|
|||||||
import zipfile
|
import zipfile
|
||||||
import re
|
import re
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
from django.contrib.auth import get_user_model
|
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'
|
'assigned_to', 'vehicle', 'work_order', 'work_hours_table'
|
||||||
).order_by('-created_at')
|
).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):
|
def _can_access_service_record(user, service_record):
|
||||||
service_record_id = getattr(service_record, 'pk', service_record)
|
service_record_id = getattr(service_record, 'pk', service_record)
|
||||||
return _service_records_queryset_for_user(user).filter(pk=service_record_id).exists()
|
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_residence = (getattr(creator, 'residence', None) or '').strip() or "-"
|
||||||
creator_work_position = (getattr(creator, 'work_position', None) or '').strip() or creator_occupation
|
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_start = work_order.travel_start_at
|
||||||
travel_end = work_order.travel_end_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)
|
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_rate = 0.0
|
||||||
daily_total = daily_qty * daily_rate
|
daily_total = daily_qty * daily_rate
|
||||||
transport_total = float(work_order.servicer_vehicle_fuel_cost or 0.0)
|
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", "", "", "", "", "", "", ""],
|
["OBRAČUN PUTNIH TROŠKOVA", "", "", "", "", "", "", ""],
|
||||||
["ODLAZAK Datum", "ODLAZAK Vrijeme", "POVRATAK Datum", "POVRATAK Vrijeme", "Broj sati", "Količina dnevnica", "Iznos dnevnice", "Ukupan iznos"],
|
["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_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),
|
_fmt_time(travel_end),
|
||||||
str(travel_hours).replace('.', ','),
|
str(travel_hours).replace('.', ','),
|
||||||
str(daily_qty).replace('.', ','),
|
str(daily_qty).replace('.', ','),
|
||||||
@@ -2348,6 +2474,7 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
'work_order',
|
'work_order',
|
||||||
'work_order__vehicle',
|
'work_order__vehicle',
|
||||||
'work_order__vehicle__client',
|
'work_order__vehicle__client',
|
||||||
|
'work_hours_table',
|
||||||
)
|
)
|
||||||
.order_by('scheduled_date', 'created_at')
|
.order_by('scheduled_date', 'created_at')
|
||||||
)
|
)
|
||||||
@@ -2358,9 +2485,10 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
entry_date__month=month,
|
entry_date__month=month,
|
||||||
).order_by('entry_date')
|
).order_by('entry_date')
|
||||||
|
|
||||||
tasks_by_date = OrderedDict()
|
entries_by_date = OrderedDict()
|
||||||
for task in tasks_qs:
|
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 = {
|
manual_by_date = {
|
||||||
entry.entry_date: entry
|
entry.entry_date: entry
|
||||||
@@ -2371,8 +2499,8 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
days_in_month = monthrange(year, month)[1]
|
days_in_month = monthrange(year, month)[1]
|
||||||
for day in range(1, days_in_month + 1):
|
for day in range(1, days_in_month + 1):
|
||||||
current_date = _date(year, month, day)
|
current_date = _date(year, month, day)
|
||||||
day_tasks = tasks_by_date.get(current_date, [])
|
day_entries = entries_by_date.get(current_date, [])
|
||||||
if day_tasks:
|
if day_entries:
|
||||||
titles = OrderedDict()
|
titles = OrderedDict()
|
||||||
serials = OrderedDict()
|
serials = OrderedDict()
|
||||||
clients = OrderedDict()
|
clients = OrderedDict()
|
||||||
@@ -2380,53 +2508,31 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
work_orders = OrderedDict()
|
work_orders = OrderedDict()
|
||||||
start_values = []
|
start_values = []
|
||||||
end_values = []
|
end_values = []
|
||||||
counted_work_order_ids = set()
|
total_hours = Decimal('0.00')
|
||||||
total_hours = 0.0
|
|
||||||
|
|
||||||
for task in day_tasks:
|
for entry in day_entries:
|
||||||
title = str(task.title or '').strip()
|
if entry.get('title'):
|
||||||
if title:
|
titles[entry['title']] = entry['title']
|
||||||
titles[title] = title
|
if entry.get('serial'):
|
||||||
|
serials[entry['serial']] = entry['serial']
|
||||||
vehicle = getattr(task, 'vehicle', None)
|
if entry.get('client'):
|
||||||
work_order = getattr(task, 'work_order', None)
|
clients[entry['client']] = entry['client']
|
||||||
if work_order and getattr(work_order, 'vehicle', None):
|
if entry.get('location'):
|
||||||
vehicle = work_order.vehicle
|
locations[entry['location']] = entry['location']
|
||||||
|
if entry.get('work_order_label'):
|
||||||
serial_value = str(getattr(vehicle, 'crane_serial_number', '') or '').strip()
|
work_orders[entry['work_order_label']] = entry['work_order_label']
|
||||||
if serial_value:
|
if entry.get('start_dt'):
|
||||||
serials[serial_value] = serial_value
|
start_values.append(entry['start_dt'])
|
||||||
|
if entry.get('end_dt'):
|
||||||
client_name = str(getattr(getattr(vehicle, 'client', None), 'name', '') or '').strip()
|
end_values.append(entry['end_dt'])
|
||||||
if client_name:
|
total_hours += entry.get('total_hours', Decimal('0.00'))
|
||||||
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
|
|
||||||
|
|
||||||
start_label = '-'
|
start_label = '-'
|
||||||
end_label = '-'
|
end_label = '-'
|
||||||
if start_values:
|
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:
|
if end_values:
|
||||||
end_label = max(value for _, value in end_values).strftime('%H:%M')
|
end_label = max(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')
|
|
||||||
|
|
||||||
rows.append({
|
rows.append({
|
||||||
'date': current_date,
|
'date': current_date,
|
||||||
@@ -2437,8 +2543,8 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
'mjesto_rada': ', '.join(locations.values()) or '-',
|
'mjesto_rada': ', '.join(locations.values()) or '-',
|
||||||
'pocetak_rada': start_label,
|
'pocetak_rada': start_label,
|
||||||
'kraj_rada': end_label,
|
'kraj_rada': end_label,
|
||||||
'redovan_rad': regular_hours,
|
'redovan_rad': '8',
|
||||||
'prekovremeni': overtime_hours,
|
'prekovremeni': _format_report_hours(total_hours, default='0'),
|
||||||
'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-',
|
'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-',
|
||||||
'source': 'task',
|
'source': 'task',
|
||||||
})
|
})
|
||||||
@@ -2471,7 +2577,7 @@ def _build_monthly_servicer_report_rows(user, year, month):
|
|||||||
'mjesto_rada': '-',
|
'mjesto_rada': '-',
|
||||||
'pocetak_rada': '-',
|
'pocetak_rada': '-',
|
||||||
'kraj_rada': '-',
|
'kraj_rada': '-',
|
||||||
'redovan_rad': '-',
|
'redovan_rad': '0',
|
||||||
'prekovremeni': '0',
|
'prekovremeni': '0',
|
||||||
'radni_nalog': '-',
|
'radni_nalog': '-',
|
||||||
'source': 'empty',
|
'source': 'empty',
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
vehicle_owner_name = serializers.SerializerMethodField()
|
vehicle_owner_name = serializers.SerializerMethodField()
|
||||||
vehicle_asset_type = serializers.SerializerMethodField()
|
vehicle_asset_type = serializers.SerializerMethodField()
|
||||||
crane_serial_number = serializers.SerializerMethodField()
|
crane_serial_number = serializers.SerializerMethodField()
|
||||||
|
work_hours_table = serializers.SerializerMethodField()
|
||||||
template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
|
template_id = serializers.UUIDField(write_only=True, required=False, allow_null=True)
|
||||||
auto_close_work_order = serializers.BooleanField(write_only=True, required=False)
|
auto_close_work_order = serializers.BooleanField(write_only=True, required=False)
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
'id', 'title', 'description', 'status',
|
'id', 'title', 'description', 'status',
|
||||||
'assigned_to', 'assigned_to_name',
|
'assigned_to', 'assigned_to_name',
|
||||||
'vehicle', 'vehicle_registration', 'vehicle_make', 'vehicle_model',
|
'vehicle', 'vehicle_registration', 'vehicle_make', 'vehicle_model',
|
||||||
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number',
|
'vehicle_owner_name', 'vehicle_asset_type', 'crane_serial_number', 'work_hours_table',
|
||||||
'work_order', 'work_order_label',
|
'work_order', 'work_order_label',
|
||||||
'scheduled_date',
|
'scheduled_date',
|
||||||
'service_report_note',
|
'service_report_note',
|
||||||
@@ -212,3 +213,9 @@ class TaskSerializer(serializers.ModelSerializer):
|
|||||||
if not obj.vehicle_id:
|
if not obj.vehicle_id:
|
||||||
return None
|
return None
|
||||||
return obj.vehicle.crane_serial_number or None
|
return obj.vehicle.crane_serial_number or None
|
||||||
|
|
||||||
|
def get_work_hours_table(self, obj):
|
||||||
|
table = getattr(obj, 'work_hours_table', None)
|
||||||
|
if not table:
|
||||||
|
return None
|
||||||
|
return TaskWorkHoursTableSerializer(table).data
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from django.test import TestCase
|
|||||||
from modules.task_management.serializers import TaskSerializer
|
from modules.task_management.serializers import TaskSerializer
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from modules.task_management.models import Task
|
from modules.task_management.models import Task, TaskWorkHoursTable
|
||||||
from modules.fleet.models import Vehicle, WorkOrder
|
from modules.fleet.models import Vehicle, WorkOrder
|
||||||
from modules.crm.models import Client
|
from modules.crm.models import Client
|
||||||
|
|
||||||
@@ -86,6 +86,22 @@ class TaskSerializerTests(TestCase):
|
|||||||
assert serializer.data['vehicle_owner_name'] == 'Klijent zadatka'
|
assert serializer.data['vehicle_owner_name'] == 'Klijent zadatka'
|
||||||
assert serializer.data['vehicle_asset_type'] == 'crane'
|
assert serializer.data['vehicle_asset_type'] == 'crane'
|
||||||
|
|
||||||
|
def test_work_hours_table_is_included_in_serialized_output(self):
|
||||||
|
task = Task.objects.create(title="Task work hours", assigned_to=self.user, vehicle=self.vehicle)
|
||||||
|
TaskWorkHoursTable.objects.create(
|
||||||
|
task=task,
|
||||||
|
data={
|
||||||
|
'rows': [
|
||||||
|
{'date': '12.07.2026', 'work_hours': '8', 'travel_hours': '2'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = TaskSerializer(instance=task)
|
||||||
|
assert serializer.data['work_hours_table']['data']['rows'][0]['date'] == '12.07.2026'
|
||||||
|
assert serializer.data['work_hours_table']['data']['rows'][0]['work_hours'] == '8'
|
||||||
|
assert serializer.data['work_hours_table']['data']['rows'][0]['travel_hours'] == '2'
|
||||||
|
|
||||||
def test_scheduled_date_accepts_valid_date(self):
|
def test_scheduled_date_accepts_valid_date(self):
|
||||||
serializer = TaskSerializer(
|
serializer = TaskSerializer(
|
||||||
data={"title": "Task mit datum", "status": "aktivan",
|
data={"title": "Task mit datum", "status": "aktivan",
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ function toDateKey(value) {
|
|||||||
const text = String(value || '').trim();
|
const text = String(value || '').trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
|
if (/^\d{4}-\d{2}-\d{2}$/.test(text)) return text;
|
||||||
|
const croatian = text.match(/^(\d{2})\.(\d{2})\.(\d{4})\.?$/);
|
||||||
|
if (croatian) {
|
||||||
|
return `${croatian[3]}-${croatian[2]}-${croatian[1]}`;
|
||||||
|
}
|
||||||
const parsed = new Date(text);
|
const parsed = new Date(text);
|
||||||
if (Number.isNaN(parsed.getTime())) return null;
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
return `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}`;
|
return `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}`;
|
||||||
@@ -77,6 +81,83 @@ function joinUnique(values) {
|
|||||||
return Array.from(new Set(values.filter(Boolean).map((item) => String(item).trim()).filter(Boolean))).join(', ');
|
return Array.from(new Set(values.filter(Boolean).map((item) => String(item).trim()).filter(Boolean))).join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseReportHours(value) {
|
||||||
|
if (value == null || value === '') return 0;
|
||||||
|
const numeric = Number(String(value).replace(',', '.'));
|
||||||
|
return Number.isFinite(numeric) ? numeric : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReportDateTime(dateKey, value) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return new Date(value.getTime());
|
||||||
|
}
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const timeMatch = text.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
|
||||||
|
if (dateKey && timeMatch) {
|
||||||
|
const hours = String(timeMatch[1]).padStart(2, '0');
|
||||||
|
const minutes = timeMatch[2];
|
||||||
|
const seconds = timeMatch[3] || '00';
|
||||||
|
return new Date(`${dateKey}T${hours}:${minutes}:${seconds}`);
|
||||||
|
}
|
||||||
|
const parsed = new Date(text);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskWorkHoursEntries(task, workOrdersById) {
|
||||||
|
const tableData = task?.work_hours_table?.data ?? task?.work_hours_table ?? null;
|
||||||
|
const rows = Array.isArray(tableData?.rows) ? tableData.rows : [];
|
||||||
|
const workOrder = workOrdersById.get(String(task?.work_order || ''));
|
||||||
|
const base = {
|
||||||
|
title: task?.title || '',
|
||||||
|
serial: getTaskCraneSerial(task) || '',
|
||||||
|
client: getTaskCraneOwner(task) || '',
|
||||||
|
location: workOrder?.location || '',
|
||||||
|
workOrderLabel: task?.work_order_label || '',
|
||||||
|
};
|
||||||
|
const entries = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row || typeof row !== 'object') continue;
|
||||||
|
const dateKey = toDateKey(row.date) || toDateKey(task?.scheduled_date);
|
||||||
|
if (!dateKey) continue;
|
||||||
|
const startAt = parseReportDateTime(dateKey, row.work_time_from || row.travel_time_from || '');
|
||||||
|
const endAt = parseReportDateTime(dateKey, row.work_time_to || row.travel_time_to || '');
|
||||||
|
entries.push({
|
||||||
|
...base,
|
||||||
|
dateKey,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
totalHours: parseReportHours(row.work_hours) + parseReportHours(row.travel_hours),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.length > 0) {
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
const travelStart = workOrder?.travel_start_at;
|
||||||
|
const travelEnd = workOrder?.travel_end_at;
|
||||||
|
if (travelStart && travelEnd) {
|
||||||
|
const startAt = parseReportDateTime(null, travelStart);
|
||||||
|
const endAt = parseReportDateTime(null, travelEnd);
|
||||||
|
const dateKey = toDateKey(task?.scheduled_date) || toDateKey(travelStart);
|
||||||
|
const totalHours = startAt && endAt ? Math.max(0, (endAt.getTime() - startAt.getTime()) / 3600000) : 0;
|
||||||
|
if (dateKey) {
|
||||||
|
entries.push({
|
||||||
|
...base,
|
||||||
|
dateKey,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
totalHours,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders = [], onTaskClick }) {
|
export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders = [], onTaskClick }) {
|
||||||
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
|
const [viewYear, setViewYear] = useState(() => new Date().getFullYear());
|
||||||
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
|
const [viewMonth, setViewMonth] = useState(() => new Date().getMonth());
|
||||||
@@ -196,6 +277,17 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
};
|
};
|
||||||
}, [reportOpen, reportType, viewYear, viewMonth]);
|
}, [reportOpen, reportType, viewYear, viewMonth]);
|
||||||
|
|
||||||
|
const reportTaskEntriesByDate = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const task of tasks) {
|
||||||
|
for (const entry of getTaskWorkHoursEntries(task, workOrdersById)) {
|
||||||
|
if (!map[entry.dateKey]) map[entry.dateKey] = [];
|
||||||
|
map[entry.dateKey].push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tasks, workOrdersById]);
|
||||||
|
|
||||||
const servicerRows = useMemo(() => {
|
const servicerRows = useMemo(() => {
|
||||||
if (reportType !== 'servicer') return [];
|
if (reportType !== 'servicer') return [];
|
||||||
const rows = [];
|
const rows = [];
|
||||||
@@ -204,42 +296,28 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
|
|
||||||
for (let day = 1; day <= daysInMonth; day += 1) {
|
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||||
const key = `${monthPrefix}-${String(day).padStart(2, '0')}`;
|
const key = `${monthPrefix}-${String(day).padStart(2, '0')}`;
|
||||||
const dayTasks = tasksByDate[key] ?? [];
|
const dayEntries = reportTaskEntriesByDate[key] ?? [];
|
||||||
const manualEntry = manualEntriesByDate[key];
|
const manualEntry = manualEntriesByDate[key];
|
||||||
|
|
||||||
if (dayTasks.length > 0) {
|
if (dayEntries.length > 0) {
|
||||||
const titles = [];
|
const titles = [];
|
||||||
const serials = [];
|
const serials = [];
|
||||||
const clients = [];
|
const clients = [];
|
||||||
const locations = [];
|
const locations = [];
|
||||||
const workOrderLabels = [];
|
const workOrderLabels = [];
|
||||||
const workStartTimes = [];
|
const startTimes = [];
|
||||||
const workEndTimes = [];
|
const endTimes = [];
|
||||||
const countedWorkOrders = new Set();
|
|
||||||
let totalHours = 0;
|
let totalHours = 0;
|
||||||
|
|
||||||
for (const task of dayTasks) {
|
for (const entry of dayEntries) {
|
||||||
if (task?.title) titles.push(task.title);
|
if (entry.title) titles.push(entry.title);
|
||||||
serials.push(getTaskCraneSerial(task));
|
if (entry.serial) serials.push(entry.serial);
|
||||||
clients.push(getTaskCraneOwner(task));
|
if (entry.client) clients.push(entry.client);
|
||||||
if (task?.work_order_label) workOrderLabels.push(task.work_order_label);
|
if (entry.location) locations.push(entry.location);
|
||||||
|
if (entry.workOrderLabel) workOrderLabels.push(entry.workOrderLabel);
|
||||||
const workOrder = workOrdersById.get(String(task.work_order || ''));
|
if (entry.startAt) startTimes.push(entry.startAt);
|
||||||
if (workOrder?.location) locations.push(workOrder.location);
|
if (entry.endAt) endTimes.push(entry.endAt);
|
||||||
if (
|
totalHours += Number(entry.totalHours || 0);
|
||||||
workOrder?.travel_start_at
|
|
||||||
&& workOrder?.travel_end_at
|
|
||||||
&& !countedWorkOrders.has(String(workOrder.id))
|
|
||||||
) {
|
|
||||||
const startAt = new Date(workOrder.travel_start_at);
|
|
||||||
const endAt = new Date(workOrder.travel_end_at);
|
|
||||||
if (!Number.isNaN(startAt.getTime()) && !Number.isNaN(endAt.getTime()) && endAt > startAt) {
|
|
||||||
countedWorkOrders.add(String(workOrder.id));
|
|
||||||
workStartTimes.push(startAt);
|
|
||||||
workEndTimes.push(endAt);
|
|
||||||
totalHours += (endAt.getTime() - startAt.getTime()) / 3600000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
@@ -252,10 +330,10 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
joinUnique(serials) || '-',
|
joinUnique(serials) || '-',
|
||||||
joinUnique(clients) || '-',
|
joinUnique(clients) || '-',
|
||||||
joinUnique(locations) || '-',
|
joinUnique(locations) || '-',
|
||||||
workStartTimes.length ? formatTime(new Date(Math.min(...workStartTimes.map((item) => item.getTime()))).toISOString()) : '-',
|
startTimes.length ? formatTime(new Date(Math.min(...startTimes.map((item) => item.getTime())))) : '-',
|
||||||
workEndTimes.length ? formatTime(new Date(Math.max(...workEndTimes.map((item) => item.getTime()))).toISOString()) : '-',
|
endTimes.length ? formatTime(new Date(Math.max(...endTimes.map((item) => item.getTime())))) : '-',
|
||||||
totalHours > 0 ? formatHourValue(Math.min(totalHours, 8), '0') : '-',
|
'8',
|
||||||
totalHours > 8 ? formatHourValue(totalHours - 8, '0') : '0',
|
formatHourValue(totalHours, '0'),
|
||||||
joinUnique(workOrderLabels) || '-',
|
joinUnique(workOrderLabels) || '-',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -292,7 +370,7 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
|
|||||||
}
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
}, [manualEntriesByDate, reportType, tasksByDate, viewMonth, viewYear, workOrdersById]);
|
}, [manualEntriesByDate, reportTaskEntriesByDate, reportType, viewMonth, viewYear]);
|
||||||
|
|
||||||
const costsRows = useMemo(() => {
|
const costsRows = useMemo(() => {
|
||||||
if (reportType !== 'costs') return [];
|
if (reportType !== 'costs') return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user