feat: dnevni unosi i svi dani u mjesecnom izvjestaju servisera
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

- Dodan model MonthlyServicerDayEntry s automatskom popunom prema tipu:
  office (08:00-16:00, Zagreb ured), vacation i sick_leave (8h bez vremena)
- Migracija 0034_monthlyservicerdayentry za novu tablicu s UniqueConstraint
- Novi ViewSet + serializer + endpoint fleet/monthly-servicer-day-entries/
  (upsert: POST za novi dan ili update za isti dan ako vec postoji)
- Backend DOCX report (_build_monthly_servicer_report_rows) sad generira
  redak za SVAKI dan u mjesecu: taskovi > rucni unos > prazan red
- TaskCalendarWidget potpuno prepisan bez mojibake znakova
  (zamjena UTF-8 specijalnih znakova ASCII ekvivalentima u string literalima)
- Tablica izvjestaja servisera: klikom na red bez naloga otvara se overlay
  s tri opcije (Rad u uredu / Bolovanje / Godisnji)
- TaskCalendarPortal: dodan calendarWorkOrders computed (svi statusi)
  kako bi report imao potpune podatke o radnom vremenu
- fetchMonthlyServicerEntries i upsertMonthlyServicerEntry u store-u

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-31 16:42:07 +02:00
parent fe739b14c0
commit 3a33751012
8 changed files with 781 additions and 205 deletions

View File

@@ -45,6 +45,7 @@ from .models import (
VehicleNotification,
EmailDispatchLog,
ServiceContextNote,
MonthlyServicerDayEntry,
WorkOrder,
WorkOrderPhoto,
GeneratedWorkOrderPdf,
@@ -65,6 +66,7 @@ from .serializers import (
VehicleNotificationSerializer,
ServiceContextNoteSerializer,
ServiceContextNoteCreateSerializer,
MonthlyServicerDayEntrySerializer,
VehicleServicePhotoSerializer,
VehicleServiceAttachmentSerializer,
)
@@ -2114,6 +2116,210 @@ _MONTH_NAMES_HR = [
]
def _format_report_hours(value, default='-'):
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 or '0'
def _build_monthly_servicer_report_rows(user, year, month):
from calendar import monthrange
from datetime import date as _date
from modules.task_management.models import Task as _TaskModel
tasks_qs = (
_TaskModel.objects
.filter(
assigned_to=user,
is_active=True,
scheduled_date__year=year,
scheduled_date__month=month,
)
.select_related(
'vehicle',
'vehicle__client',
'work_order',
'work_order__vehicle',
'work_order__vehicle__client',
)
.order_by('scheduled_date', 'created_at')
)
manual_entries = MonthlyServicerDayEntry.objects.filter(
user=user,
is_active=True,
entry_date__year=year,
entry_date__month=month,
).order_by('entry_date')
tasks_by_date = OrderedDict()
for task in tasks_qs:
tasks_by_date.setdefault(task.scheduled_date, []).append(task)
manual_by_date = {
entry.entry_date: entry
for entry in manual_entries
}
rows = []
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:
titles = OrderedDict()
serials = OrderedDict()
clients = OrderedDict()
locations = OrderedDict()
work_orders = OrderedDict()
start_values = []
end_values = []
counted_work_order_ids = set()
total_hours = 0.0
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
start_label = '-'
end_label = '-'
if start_values:
start_label = min(value for _, value in 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')
rows.append({
'date': current_date,
'datum': current_date.strftime('%d.%m.%Y'),
'opis_posla': ' | '.join(titles.values()) or '-',
'br_dizalice': ', '.join(serials.values()) or '-',
'komitent': ', '.join(clients.values()) or '-',
'mjesto_rada': ', '.join(locations.values()) or '-',
'pocetak_rada': start_label,
'kraj_rada': end_label,
'redovan_rad': regular_hours,
'prekovremeni': overtime_hours,
'radni_nalog': ', '.join(value for value in work_orders.values() if value) or '-',
'source': 'task',
})
continue
manual_entry = manual_by_date.get(current_date)
if manual_entry:
rows.append({
'date': current_date,
'datum': current_date.strftime('%d.%m.%Y'),
'opis_posla': str(manual_entry.description or '').strip() or '-',
'br_dizalice': '-',
'komitent': '-',
'mjesto_rada': str(manual_entry.location or '').strip() or '-',
'pocetak_rada': manual_entry.start_time.strftime('%H:%M') if manual_entry.start_time else '-',
'kraj_rada': manual_entry.end_time.strftime('%H:%M') if manual_entry.end_time else '-',
'redovan_rad': _format_report_hours(manual_entry.regular_hours, default='0'),
'prekovremeni': _format_report_hours(manual_entry.overtime_hours, default='0'),
'radni_nalog': '-',
'source': manual_entry.entry_type,
})
continue
rows.append({
'date': current_date,
'datum': current_date.strftime('%d.%m.%Y'),
'opis_posla': '-',
'br_dizalice': '-',
'komitent': '-',
'mjesto_rada': '-',
'pocetak_rada': '-',
'kraj_rada': '-',
'redovan_rad': '-',
'prekovremeni': '0',
'radni_nalog': '-',
'source': 'empty',
})
return rows
class MonthlyServicerDayEntryViewSet(viewsets.ModelViewSet):
serializer_class = MonthlyServicerDayEntrySerializer
permission_classes = [permissions.IsAuthenticated]
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
def get_queryset(self):
queryset = MonthlyServicerDayEntry.objects.filter(
user=self.request.user,
is_active=True,
).order_by('entry_date')
year = self.request.query_params.get('year')
month = self.request.query_params.get('month')
if year and str(year).isdigit():
queryset = queryset.filter(entry_date__year=int(year))
if month and str(month).isdigit():
queryset = queryset.filter(entry_date__month=int(month))
return queryset
def create(self, request, *args, **kwargs):
raw_entry_date = request.data.get('entry_date')
existing = None
if raw_entry_date:
existing = self.get_queryset().filter(entry_date=raw_entry_date).first()
if existing is not None:
serializer = self.get_serializer(existing, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
entry = serializer.save()
return Response(self.get_serializer(entry).data, status=status.HTTP_200_OK)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
entry = serializer.save(user=request.user)
return Response(self.get_serializer(entry).data, status=status.HTTP_201_CREATED)
def perform_destroy(self, instance):
instance.is_active = False
instance.save(update_fields=['is_active', 'updated_at'])
@api_view(['GET'])
@permission_classes([permissions.IsAuthenticated])
def monthly_servicer_report_docx(request):
@@ -2128,61 +2334,10 @@ def monthly_servicer_report_docx(request):
return Response({'detail': 'Nevažeći year/month parametar.'}, status=400)
try:
from modules.task_management.models import Task as _TaskModel
normalized_rows = _build_monthly_servicer_report_rows(request.user, year, month)
except ImportError:
return Response({'detail': 'Task model nije dostupan.'}, status=500)
tasks_qs = (
_TaskModel.objects
.filter(
assigned_to=request.user,
is_active=True,
scheduled_date__year=year,
scheduled_date__month=month,
)
.select_related('work_order', 'work_order__vehicle', 'work_order__vehicle__client')
.order_by('scheduled_date', 'created_at')
)
rows = []
for task in tasks_qs:
wo = task.work_order
datum = task.scheduled_date.strftime('%d.%m.%Y') if task.scheduled_date else '-'
opis_posla = task.title or '-'
br_dizalice = '-'
komitent = '-'
mjesto_rada = '-'
pocetak_rada = '-'
kraj_rada = '-'
redovan_rad = '-'
prekovremeni = '0'
radni_nalog = '-'
if wo:
vehicle = getattr(wo, 'vehicle', None)
if vehicle:
sn = str(getattr(vehicle, 'crane_serial_number', '') or '').strip()
br_dizalice = sn or '-'
client_obj = getattr(vehicle, 'client', None)
komitent = str(getattr(client_obj, 'name', '') or '').strip() or '-'
mjesto_rada = wo.location or '-'
ts = wo.travel_start_at
te = wo.travel_end_at
if ts:
pocetak_rada = timezone.localtime(ts).strftime('%H:%M')
if te:
kraj_rada = timezone.localtime(te).strftime('%H:%M')
if ts and te and te > ts:
delta_h = (te - ts).total_seconds() / 3600.0
reg = min(delta_h, 8.0)
ovt = max(0.0, delta_h - 8.0)
redovan_rad = f"{reg:.2f}".rstrip('0').rstrip('.')
prekovremeni = f"{ovt:.2f}".rstrip('0').rstrip('.') if ovt > 0 else '0'
radni_nalog = str(wo.display_code or '').strip() or '-'
rows.append([datum, opis_posla, br_dizalice, komitent, mjesto_rada,
pocetak_rada, kraj_rada, redovan_rad, prekovremeni, radni_nalog])
from docx import Document as _DocxDoc
from docx.shared import Pt, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
@@ -2217,7 +2372,7 @@ def monthly_servicer_report_docx(request):
col_widths = [Cm(2.2), Cm(5.5), Cm(2.5), Cm(3.5), Cm(3.5),
Cm(2.2), Cm(2.2), Cm(2.4), Cm(2.4), Cm(2.4)]
table = doc.add_table(rows=1 + len(rows), cols=len(headers))
table = doc.add_table(rows=1 + len(normalized_rows), cols=len(headers))
table.style = 'Table Grid'
hdr_cells = table.rows[0].cells
@@ -2229,9 +2384,21 @@ def monthly_servicer_report_docx(request):
run_h.bold = True
run_h.font.size = Pt(8)
for ri, row_data in enumerate(rows):
for ri, row_data in enumerate(normalized_rows):
data_cells = table.rows[ri + 1].cells
for ci, (val, w) in enumerate(zip(row_data, col_widths)):
values = [
row_data['datum'],
row_data['opis_posla'],
row_data['br_dizalice'],
row_data['komitent'],
row_data['mjesto_rada'],
row_data['pocetak_rada'],
row_data['kraj_rada'],
row_data['redovan_rad'],
row_data['prekovremeni'],
row_data['radni_nalog'],
]
for ci, (val, w) in enumerate(zip(values, col_widths)):
data_cells[ci].width = w
data_cells[ci].text = str(val)
if data_cells[ci].paragraphs[0].runs: