feat: update service report exports and calendar bulk downloads
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

Align service-record PDF/DOCX generation with task-level data and naming.

- Use Task.service_report_note as report note source
- Use Task.scheduled_date for service-record dates
- Rename per-task exports to MT...SN... format
- Add monthly SN/PN ZIP download endpoints with 7-day retention
- Add calendar 'Preuzmi sve' modal with both bulk download actions
- Prefill work-hours day/date from scheduled_date for empty tables
- Remove duplicate note table and clean extra DOCX page breaks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-08-06 10:32:03 +02:00
parent 9a7c7e3163
commit 594881f6cd
7 changed files with 474 additions and 44 deletions

View File

@@ -6,8 +6,10 @@ from django.utils import timezone
from rest_framework.test import APIClient
import uuid
from io import BytesIO
from datetime import timedelta
from datetime import date, timedelta
import zipfile
from pathlib import Path
from django.conf import settings
from PIL import Image
from modules.fleet.models import Vehicle, WorkOrder, WorkOrderInvoice, VehicleServiceRecord, VehicleServicePhoto, GeneratedWorkOrderPdf
@@ -55,13 +57,17 @@ class WorkOrderImagesEndpointTests(TestCase):
self.work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT150726',
purpose='kontrola',
notes='Napomena iz putnog naloga',
)
self.task = Task.objects.create(
title='Test servisni zadatak',
assigned_to=self.user,
vehicle=self.vehicle,
work_order=self.work_order,
scheduled_date=date(2033, 12, 24),
service_report_note='Napomena iz taska',
)
self.service_record = VehicleServiceRecord.objects.create(
@@ -271,3 +277,51 @@ class WorkOrderImagesEndpointTests(TestCase):
settings_xml = archive.read('word/settings.xml').decode('utf-8')
self.assertNotIn('documentProtection', settings_xml)
self.assertNotIn('writeProtection', settings_xml)
def test_task_specific_service_records_docx_uses_task_note_and_scheduled_date(self):
response = self.client.get(
f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}"
)
self.assertEqual(response.status_code, 200, response.content)
content_disposition = response.get('Content-Disposition', '')
self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', content_disposition)
archive = zipfile.ZipFile(BytesIO(response.content))
document_xml = archive.read('word/document.xml').decode('utf-8')
self.assertIn('Napomena iz taska', document_xml)
self.assertNotIn('Napomena iz putnog naloga', document_xml)
self.assertIn('24.12.2033', document_xml)
self.assertEqual(document_xml.count('w:type="page"'), 1)
def test_monthly_service_tasks_archive_returns_zip_with_task_docx(self):
response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/zip')
archive = zipfile.ZipFile(BytesIO(response.content))
names = archive.namelist()
self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', names)
def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self):
WorkOrderInvoice.objects.create(
work_order=self.work_order,
naziv_racuna='Prosinac račun',
datum='2033-12-10',
image=create_test_pdf('racun-prosinac.pdf'),
created_by=self.user,
)
before_files = set((Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives').glob('*.zip'))
response = self.client.get('/api/fleet/reports/monthly-work-orders-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/zip')
archive = zipfile.ZipFile(BytesIO(response.content))
names = archive.namelist()
self.assertIn('MT150726.work-order.pdf', names)
self.assertTrue(any(name.startswith('Racuni/MT150726/') for name in names), names)
generated_archives_dir = Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives'
self.assertTrue(generated_archives_dir.exists())
after_files = set(generated_archives_dir.glob('*.zip'))
self.assertTrue(after_files - before_files)

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, MonthlyServicerDayEntryViewSet,
monthly_servicer_report_docx, monthly_costs_report_docx, monthly_service_tasks_archive, monthly_work_orders_archive, MonthlyServicerDayEntryViewSet,
)
router = DefaultRouter()
@@ -24,4 +24,6 @@ urlpatterns += [
path('pusher-auth/', pusher_auth, name='pusher-auth'),
path('reports/monthly-servicer/', monthly_servicer_report_docx, name='monthly-servicer-report'),
path('reports/monthly-costs/', monthly_costs_report_docx, name='monthly-costs-report'),
path('reports/monthly-service-tasks-archive/', monthly_service_tasks_archive, name='monthly-service-tasks-archive'),
path('reports/monthly-work-orders-archive/', monthly_work_orders_archive, name='monthly-work-orders-archive'),
]

View File

@@ -6,6 +6,7 @@ import base64
import csv
import mimetypes
import threading
import zipfile
import re
from collections import OrderedDict
from datetime import timedelta
@@ -183,6 +184,7 @@ def _parse_format(value):
GENERATED_PDF_TTL_HOURS = 24
GENERATED_ARCHIVE_TTL_DAYS = 7
def _work_order_display_code(work_order):
@@ -192,6 +194,17 @@ def _work_order_display_code(work_order):
return 'NALOG'
def _sanitize_task_title_for_filename(value):
normalized = re.sub(r'\s+', '_', str(value or '').strip())
normalized = re.sub(r'[^A-Za-z0-9_-]+', '', normalized)
normalized = normalized.strip('._-')
return normalized or 'servisni_zapis'
def _service_task_filename_label(task):
return f"SN-{_sanitize_task_title_for_filename(getattr(task, 'title', ''))}"
def _pdf_filename(work_order, pdf_type):
display_code = _work_order_display_code(work_order)
if pdf_type == 'invoices':
@@ -214,14 +227,14 @@ def _service_records_pdf_filename(work_order, task=None):
if not task:
return _pdf_filename(work_order, 'service_records')
display_code = _work_order_display_code(work_order)
return f"{display_code}.task-{task.pk}.work-order-service-records.pdf"
return f"{display_code}.{_service_task_filename_label(task)}.pdf"
def _service_records_docx_filename(work_order, task=None):
if not task:
return _docx_filename(work_order, 'service_records')
display_code = _work_order_display_code(work_order)
return f"{display_code}.task-{task.pk}.work-order-service-records.docx"
return f"{display_code}.{_service_task_filename_label(task)}.docx"
def _resolve_service_report_tasks(work_order, task_id):
@@ -941,13 +954,13 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
.prefetch_related('photos')
.order_by('service_date', 'created_at')
)
# Per-task notes; fall back to work_order.notes
# Per-task notes from Task.service_report_note (edited via "Uredi tekst napomene")
task_note_parts = [
str(task.service_report_note or '').strip()
for task in related_tasks
if str(task.service_report_note or '').strip()
]
notes_text = "\n".join(task_note_parts) if task_note_parts else str(work_order.notes or '').strip()
notes_text = "\n".join(task_note_parts) if task_note_parts else '-'
buffer = BytesIO()
@@ -1387,8 +1400,9 @@ def _build_service_record_pdf(service_record):
y = content_top - 6
y -= 6
task_scheduled_date = getattr(task, 'scheduled_date', None) if task else None
info_rows = [
["ID", str(service_record.pk), "Datum", _fmt_date(service_record.service_date)],
["ID", str(service_record.pk), "Datum", _fmt_date(task_scheduled_date)],
["Naziv", service_record.service_title or '-', "Servisni zadatak", getattr(task, 'title', '-') or '-'],
["Dizalica", getattr(vehicle, 'registration_number', '-') or '-', "SN", getattr(vehicle, 'crane_serial_number', '-') or '-'],
["Serviser", _user_display_name(service_record.performed_by) or '-', "KM", str(service_record.mileage or '-')],
@@ -1704,6 +1718,12 @@ def _docx_remove_rows_after(table, keep_rows=1):
table._tbl.remove(row._tr)
def _docx_remove_table(table):
parent = table._tbl.getparent()
if parent is not None:
parent.remove(table._tbl)
def _docx_move_table_after_paragraph_text(document, table, marker_text):
if not marker_text:
return False
@@ -1745,11 +1765,15 @@ def _extract_unique_parts_entries(records):
if match:
serial = _normalize_whitespace(match.group(1)) or '-'
description = _normalize_whitespace(match.group(2)) or '-'
related_task = getattr(record, 'task', None)
changed_at_value = '-'
if related_task and getattr(related_task, 'scheduled_date', None):
changed_at_value = related_task.scheduled_date.strftime('%d.%m.%Y')
unique[key] = {
'serial': serial,
'description': description,
'note': '-',
'changed_at': record.service_date.strftime('%d.%m.%Y') if record.service_date else '-',
'changed_at': changed_at_value,
}
return list(unique.values())
@@ -1791,6 +1815,27 @@ def _docx_cleanup_service_report_template(document):
body.remove(element)
def _docx_remove_empty_page_break_paragraphs(document):
body = document._body._element
paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'
break_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}br'
text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'
wordprocessing_namespace = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
for element in list(body):
if element.tag != paragraph_namespace:
continue
text = ''.join(node.text or '' for node in element.iter(text_namespace)).strip()
if text:
continue
has_page_break = any(
br.tag == break_namespace and br.get(f'{{{wordprocessing_namespace}}}type') == 'page'
for br in element.iter(break_namespace)
)
if has_page_break:
body.remove(element)
def _build_work_order_docx_bytes(work_order):
vehicle = work_order.vehicle
creator = work_order.creator
@@ -1903,15 +1948,15 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
for task in related_tasks
}
# Build notes: use per-task service_report_note; fall back to work_order.notes
# Build notes only from per-task service_report_note (edited via "Uredi tekst napomene")
task_note_parts = [
str(task.service_report_note or '').strip()
for task in related_tasks
if str(task.service_report_note or '').strip()
]
notes_text = "\n".join(task_note_parts) if task_note_parts else (str(work_order.notes or '').strip() or '-')
notes_text = "\n".join(task_note_parts) if task_note_parts else '-'
if len(doc.tables) >= 4:
if len(doc.tables) >= 3:
info_table = doc.tables[0]
_set_docx_cell_text(info_table, 1, 0, client_name)
_set_docx_cell_text(info_table, 1, 1, work_order.location or '-')
@@ -1941,20 +1986,8 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
target_row.cells[1].text = entry['description']
target_row.cells[2].text = entry['note']
target_row.cells[3].text = entry['changed_at']
details_table = doc.tables[3]
repair_lines = []
for task in related_tasks:
task_records = records_by_task.get(task.id, [])
if not task_records:
continue
repair_lines.append(f"Zadatak: {task.title or f'Servisni zadatak #{task.id}'}")
for record in task_records:
description = str(record.description or '-').strip() or '-'
repair_lines.append(f"- {description}")
repair_text = "\n".join(repair_lines) if repair_lines else '-'
_set_docx_cell_text(details_table, 0, 0, f"Kvar: {notes_text}")
_set_docx_cell_text(details_table, 1, 0, f"Popravak: {repair_text}")
if len(doc.tables) > 3:
_docx_remove_table(doc.tables[3])
else:
raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."})
@@ -1997,6 +2030,7 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
cells[index].text = '-'
_docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati')
_docx_cleanup_service_report_template(doc)
_docx_remove_empty_page_break_paragraphs(doc)
doc.add_page_break()
_docx_add_heading(doc, 'Servisni zapisi', level=2)
@@ -2006,9 +2040,10 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
if not task_records:
continue
has_records = True
task_date_label = task.scheduled_date.strftime('%d.%m.%Y') if task.scheduled_date else '-'
_docx_add_heading(doc, task.title or f"Servisni zadatak #{task.id}", level=3)
for record in task_records:
doc.add_paragraph(f"Datum: {record.service_date or '-'}")
doc.add_paragraph(f"Datum: {task_date_label}")
doc.add_paragraph(f"Opis: {record.description or '-'}")
doc.add_paragraph(f"Korišteni dijelovi: {record.parts or '-'}")
doc.add_paragraph(f"Trošak: {record.cost or '-'} EUR")
@@ -2635,6 +2670,185 @@ def monthly_costs_report_docx(request):
return resp
def _monthly_archive_prefix_for_user(user):
first_name = str(getattr(user, 'first_name', '') or '').strip()
last_name = str(getattr(user, 'last_name', '') or '').strip()
if first_name and last_name:
return f"{first_name[0].upper()}{last_name[0].upper()}"
return 'MT'
def _generated_archives_dir():
root = Path(settings.MEDIA_ROOT) / 'fleet' / 'generated_archives'
root.mkdir(parents=True, exist_ok=True)
return root
def _cleanup_expired_generated_archives():
root = _generated_archives_dir()
threshold = timezone.now() - timedelta(days=GENERATED_ARCHIVE_TTL_DAYS)
threshold_ts = threshold.timestamp()
for candidate in root.glob('*.zip'):
try:
if candidate.stat().st_mtime <= threshold_ts:
candidate.unlink(missing_ok=True)
except OSError:
continue
def _persist_generated_archive(filename, content):
root = _generated_archives_dir()
timestamp = timezone.now().strftime('%Y%m%d%H%M%S')
stored_name = f"{timestamp}-{Path(filename).name}"
target = root / stored_name
with target.open('wb') as handle:
handle.write(content)
return target
def _unique_zip_entry_name(entry_name, used_names):
candidate = entry_name
entry_path = Path(entry_name)
stem = entry_path.stem
suffix = entry_path.suffix
parent = str(entry_path.parent)
counter = 2
while candidate in used_names:
filename = f"{stem}-{counter}{suffix}"
candidate = f"{parent}/{filename}" if parent not in ('', '.') else filename
counter += 1
used_names.add(candidate)
return candidate
@api_view(['GET'])
@permission_classes([permissions.IsAuthenticated])
def monthly_service_tasks_archive(request):
from datetime import date as _dt_date
from modules.task_management.models import Task
try:
year = int(request.query_params.get('year', _dt_date.today().year))
month = int(request.query_params.get('month', _dt_date.today().month))
if not (1 <= month <= 12):
raise ValueError()
except (ValueError, TypeError):
return Response({'detail': 'Nevažeći year/month parametar.'}, status=400)
tasks = list(
Task.objects
.filter(
assigned_to=request.user,
is_active=True,
scheduled_date__year=year,
scheduled_date__month=month,
work_order__isnull=False,
work_order__is_active=True,
)
.select_related('work_order', 'vehicle', 'work_hours_table')
.order_by('scheduled_date', 'created_at')
)
if not tasks:
return Response({'detail': 'Nema servisnih taskova za odabrani mjesec.'}, status=404)
used_names = set()
archive_buffer = BytesIO()
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
for task in tasks:
work_order = task.work_order
if work_order is None:
continue
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=[task])
base_name = _service_records_docx_filename(work_order, task)
entry_name = _unique_zip_entry_name(base_name, used_names)
archive.writestr(entry_name, docx_bytes)
archive_content = archive_buffer.getvalue()
if not archive_content:
return Response({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}, status=400)
prefix = _monthly_archive_prefix_for_user(request.user)
archive_filename = f"{prefix}-{month:02d}-{year}-SN.zip"
_cleanup_expired_generated_archives()
_persist_generated_archive(archive_filename, archive_content)
response = HttpResponse(archive_content, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename="{archive_filename}"'
return response
@api_view(['GET'])
@permission_classes([permissions.IsAuthenticated])
def monthly_work_orders_archive(request):
from datetime import date as _dt_date
from modules.task_management.models import Task
try:
year = int(request.query_params.get('year', _dt_date.today().year))
month = int(request.query_params.get('month', _dt_date.today().month))
if not (1 <= month <= 12):
raise ValueError()
except (ValueError, TypeError):
return Response({'detail': 'Nevažeći year/month parametar.'}, status=400)
monthly_tasks = (
Task.objects
.filter(
assigned_to=request.user,
is_active=True,
scheduled_date__year=year,
scheduled_date__month=month,
work_order__isnull=False,
work_order__is_active=True,
)
.select_related('work_order')
.order_by('scheduled_date', 'created_at')
)
work_order_ids = [task.work_order_id for task in monthly_tasks if task.work_order_id]
if not work_order_ids:
return Response({'detail': 'Nema putnih naloga za odabrani mjesec.'}, status=404)
work_orders = list(
WorkOrder.objects
.filter(id__in=work_order_ids, is_active=True)
.select_related('vehicle', 'vehicle__client', 'creator')
.distinct()
)
if not work_orders:
return Response({'detail': 'Nema putnih naloga za odabrani mjesec.'}, status=404)
used_names = set()
archive_buffer = BytesIO()
with zipfile.ZipFile(archive_buffer, mode='w', compression=zipfile.ZIP_DEFLATED) as archive:
for work_order in work_orders:
pdf_bytes = _build_work_order_pdf(work_order)
work_order_pdf_name = _unique_zip_entry_name(_pdf_filename(work_order, 'work_order'), used_names)
archive.writestr(work_order_pdf_name, pdf_bytes)
display_code = _work_order_display_code(work_order)
invoices = work_order.invoices.filter(is_active=True).order_by('datum', 'created_at')
for index, invoice in enumerate(invoices, start=1):
attachment = _file_attachment(invoice.image, fallback_name=f"invoice-{index}.bin")
if not attachment:
continue
invoice_filename, content, _content_type = attachment
archive_path = f"Racuni/{display_code}/{invoice_filename}"
archive.writestr(_unique_zip_entry_name(archive_path, used_names), content)
archive_content = archive_buffer.getvalue()
if not archive_content:
return Response({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}, status=400)
prefix = _monthly_archive_prefix_for_user(request.user)
archive_filename = f"{prefix}-{month:02d}-{year}-PN.zip"
_cleanup_expired_generated_archives()
_persist_generated_archive(archive_filename, archive_content)
response = HttpResponse(archive_content, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename="{archive_filename}"'
return response
@api_view(['POST'])
@permission_classes([permissions.IsAuthenticated])
def pusher_auth(request):
@@ -3013,6 +3227,7 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
'title': task.title,
'status': task.status,
'description': task.description,
'scheduled_date': task.scheduled_date,
'service_report_note': task.service_report_note or '',
'assigned_to_name': _user_display_name(task.assigned_to),
'vehicle_registration': getattr(task.vehicle, 'registration_number', None),