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,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),