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

@@ -0,0 +1,45 @@
# Generated by Django 5.2.15 on 2026-07-31 12:30
import uuid
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fleet', '0033_emaildispatchlog'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='MonthlyServicerDayEntry',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('entry_date', models.DateField(db_index=True, verbose_name='Datum')),
('entry_type', models.CharField(choices=[('office', 'Rad u uredu'), ('sick_leave', 'Bolovanje'), ('vacation', 'Godišnji odmor')], max_length=24, verbose_name='Vrsta unosa')),
('description', models.CharField(blank=True, max_length=255, verbose_name='Opis posla')),
('location', models.CharField(blank=True, max_length=255, verbose_name='Mjesto rada')),
('start_time', models.TimeField(blank=True, null=True, verbose_name='Početak rada')),
('end_time', models.TimeField(blank=True, null=True, verbose_name='Kraj rada')),
('regular_hours', models.DecimalField(decimal_places=2, default=Decimal('8.00'), max_digits=5, verbose_name='Redovan rad')),
('overtime_hours', models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=5, verbose_name='Prekovremeni rad')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='monthly_servicer_day_entries', to=settings.AUTH_USER_MODEL, verbose_name='Serviser')),
],
options={
'verbose_name': 'Dnevni unos mjesečnog izvještaja servisera',
'verbose_name_plural': 'Dnevni unosi mjesečnog izvještaja servisera',
'ordering': ['entry_date'],
},
),
migrations.AddConstraint(
model_name='monthlyservicerdayentry',
constraint=models.UniqueConstraint(fields=('user', 'entry_date', 'is_active'), name='uniq_monthly_servicer_day_entry_user_date_active'),
),
]

View File

@@ -1,5 +1,6 @@
import uuid
import re
from datetime import time
from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -694,4 +695,84 @@ class ServiceContextNote(BaseModel):
if self.task.work_order_id != self.work_order_id:
raise ValidationError({
'task': _("Radni zadatak mora biti povezan s istim putnim nalogom kao bilješka."),
})
})
class MonthlyServicerDayEntry(BaseModel):
ENTRY_TYPE_CHOICES = [
('office', _('Rad u uredu')),
('sick_leave', _('Bolovanje')),
('vacation', _('Godišnji odmor')),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='monthly_servicer_day_entries',
verbose_name=_('Serviser'),
)
entry_date = models.DateField(db_index=True, verbose_name=_('Datum'))
entry_type = models.CharField(
max_length=24,
choices=ENTRY_TYPE_CHOICES,
verbose_name=_('Vrsta unosa'),
)
description = models.CharField(max_length=255, blank=True, verbose_name=_('Opis posla'))
location = models.CharField(max_length=255, blank=True, verbose_name=_('Mjesto rada'))
start_time = models.TimeField(null=True, blank=True, verbose_name=_('Početak rada'))
end_time = models.TimeField(null=True, blank=True, verbose_name=_('Kraj rada'))
regular_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('8.00'),
verbose_name=_('Redovan rad'),
)
overtime_hours = models.DecimalField(
max_digits=5,
decimal_places=2,
default=Decimal('0.00'),
verbose_name=_('Prekovremeni rad'),
)
class Meta:
verbose_name = _('Dnevni unos mjesečnog izvještaja servisera')
verbose_name_plural = _('Dnevni unosi mjesečnog izvještaja servisera')
ordering = ['entry_date']
constraints = [
models.UniqueConstraint(
fields=['user', 'entry_date', 'is_active'],
name='uniq_monthly_servicer_day_entry_user_date_active',
),
]
def __str__(self):
return f"{self.user_id} @ {self.entry_date} ({self.entry_type})"
def apply_defaults(self):
if self.entry_type == 'office':
self.description = 'Rad u uredu'
self.location = 'Zagreb (ured)'
self.start_time = time(8, 0)
self.end_time = time(16, 0)
self.regular_hours = Decimal('8.00')
self.overtime_hours = Decimal('0.00')
return
if self.entry_type == 'vacation':
self.description = 'Godišnji odmor'
elif self.entry_type == 'sick_leave':
self.description = 'Bolovanje'
self.location = ''
self.start_time = None
self.end_time = None
self.regular_hours = Decimal('8.00')
self.overtime_hours = Decimal('0.00')
def clean(self):
super().clean()
self.apply_defaults()
def save(self, *args, **kwargs):
self.full_clean()
return super().save(*args, **kwargs)

View File

@@ -10,6 +10,7 @@ from .models import (
Crane,
Vehicle,
ServiceContextNote,
MonthlyServicerDayEntry,
VehicleNotification,
WorkOrder,
WorkOrderAdditionalCostsTable,
@@ -228,6 +229,35 @@ class ServiceContextNoteCreateSerializer(serializers.Serializer):
attrs['target_users'] = [target_user]
return attrs
class MonthlyServicerDayEntrySerializer(serializers.ModelSerializer):
class Meta:
model = MonthlyServicerDayEntry
fields = [
'id',
'entry_date',
'entry_type',
'description',
'location',
'start_time',
'end_time',
'regular_hours',
'overtime_hours',
'created_at',
'updated_at',
]
read_only_fields = [
'id',
'description',
'location',
'start_time',
'end_time',
'regular_hours',
'overtime_hours',
'created_at',
'updated_at',
]
class WorkOrderSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
crane_label = serializers.SerializerMethodField()

View File

@@ -3,7 +3,7 @@ from django.urls import path
from .views import (
CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet,
WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth,
monthly_servicer_report_docx, monthly_costs_report_docx,
monthly_servicer_report_docx, monthly_costs_report_docx, MonthlyServicerDayEntryViewSet,
)
router = DefaultRouter()
@@ -16,6 +16,7 @@ router.register(r'service-records', VehicleServiceRecordViewSet, basename='servi
router.register(r'notifications', VehicleNotificationViewSet, basename='vehicle-notification')
router.register(r'service-photos', VehicleServicePhotoViewSet, basename='service-photo')
router.register(r'service-attachments', VehicleServiceAttachmentViewSet, basename='service-attachment')
router.register(r'monthly-servicer-day-entries', MonthlyServicerDayEntryViewSet, basename='monthly-servicer-day-entry')
urlpatterns = router.urls

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: