feat: dnevni unosi i svi dani u mjesecnom izvjestaju servisera
- 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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user