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>
This commit is contained in:
mariomitte
2026-07-23 13:27:13 +02:00
parent 908025abc6
commit 1055820741
3 changed files with 447 additions and 0 deletions

Binary file not shown.

View File

@@ -6,6 +6,7 @@ import base64
import csv import csv
import mimetypes import mimetypes
import threading import threading
import re
from collections import OrderedDict from collections import OrderedDict
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
@@ -83,6 +84,8 @@ from .tasks import (
register_unicode_fonts() register_unicode_fonts()
User = get_user_model() 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): def _fleet_assets_queryset_for_user(user, model, *, asset_type=None):
queryset = model.objects.select_related('client', 'assigned_servicer').filter(is_active=True) 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" 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): def _get_cached_pdf(work_order, pdf_type):
now = timezone.now() now = timezone.now()
expected_filename = _pdf_filename(work_order, pdf_type) 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."}) 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): def _guess_content_type(filename):
mime, _ = mimetypes.guess_type(str(filename or '')) mime, _ = mimetypes.guess_type(str(filename or ''))
return mime or 'application/octet-stream' 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")}"' response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "work_order")}"'
return response 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') @action(detail=True, methods=['post'], url_path='pdf-request')
def pdf_request(self, request, pk=None): def pdf_request(self, request, pk=None):
work_order = self.get_object() 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")}"' response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"'
return response 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') @action(detail=True, methods=['post'], url_path='service-records-pdf-request')
def service_records_pdf_request(self, request, pk=None): def service_records_pdf_request(self, request, pk=None):
work_order = self.get_object() work_order = self.get_object()
@@ -1879,6 +2312,17 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response 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') @action(detail=True, methods=['post'], url_path='invoices-pdf-request')
def invoices_pdf_request(self, request, pk=None): def invoices_pdf_request(self, request, pk=None):
work_order = self.get_object() work_order = self.get_object()

View File

@@ -140,6 +140,9 @@ export function useAuthenticatedMediaSources(mediaPaths = []) {
if (!resolved) { if (!resolved) {
return ''; return '';
} }
if (isProtectedMediaUrl(resolved)) {
return authenticatedSources[resolved] || '';
}
return authenticatedSources[resolved] || resolved; return authenticatedSources[resolved] || resolved;
}; };