From 10558207410fe71de507c35ff4060ef68e4e3a39 Mon Sep 17 00:00:00 2001 From: mariomitte Date: Thu, 23 Jul 2026 13:27:13 +0200 Subject: [PATCH] fix: uskladi servisni DOCX template i deduplikaciju dijelova - koristi novi Servisni N-R template za service-records DOCX\n- premjesta tablicu radnih sati odmah ispod naslova na prvoj stranici\n- deduplicira ponovljene unose koristenih dijelova u tablici artikala\n- uklanja fallback neautorizirani media URL za zasticene fleet resurse Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../fleet/docx_templates/Servisni N-R.docx | Bin 0 -> 33607 bytes backend/modules/fleet/views.py | 444 ++++++++++++++++++ .../dashboard/WorkOrderImageCarousel.jsx | 3 + 3 files changed, 447 insertions(+) create mode 100644 backend/modules/fleet/docx_templates/Servisni N-R.docx diff --git a/backend/modules/fleet/docx_templates/Servisni N-R.docx b/backend/modules/fleet/docx_templates/Servisni N-R.docx new file mode 100644 index 0000000000000000000000000000000000000000..134fffbbd095f313d2a0636f2008be1a33aeba9a GIT binary patch literal 33607 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 XBMt`aEPm?qqr4dhzU}FG{`CDHxu^g& literal 0 HcmV?d00001 diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index edb9413..4321d6a 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -6,6 +6,7 @@ import base64 import csv import mimetypes import threading +import re from collections import OrderedDict from datetime import timedelta from pathlib import Path @@ -83,6 +84,8 @@ from .tasks import ( register_unicode_fonts() User = get_user_model() +DOCX_TEMPLATE_DIR = Path(__file__).resolve().parent / 'docx_templates' +SERVICE_REPORT_DOCX_TEMPLATE_NAME = 'Servisni N-R.docx' def _fleet_assets_queryset_for_user(user, model, *, asset_type=None): queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True) @@ -196,6 +199,15 @@ def _pdf_filename(work_order, pdf_type): return f"{display_code}.work-order.pdf" +def _docx_filename(work_order, doc_type): + display_code = _work_order_display_code(work_order) + if doc_type == 'invoices': + return f"{display_code}.work-order-invoices.docx" + if doc_type == 'service_records': + return f"{display_code}.work-order-service-records.docx" + return f"{display_code}.work-order.docx" + + def _get_cached_pdf(work_order, pdf_type): now = timezone.now() expected_filename = _pdf_filename(work_order, pdf_type) @@ -1532,6 +1544,405 @@ def _build_work_order_invoices_pdf_bytes(work_order): raise DRFValidationError({"detail": "Neispravan PDF sadržaj računa."}) +def _create_docx_document(template_name=None): + try: + from docx import Document + except ModuleNotFoundError: + raise DRFValidationError({"detail": "DOCX generiranje nije dostupno: nedostaje python-docx paket."}) + if not template_name: + return Document() + template_path = DOCX_TEMPLATE_DIR / template_name + if not template_path.exists(): + raise DRFValidationError({"detail": f"DOCX template nije pronađen: {template_name}."}) + return Document(str(template_path)) + + +def _set_docx_cell_text(table, row_index, col_index, value): + row_count = len(getattr(table, 'rows', [])) + if row_count <= row_index: + raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu tablice."}) + col_count = len(getattr(table.rows[row_index], 'cells', [])) + if col_count <= col_index: + raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu tablice."}) + table.rows[row_index].cells[col_index].text = str(value or '-') + + +def _docx_add_heading(document, text, level=1): + style_name = f'Heading {int(level)}' + try: + document.styles[style_name] + return document.add_paragraph(str(text or ''), style=style_name) + except KeyError: + paragraph = document.add_paragraph() + run = paragraph.add_run(str(text or '')) + run.bold = True + return paragraph + + +def _normalize_whitespace(value): + return " ".join(str(value or '').split()).strip() + + +def _docx_remove_rows_after(table, keep_rows=1): + rows = list(getattr(table, 'rows', [])) + for row in rows[keep_rows:]: + table._tbl.remove(row._tr) + + +def _docx_move_table_after_paragraph_text(document, table, marker_text): + if not marker_text: + return False + marker_norm = _normalize_whitespace(marker_text).lower() + if not marker_norm: + return False + body = document._body._element + children = list(body) + table_element = table._tbl + paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p' + text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t' + for index, element in enumerate(children): + if element.tag != paragraph_namespace: + continue + paragraph_text = ''.join(node.text or '' for node in element.iter(text_namespace)) + if marker_norm not in _normalize_whitespace(paragraph_text).lower(): + continue + if table_element in children: + body.remove(table_element) + children = list(body) + index = children.index(element) + body.insert(index + 1, table_element) + return True + return False + + +def _extract_unique_parts_entries(records): + unique = OrderedDict() + for record in records: + raw_parts = _normalize_whitespace(getattr(record, 'parts', None)) + if not raw_parts or raw_parts == '-': + continue + key = raw_parts.lower() + if key in unique: + continue + serial = '-' + description = raw_parts + match = re.match(r'^\[([^\]]+)\]\s*(.*)$', raw_parts) + if match: + serial = _normalize_whitespace(match.group(1)) or '-' + description = _normalize_whitespace(match.group(2)) or '-' + unique[key] = { + 'serial': serial, + 'description': description, + 'note': '-', + 'changed_at': record.service_date.strftime('%d.%m.%Y') if record.service_date else '-', + } + return list(unique.values()) + + +def _docx_cleanup_service_report_template(document): + body = document._body._element + children = list(body) + paragraph_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p' + text_namespace = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t' + seen_hours_heading = False + seen_note = False + remove_tail = False + + for element in children: + tag = element.tag + if tag == paragraph_namespace: + text = ''.join(node.text or '' for node in element.iter(text_namespace)) + normalized = _normalize_whitespace(text) + normalized_lower = normalized.lower() + + if 'servisni zapisi' in normalized_lower: + remove_tail = True + if remove_tail: + body.remove(element) + continue + + if normalized_lower == 'tablica radnih sati': + if seen_hours_heading: + body.remove(element) + continue + seen_hours_heading = True + + if normalized_lower.startswith('napomene:'): + if seen_note: + body.remove(element) + continue + seen_note = True + elif remove_tail and not tag.endswith('sectPr'): + body.remove(element) + + +def _build_work_order_docx_bytes(work_order): + vehicle = work_order.vehicle + creator = work_order.creator + doc = _create_docx_document() + _docx_add_heading(doc, f'Putni nalog {_work_order_display_code(work_order)}', level=1) + + rows = [ + ('Datum naloga', str(work_order.date or '-')), + ('Serviser', _user_display_name(creator) or getattr(creator, 'email', '-') or '-'), + ('Klijent', getattr(getattr(vehicle, 'client', None), 'name', None) or '-'), + ('Dizalica', " ".join(part for part in [vehicle.registration_number, vehicle.make, vehicle.model] if part) or '-'), + ('Lokacija', work_order.location or '-'), + ('Ishodište', work_order.origin_location or '-'), + ('Svrha', work_order.purpose or '-'), + ('Status', work_order.status or '-'), + ('Napomene', work_order.notes or '-'), + ] + table = doc.add_table(rows=1, cols=2) + table.rows[0].cells[0].text = 'Polje' + table.rows[0].cells[1].text = 'Vrijednost' + for label, value in rows: + row_cells = table.add_row().cells + row_cells[0].text = str(label) + row_cells[1].text = str(value) + + additional_costs_table = getattr(work_order, 'additional_costs_table', None) + if additional_costs_table and isinstance(additional_costs_table.data, dict): + additional_rows = additional_costs_table.data.get('rows', []) + if isinstance(additional_rows, list) and additional_rows: + doc.add_paragraph() + _docx_add_heading(doc, 'Dodatni troškovi', level=2) + costs_table = doc.add_table(rows=1, cols=4) + costs_table.rows[0].cells[0].text = 'Naziv' + costs_table.rows[0].cells[1].text = 'Broj računa' + costs_table.rows[0].cells[2].text = 'Ukupan iznos' + costs_table.rows[0].cells[3].text = 'Prilog' + for entry in additional_rows: + if not isinstance(entry, dict): + continue + row_cells = costs_table.add_row().cells + row_cells[0].text = str(entry.get('naziv') or '-') + row_cells[1].text = str(entry.get('broj_racuna') or '-') + row_cells[2].text = str(entry.get('ukupan_iznos') or '-') + row_cells[3].text = str(entry.get('prilog') or '-') + + buffer = BytesIO() + doc.save(buffer) + return buffer.getvalue() + + +def _build_work_order_service_records_docx_bytes(work_order): + doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME) + vehicle = work_order.vehicle + client_name = getattr(getattr(vehicle, 'client', None), 'name', None) or '-' + servicer_name = _user_display_name(work_order.creator) or '-' + completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒" + notes_text = str(work_order.notes or '').strip() or '-' + + related_tasks = list(_work_order_related_tasks_queryset(work_order)) + normalized_rows = [] + for task in related_tasks: + table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None) + if not isinstance(table_data, dict): + continue + rows = table_data.get('rows', []) + if not isinstance(rows, list): + continue + for row in rows: + if not isinstance(row, dict): + continue + normalized_rows.append({ + 'day': str(row.get('day', '') or '').strip() or '-', + 'date': str(row.get('date', '') or '').strip() or '-', + 'work_time': " - ".join([ + str(row.get('work_time_from', '') or '').strip() or '-', + str(row.get('work_time_to', '') or '').strip() or '-', + ]), + 'travel_time': " - ".join([ + str(row.get('travel_time_from', '') or '').strip() or '-', + str(row.get('travel_time_to', '') or '').strip() or '-', + ]), + 'break_hours': str(row.get('break_hours', '') or '').strip() or '-', + 'work_hours': str(row.get('work_hours', '') or '').strip() or '-', + 'travel_hours': str(row.get('travel_hours', '') or '').strip() or '-', + 'places': "\n".join([ + f"Polazak: {str(row.get('departure_place', '') or '').strip() or '-'}", + f"Dolazak: {str(row.get('arrival_place', '') or '').strip() or '-'}", + ]), + 'vehicle_km': str(row.get('vehicle_km', '') or '').strip() or '-', + }) + + service_rows = list( + VehicleServiceRecord.objects.filter( + is_active=True, + vehicle_id=work_order.vehicle_id, + ) + .select_related('performed_by', 'task') + .order_by('service_date', 'created_at') + ) + records_by_task = { + task.id: [row for row in service_rows if getattr(row, 'task_id', None) == task.id] + for task in related_tasks + } + + if len(doc.tables) >= 4: + 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 '-') + _set_docx_cell_text(info_table, 3, 0, servicer_name) + if len(info_table.rows[3].cells) > 1: + _set_docx_cell_text(info_table, 3, 1, '-') + + transport_table = doc.tables[1] + _set_docx_cell_text(transport_table, 1, 0, work_order.servicer_vehicle_make_model or '-') + _set_docx_cell_text(transport_table, 1, 1, work_order.servicer_vehicle_registration or '-') + _set_docx_cell_text(transport_table, 1, 2, '-') + _set_docx_cell_text(transport_table, 1, 4, completion_label) + + all_task_records = [record for task in related_tasks for record in records_by_task.get(task.id, [])] + summary_table = doc.tables[2] + _docx_remove_rows_after(summary_table, keep_rows=1) + unique_parts_entries = _extract_unique_parts_entries(all_task_records) + summary_rows = unique_parts_entries or [{ + 'serial': '-', + 'description': '-', + 'note': '-', + 'changed_at': '-', + }] + for index, entry in enumerate(summary_rows): + target_row = summary_table.rows[1] if index == 0 and len(summary_table.rows) > 1 else summary_table.add_row() + target_row.cells[0].text = entry['serial'] + 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] + issue_text = str(work_order.notes or '-').strip() or '-' + 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: {issue_text}") + _set_docx_cell_text(details_table, 1, 0, f"Popravak: {repair_text}") + else: + raise DRFValidationError({"detail": "DOCX template ima neočekivanu strukturu (nedostaju tablice)."}) + + for paragraph in doc.paragraphs: + paragraph_text = str(getattr(paragraph, 'text', '') or '').strip() + if paragraph_text.lower().startswith('napomene:'): + paragraph.text = f"Napomene: {notes_text}" + break + + hours_table = None + for table in doc.tables: + if not table.rows: + continue + headers = [_normalize_whitespace(cell.text).lower() for cell in table.rows[0].cells] + if len(headers) >= 9 and headers[0] == 'dan' and headers[1] == 'datum': + hours_table = table + break + if hours_table is None: + _docx_add_heading(doc, 'Tablica radnih sati', level=2) + hours_table = doc.add_table(rows=1, cols=9) + headers = ['Dan', 'Datum', 'Vrijeme rada', 'Vrijeme putovanja', 'Pauza h', 'Sati rada', 'Sati puta', 'Polazak / dolazak', 'Km vozila'] + for index, header in enumerate(headers): + hours_table.rows[0].cells[index].text = header + _docx_remove_rows_after(hours_table, keep_rows=1) + if normalized_rows: + for row in normalized_rows: + cells = hours_table.add_row().cells + cells[0].text = row['day'] + cells[1].text = row['date'] + cells[2].text = row['work_time'] + cells[3].text = row['travel_time'] + cells[4].text = row['break_hours'] + cells[5].text = row['work_hours'] + cells[6].text = row['travel_hours'] + cells[7].text = row['places'] + cells[8].text = row['vehicle_km'] + else: + cells = hours_table.add_row().cells + for index in range(9): + cells[index].text = '-' + _docx_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati') + _docx_cleanup_service_report_template(doc) + doc.add_page_break() + _docx_add_heading(doc, 'Servisni zapisi', level=2) + + has_records = False + for task in related_tasks: + task_records = records_by_task.get(task.id, []) + if not task_records: + continue + has_records = True + _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"Opis: {record.description or '-'}") + doc.add_paragraph(f"Korišteni dijelovi: {record.parts or '-'}") + doc.add_paragraph(f"Trošak: {record.cost or '-'} EUR") + doc.add_paragraph(f"Kilometraža: {record.mileage if record.mileage is not None else '-'}") + doc.add_paragraph('') + if not has_records: + doc.add_paragraph('Nema povezanih servisnih zapisa za ovaj putni nalog.') + + buffer = BytesIO() + doc.save(buffer) + return buffer.getvalue() + + +def _build_work_order_invoices_docx_bytes(work_order): + doc = _create_docx_document() + _docx_add_heading(doc, f'Računi putnog naloga {_work_order_display_code(work_order)}', level=1) + doc.add_paragraph(f'Datum generiranja: {timezone.localtime(timezone.now()).strftime("%d.%m.%Y %H:%M")}') + doc.add_paragraph(f'Klijent: {getattr(getattr(work_order.vehicle, "client", None), "name", None) or "-"}') + doc.add_paragraph(f'Dizalica: {work_order.vehicle.registration_number or "-"}') + + invoices = list(work_order.invoices.filter(is_active=True).order_by('-datum', '-created_at')) + table = doc.add_table(rows=1, cols=5) + table.rows[0].cells[0].text = 'Naziv računa' + table.rows[0].cells[1].text = 'Lokacija' + table.rows[0].cells[2].text = 'Datum' + table.rows[0].cells[3].text = 'Opis' + table.rows[0].cells[4].text = 'Prilog' + + if invoices: + for invoice in invoices: + cells = table.add_row().cells + cells[0].text = str(invoice.naziv_racuna or '-') + cells[1].text = str(invoice.lokacija or '-') + cells[2].text = invoice.datum.strftime('%d.%m.%Y') if invoice.datum else '-' + cells[3].text = str(invoice.opis or '-') + cells[4].text = Path(invoice.image.name).name if invoice.image and getattr(invoice.image, 'name', '') else '-' + else: + cells = table.add_row().cells + for index in range(5): + cells[index].text = '-' + + additional_costs_table = getattr(work_order, 'additional_costs_table', None) + if additional_costs_table and isinstance(additional_costs_table.data, dict): + rows = additional_costs_table.data.get('rows', []) + if isinstance(rows, list) and rows: + doc.add_paragraph() + _docx_add_heading(doc, 'Dodatni troškovi', level=2) + costs_table = doc.add_table(rows=1, cols=3) + costs_table.rows[0].cells[0].text = 'Naziv' + costs_table.rows[0].cells[1].text = 'Broj računa' + costs_table.rows[0].cells[2].text = 'Ukupan iznos' + for row in rows: + if not isinstance(row, dict): + continue + cells = costs_table.add_row().cells + cells[0].text = str(row.get('naziv') or '-') + cells[1].text = str(row.get('broj_racuna') or '-') + cells[2].text = str(row.get('ukupan_iznos') or '-') + + buffer = BytesIO() + doc.save(buffer) + return buffer.getvalue() + + def _guess_content_type(filename): mime, _ = mimetypes.guess_type(str(filename or '')) return mime or 'application/octet-stream' @@ -1836,6 +2247,17 @@ class WorkOrderViewSet(viewsets.ModelViewSet): response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "work_order")}"' return response + @action(detail=True, methods=['get'], url_path='docx') + def docx(self, request, pk=None): + work_order = self.get_object() + docx_bytes = _build_work_order_docx_bytes(work_order) + response = HttpResponse( + docx_bytes, + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) + response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "work_order")}"' + return response + @action(detail=True, methods=['post'], url_path='pdf-request') def pdf_request(self, request, pk=None): work_order = self.get_object() @@ -1857,6 +2279,17 @@ class WorkOrderViewSet(viewsets.ModelViewSet): response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"' return response + @action(detail=True, methods=['get'], url_path='service-records-docx') + def service_records_docx(self, request, pk=None): + work_order = self.get_object() + docx_bytes = _build_work_order_service_records_docx_bytes(work_order) + response = HttpResponse( + docx_bytes, + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) + response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "service_records")}"' + return response + @action(detail=True, methods=['post'], url_path='service-records-pdf-request') def service_records_pdf_request(self, request, pk=None): work_order = self.get_object() @@ -1879,6 +2312,17 @@ class WorkOrderViewSet(viewsets.ModelViewSet): response['Content-Disposition'] = f'attachment; filename="{filename}"' return response + @action(detail=True, methods=['get'], url_path='invoices-docx') + def invoices_docx(self, request, pk=None): + work_order = self.get_object() + docx_bytes = _build_work_order_invoices_docx_bytes(work_order) + response = HttpResponse( + docx_bytes, + content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ) + response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "invoices")}"' + return response + @action(detail=True, methods=['post'], url_path='invoices-pdf-request') def invoices_pdf_request(self, request, pk=None): work_order = self.get_object() diff --git a/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx b/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx index 269d3e3..32f8bc2 100644 --- a/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx +++ b/frontend/src/components/dashboard/WorkOrderImageCarousel.jsx @@ -140,6 +140,9 @@ export function useAuthenticatedMediaSources(mediaPaths = []) { if (!resolved) { return ''; } + if (isProtectedMediaUrl(resolved)) { + return authenticatedSources[resolved] || ''; + } return authenticatedSources[resolved] || resolved; };