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