Files
004.erp.generic/backend/modules/fleet/serializers.py
mariomitte 51a65f9b80
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
fix: omoguci rucni unos vremena u mjesecnom izvjestaju
Dodaje uredski unos pocetka i kraja rada u sidebaru mjesecnog izvjestaja servisera i automatski racuna redovan rad iz unesenog raspona. Zadrzava postojece predpopunjavanje za rad u uredu i validira neispravan vremenski raspon.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-31 17:37:44 +02:00

634 lines
27 KiB
Python

from rest_framework import serializers
import re
from pathlib import Path
from decimal import Decimal, InvalidOperation
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from modules.task_management.models import Task
from .models import (
Crane,
Vehicle,
ServiceContextNote,
MonthlyServicerDayEntry,
VehicleNotification,
WorkOrder,
WorkOrderAdditionalCostsTable,
WorkOrderPhoto,
WorkOrderInvoice,
VehicleServiceRecord,
VehicleServicePhoto,
VehicleServiceAttachment,
)
from . import services
User = get_user_model()
class VehicleSerializer(serializers.ModelSerializer):
# Čitljivo ime klijenta — read-only, za prikaz u frontendu i adminu
client_name = serializers.SerializerMethodField()
class Meta:
model = Vehicle
fields = [
'id', 'asset_type', 'registration_number', 'make', 'model', 'year', 'vin',
'crane_serial_number',
'superstructure_working_hours', 'chassis_working_hours',
'current_mileage', 'service_interval_km',
'is_company_vehicle', 'client', 'client_name',
'assigned_servicer', 'is_active',
]
read_only_fields = ['id', 'client_name']
def get_client_name(self, obj):
if obj.client_id:
return obj.client.name
return None
def create(self, validated_data):
validated_data.setdefault('asset_type', 'vehicle')
return services.VehicleService.create_vehicle(validated_data)
def update(self, instance, validated_data):
validated_data.pop('asset_type', None)
return services.VehicleService.update_vehicle(instance, validated_data)
def validate_crane_serial_number(self, value):
if value in (None, ''):
return None
normalized = str(value).strip()
if not re.fullmatch(r"[0-9]{6}", normalized):
raise serializers.ValidationError('Serijski broj dizalice mora sadržavati točno 6 znamenki.')
return normalized
def validate(self, attrs):
asset_type = attrs.get('asset_type', getattr(self.instance, 'asset_type', None))
if asset_type is None and getattr(self.Meta, 'model', None) is Crane:
asset_type = 'crane'
serial = attrs.get('crane_serial_number', getattr(self.instance, 'crane_serial_number', None))
is_create = self.instance is None
if asset_type == 'crane' and not serial and is_create:
raise serializers.ValidationError({
'crane_serial_number': 'Serijski broj dizalice je obavezan za dizalice.'
})
return attrs
class CraneSerializer(VehicleSerializer):
display_name = serializers.SerializerMethodField()
class Meta(VehicleSerializer.Meta):
model = Crane
fields = VehicleSerializer.Meta.fields + ['display_name']
read_only_fields = VehicleSerializer.Meta.read_only_fields + ['display_name']
def get_display_name(self, obj):
details = " ".join(part for part in [obj.make, obj.model] if part)
return f"{obj.registration_number} {details}".strip()
def create(self, validated_data):
validated_data['asset_type'] = 'crane'
return services.VehicleService.create_vehicle(validated_data)
def update(self, instance, validated_data):
validated_data.pop('asset_type', None)
return services.VehicleService.update_vehicle(instance, validated_data)
class VehicleNotificationSerializer(serializers.ModelSerializer):
class Meta:
model = VehicleNotification
fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'is_read', 'created_at']
read_only_fields = ['id', 'title', 'message', 'metadata', 'level', 'is_sent', 'created_at']
# is_read is writable via the "mark_read" action, but normally read-only in list
class ServiceContextNoteSerializer(serializers.ModelSerializer):
created_by_name = serializers.SerializerMethodField()
recipient_name = serializers.SerializerMethodField()
work_order_label = serializers.SerializerMethodField()
task_title = serializers.SerializerMethodField()
class Meta:
model = ServiceContextNote
fields = [
'id',
'created_by',
'created_by_name',
'recipient',
'recipient_name',
'audience_source',
'work_order',
'work_order_label',
'task',
'task_title',
'note',
'note_date',
'is_closed',
'closed_at',
'created_at',
'updated_at',
]
read_only_fields = fields
def get_created_by_name(self, obj):
return obj.created_by.get_full_name() or obj.created_by.email
def get_recipient_name(self, obj):
return obj.recipient.get_full_name() or obj.recipient.email
def get_work_order_label(self, obj):
if not obj.work_order_id:
return None
display_code = str(getattr(obj.work_order, 'display_code', '') or '').strip().upper()
return display_code or f"WO-{obj.work_order_id}"
def get_task_title(self, obj):
if not obj.task_id:
return None
return obj.task.title
class ServiceContextNoteCreateSerializer(serializers.Serializer):
AUDIENCE_CHOICES = ['self', 'member', 'team']
note = serializers.CharField(required=True, allow_blank=False, trim_whitespace=True)
note_date = serializers.DateField(required=False, allow_null=True)
audience = serializers.ChoiceField(choices=AUDIENCE_CHOICES, default='self')
target_user = serializers.PrimaryKeyRelatedField(
queryset=User.objects.filter(is_active=True),
required=False,
allow_null=True,
)
target_users = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.filter(is_active=True)),
required=False,
allow_empty=False,
)
work_order = serializers.PrimaryKeyRelatedField(
queryset=WorkOrder.objects.filter(is_active=True),
required=False,
allow_null=True,
)
task = serializers.PrimaryKeyRelatedField(
queryset=Task.objects.filter(is_active=True),
required=False,
allow_null=True,
)
def validate(self, attrs):
request = self.context.get('request')
user = getattr(request, 'user', None)
if user is None:
raise serializers.ValidationError('Autentikacija je obavezna.')
audience = attrs.get('audience', 'self')
target_user = attrs.get('target_user')
target_users = attrs.get('target_users') or []
work_order = attrs.get('work_order')
task = attrs.get('task')
note = str(attrs.get('note', '') or '').strip()
can_manage_recipients = bool(
getattr(user, 'is_serviser', False)
or getattr(user, 'is_staff', False)
or (getattr(user, 'is_team_member', False) and not getattr(user, 'is_serviser', False))
)
if not note:
raise serializers.ValidationError({'note': 'Bilješka ne može biti prazna.'})
if not can_manage_recipients and audience != 'self':
raise serializers.ValidationError({'audience': 'Samo supervisor ili serviser može slati bilješke drugim članovima tima.'})
if audience == 'member' and not target_user and len(target_users) == 0:
raise serializers.ValidationError({'target_users': 'Odaberite barem jednog servisera.'})
if audience in {'self', 'team'} and target_user:
raise serializers.ValidationError({'target_user': 'Odabrani tip publike ne koristi ciljano polje člana tima.'})
if audience in {'self', 'team'} and len(target_users) > 0:
raise serializers.ValidationError({'target_users': 'Odabrani tip publike ne koristi listu servisera.'})
if target_user and not getattr(target_user, 'is_serviser', False):
raise serializers.ValidationError({'target_user': 'Bilješku je moguće poslati samo serviseru.'})
for selected_user in target_users:
if not getattr(selected_user, 'is_serviser', False):
raise serializers.ValidationError({'target_users': 'Bilješku je moguće poslati samo serviserima.'})
if work_order:
if not work_order.is_active or str(work_order.status or '').lower() != 'open':
raise serializers.ValidationError({'work_order': 'Moguće je povezati samo aktivni otvoreni putni nalog.'})
if task:
status = str(task.status or '').lower()
if status in {'zavrsen', 'neaktivan'}:
raise serializers.ValidationError({'task': 'Moguće je povezati samo aktivni radni zadatak.'})
if task and work_order and task.work_order_id and str(task.work_order_id) != str(work_order.id):
raise serializers.ValidationError({'task': 'Radni zadatak ne pripada odabranom putnom nalogu.'})
attrs['note'] = note
if task and not work_order and task.work_order_id:
attrs['work_order'] = task.work_order
if audience == 'member' and len(target_users) == 0 and target_user:
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',
'regular_hours',
'overtime_hours',
'created_at',
'updated_at',
]
class WorkOrderSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
crane_label = serializers.SerializerMethodField()
class Meta:
model = WorkOrder
fields = [
'id', 'display_code', 'vehicle', 'crane', 'crane_label', 'creator', 'date',
'start_mileage', 'end_mileage',
'servicer_vehicle_registration', 'servicer_vehicle_make_model',
'servicer_vehicle_start_mileage', 'servicer_vehicle_end_mileage',
'servicer_vehicle_fuel_cost',
'origin_location', 'location', 'travel_start_at', 'travel_end_at',
'purpose', 'notes', 'status'
]
read_only_fields = ['id', 'date']
extra_kwargs = {
'vehicle': {'required': False},
'creator': {'required': False},
'start_mileage': {'required': False, 'allow_null': True},
'end_mileage': {'required': False, 'allow_null': True},
'display_code': {'required': False, 'allow_blank': False},
'servicer_vehicle_registration': {'required': False, 'allow_blank': True},
'servicer_vehicle_make_model': {'required': False, 'allow_blank': True},
'servicer_vehicle_start_mileage': {'required': False, 'allow_null': True},
'servicer_vehicle_end_mileage': {'required': False, 'allow_null': True},
'servicer_vehicle_fuel_cost': {'required': False, 'allow_null': True},
'origin_location': {'required': False, 'allow_blank': True},
'location': {'required': False, 'allow_blank': True},
'travel_start_at': {'required': False, 'allow_null': True},
'travel_end_at': {'required': False, 'allow_null': True},
}
def get_crane(self, obj):
return obj.vehicle_id
def get_crane_label(self, obj):
if not obj.vehicle_id:
return None
details = " ".join(part for part in [obj.vehicle.registration_number, obj.vehicle.make, obj.vehicle.model] if part)
return details.strip()
def create(self, validated_data):
request = self.context.get('request')
user = getattr(request, 'user', None)
if not validated_data.get('vehicle'):
raise serializers.ValidationError({
'vehicle': 'Odaberite dizalicu za putni nalog iz servisnog konteksta.'
})
if getattr(validated_data['vehicle'], 'asset_type', None) != 'crane':
raise serializers.ValidationError({
'vehicle': 'Za putni nalog morate odabrati dizalicu (asset_type=crane).'
})
if user and 'creator' not in validated_data:
validated_data['creator'] = user
if not validated_data.get('display_code'):
creator = validated_data.get('creator')
generated_display_code = self._generate_display_code(creator, timezone.localdate())
if not generated_display_code:
raise serializers.ValidationError({
'display_code': 'Serviser mora imati ime i prezime za automatsko generiranje oznake. Unesite oznaku ručno u formatu MT070626.'
})
validated_data['display_code'] = generated_display_code
return services.VehicleService.create_work_order(validated_data)
def update(self, instance, validated_data):
return services.VehicleService.update_work_order(instance, validated_data)
def validate(self, attrs):
current = getattr(self, 'instance', None)
travel_start_at = attrs.get('travel_start_at', getattr(current, 'travel_start_at', None))
travel_end_at = attrs.get('travel_end_at', getattr(current, 'travel_end_at', None))
location = attrs.get('location', getattr(current, 'location', None))
start_mileage = attrs.get('start_mileage', getattr(current, 'start_mileage', None))
end_mileage = attrs.get('end_mileage', getattr(current, 'end_mileage', None))
servicer_start_mileage = attrs.get('servicer_vehicle_start_mileage', getattr(current, 'servicer_vehicle_start_mileage', None))
servicer_end_mileage = attrs.get('servicer_vehicle_end_mileage', getattr(current, 'servicer_vehicle_end_mileage', None))
servicer_fuel_cost = attrs.get('servicer_vehicle_fuel_cost', getattr(current, 'servicer_vehicle_fuel_cost', None))
if (travel_start_at is None) ^ (travel_end_at is None):
raise serializers.ValidationError({'travel_end_at': 'Unesite i početak i kraj puta, ili ostavite oba prazna.'})
if travel_start_at is not None and not str(location or '').strip():
raise serializers.ValidationError({'location': 'Lokacija je obavezna kada je definiran putni nalog.'})
if travel_start_at and travel_end_at and travel_end_at < travel_start_at:
raise serializers.ValidationError({'travel_end_at': 'Kraj puta ne može biti prije početka puta.'})
if end_mileage is not None and start_mileage is None:
raise serializers.ValidationError({'start_mileage': 'Početna kilometraža je obavezna ako je upisana završna.'})
if start_mileage is not None and end_mileage is not None and end_mileage < start_mileage:
raise serializers.ValidationError({'end_mileage': 'Završna kilometraža mora biti veća ili jednaka početnoj.'})
if servicer_end_mileage is not None and servicer_start_mileage is None:
raise serializers.ValidationError({'servicer_vehicle_start_mileage': 'Početna kilometraža vozila servisera je obavezna ako je upisana završna.'})
if servicer_start_mileage is not None and servicer_end_mileage is not None and servicer_end_mileage <= servicer_start_mileage:
raise serializers.ValidationError({'servicer_vehicle_end_mileage': 'Završna kilometraža vozila servisera mora biti veća od početne.'})
if servicer_fuel_cost is not None and servicer_fuel_cost < 0:
raise serializers.ValidationError({'servicer_vehicle_fuel_cost': 'Trošak goriva ne može biti negativan.'})
return attrs
def validate_display_code(self, value):
normalized = str(value or '').strip().upper()
if not re.fullmatch(r"[A-Z]{2}[0-9]{6}", normalized):
raise serializers.ValidationError('Prikaz putnog naloga mora biti u formatu MT070626 (2 slova + 6 znamenki).')
return normalized
@staticmethod
def _generate_display_code(user, target_date):
if user is None:
return None
first_name = str(getattr(user, 'first_name', '') or '').strip()
last_name = str(getattr(user, 'last_name', '') or '').strip()
if not first_name or not last_name:
return None
return f"{first_name[0].upper()}{last_name[0].upper()}{target_date.strftime('%d%m%y')}"
class WorkOrderInvoiceSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
image_content_type = serializers.SerializerMethodField()
work_order_display_code = serializers.SerializerMethodField()
class Meta:
model = WorkOrderInvoice
fields = [
'id',
'work_order',
'work_order_display_code',
'naziv_racuna',
'lokacija',
'datum',
'opis',
'image',
'image_url',
'image_content_type',
'created_by',
'created_at',
]
read_only_fields = ['id', 'created_by', 'created_at']
def get_image_url(self, obj):
request = self.context.get('request')
if not obj.image:
return None
if request is None:
return None
base = request.build_absolute_uri(
reverse('work-order-invoice-image', kwargs={'pk': obj.pk})
)
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return base
return f"{base}?w=1280&q=80&fmt=jpeg"
def get_image_content_type(self, obj):
if not obj.image:
return None
suffix = Path(obj.image.name or '').suffix.lower()
if suffix == '.pdf':
return 'application/pdf'
if suffix in {'.jpg', '.jpeg'}:
return 'image/jpeg'
if suffix == '.png':
return 'image/png'
if suffix == '.webp':
return 'image/webp'
if suffix == '.gif':
return 'image/gif'
return 'application/octet-stream'
def get_work_order_display_code(self, obj):
work_order = getattr(obj, 'work_order', None)
if work_order is None:
return None
display_code = str(getattr(work_order, 'display_code', '') or '').strip()
if display_code:
return display_code
return str(getattr(work_order, 'id', '') or '').strip() or None
def validate_image(self, value):
if not value:
return value
suffix = Path(getattr(value, 'name', '')).suffix.lower()
allowed_suffixes = {'.jpg', '.jpeg', '.png', '.webp', '.gif', '.pdf'}
if suffix not in allowed_suffixes:
raise serializers.ValidationError('Datoteka računa mora biti slika (.jpg, .jpeg, .png, .webp, .gif) ili PDF (.pdf).')
return value
class WorkOrderAdditionalCostsTableSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrderAdditionalCostsTable
fields = ['id', 'work_order', 'data', 'total_for_payout', 'created_at', 'updated_at']
read_only_fields = ['id', 'total_for_payout', 'created_at', 'updated_at']
@staticmethod
def _parse_decimal(value):
if value in (None, ''):
return Decimal('0.00')
try:
normalized = str(value).strip().replace('', '').replace(' ', '').replace(',', '.')
return Decimal(normalized)
except (InvalidOperation, ValueError, TypeError):
return Decimal('0.00')
def _normalize_row(self, row):
return {
'naziv': str(row.get('naziv', '') or '').strip(),
'broj_racuna': str(row.get('broj_racuna', '') or '').strip(),
'ukupan_iznos': str(row.get('ukupan_iznos', '') or '').strip(),
'prilog': str(row.get('prilog', '') or '').strip(),
'source_invoice_id': str(row.get('source_invoice_id', '') or '').strip(),
}
def validate_data(self, value):
if value is None:
return {'rows': []}
if not isinstance(value, dict):
raise serializers.ValidationError("Podaci tablice moraju biti JSON objekt.")
rows = value.get('rows', [])
if not isinstance(rows, list):
raise serializers.ValidationError({"rows": "Polje 'rows' mora biti lista redaka."})
normalized_rows = []
for row in rows[:100]:
if not isinstance(row, dict):
raise serializers.ValidationError({"rows": "Svaki redak mora biti JSON objekt."})
normalized_rows.append(self._normalize_row(row))
return {'rows': normalized_rows}
def _calculate_total_for_payout(self, data):
rows = data.get('rows', []) if isinstance(data, dict) else []
total = sum((self._parse_decimal(row.get('ukupan_iznos')) for row in rows), Decimal('0.00'))
return total.quantize(Decimal('0.01'))
def create(self, validated_data):
validated_data['total_for_payout'] = self._calculate_total_for_payout(validated_data.get('data'))
return super().create(validated_data)
def update(self, instance, validated_data):
data = validated_data.get('data', instance.data or {'rows': []})
validated_data['total_for_payout'] = self._calculate_total_for_payout(data)
return super().update(instance, validated_data)
class WorkOrderPhotoSerializer(serializers.ModelSerializer):
class Meta:
model = WorkOrderPhoto
fields = ['id', 'work_order', 'image', 'description', 'uploaded_by', 'created_at']
read_only_fields = ['id', 'uploaded_by', 'created_at']
extra_kwargs = {
'work_order': {'required': False},
}
class VehicleServiceRecordSerializer(serializers.ModelSerializer):
crane = serializers.SerializerMethodField()
crane_registration = serializers.SerializerMethodField()
photos_count = serializers.SerializerMethodField()
attachments_count = serializers.SerializerMethodField()
files_count = serializers.SerializerMethodField()
performed_by_name = serializers.SerializerMethodField()
vehicle_registration = serializers.SerializerMethodField()
task_label = serializers.SerializerMethodField()
crane_serial_number = serializers.SerializerMethodField()
class Meta:
model = VehicleServiceRecord
fields = [
'id', 'vehicle', 'crane', 'vehicle_registration', 'crane_registration',
'task', 'task_label', 'crane_serial_number',
'performed_by', 'performed_by_name',
'service_title', 'description', 'parts', 'cost',
'service_date', 'mileage', 'next_service_due_at',
'photos_count', 'attachments_count', 'files_count',
]
read_only_fields = [
'id', 'service_date', 'next_service_due_at',
'photos_count', 'attachments_count', 'files_count',
'performed_by_name', 'vehicle_registration', 'crane', 'crane_registration',
'task_label',
]
extra_kwargs = {
'performed_by': {'required': False},
'vehicle': {'required': False},
'mileage': {'required': False, 'allow_null': True},
}
def get_photos_count(self, obj):
# prefetch_related('photos') u viewsetu sprječava N+1
return obj.photos.count()
def get_crane(self, obj):
return obj.vehicle_id
def get_attachments_count(self, obj):
return obj.attachments.count()
def get_files_count(self, obj):
return self.get_photos_count(obj) + self.get_attachments_count(obj)
def get_performed_by_name(self, obj):
if obj.performed_by_id:
return obj.performed_by.get_full_name() or obj.performed_by.username
return None
def get_vehicle_registration(self, obj):
if obj.vehicle_id:
return obj.vehicle.registration_number
return None
def get_crane_registration(self, obj):
return self.get_vehicle_registration(obj)
def get_task_label(self, obj):
if not obj.task_id:
return None
return obj.task.title
def get_crane_serial_number(self, obj):
# Provjeri je li povezano vozilo/dizalica postoji i vrati serijski broj
if hasattr(obj, 'vehicle') and obj.vehicle:
return getattr(obj.vehicle, 'crane_serial_number', None)
return None
def create(self, validated_data):
request = self.context.get('request')
user = getattr(request, 'user', None)
task = validated_data.get('task')
if not task:
raise serializers.ValidationError({
'task': 'Odaberite radni zadatak prije kreiranja servisnog zapisa.'
})
if not task.vehicle_id:
raise serializers.ValidationError({
'task': 'Odabrani radni zadatak nema dodijeljenu dizalicu.'
})
validated_data['vehicle'] = task.vehicle
if user and not getattr(user, 'is_staff', False):
validated_data['performed_by'] = user
elif user and 'performed_by' not in validated_data:
validated_data['performed_by'] = user
return services.VehicleService.create_service_record(validated_data)
class VehicleServicePhotoSerializer(serializers.ModelSerializer):
image = serializers.ImageField(required=True)
class Meta:
model = VehicleServicePhoto
fields = ['id', 'service_record', 'image', 'description', 'uploaded_by', 'created_at']
read_only_fields = ['id', 'created_at', 'uploaded_by']
def create(self, validated_data):
# Ako postoji request user, postavi uploaded_by
request = self.context.get('request') if self.context else None
user = getattr(request, 'user', None)
if user and 'uploaded_by' not in validated_data:
validated_data['uploaded_by'] = user
# Kreiranje direktno kroz model je ok; može se delegirati u services ako želiš dodatnu logiku
return VehicleServicePhoto.objects.create(**validated_data)
class VehicleServiceAttachmentSerializer(serializers.ModelSerializer):
file = serializers.FileField(required=True)
class Meta:
model = VehicleServiceAttachment
fields = ['id', 'service_record', 'file', 'description', 'uploaded_by', 'created_at']
read_only_fields = ['id', 'created_at', 'uploaded_by']
def create(self, validated_data):
request = self.context.get('request') if self.context else None
user = getattr(request, 'user', None)
if user and 'uploaded_by' not in validated_data:
validated_data['uploaded_by'] = user
return VehicleServiceAttachment.objects.create(**validated_data)