feat: add per-task PDF/DOCX export for service records
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

Enable generating service-record documents for a single task on /putni-nalozi/racuni while preserving existing full work-order export.

- Backend service-records PDF/DOCX endpoints now accept optional task_id and validate the task belongs to the selected work order.
- Document builders support scoped task lists, and per-task downloads use task-specific filenames.
- Frontend task cards now include per-task PDF/DOCX actions, while top-level buttons are clarified as exporting all service records.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-08-03 00:44:15 +02:00
parent 7da1c84139
commit 9a7c7e3163
3 changed files with 104 additions and 21 deletions

View File

@@ -210,6 +210,32 @@ def _docx_filename(work_order, doc_type):
return f"{display_code}.work-order.docx"
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"
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"
def _resolve_service_report_tasks(work_order, task_id):
queryset = _work_order_related_tasks_queryset(work_order)
if not task_id:
return list(queryset), None
selected_task = queryset.filter(pk=task_id).first()
if not selected_task:
raise DRFValidationError({
'task_id': 'Odabrani task nije povezan s ovim putnim nalogom.'
})
return [selected_task], selected_task
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
"""
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
@@ -884,7 +910,7 @@ def _build_work_order_pdf(work_order):
return buffer.getvalue()
def _build_work_order_service_records_pdf(work_order):
def _build_work_order_service_records_pdf(work_order, related_tasks=None):
vehicle = work_order.vehicle
client_name = getattr(vehicle.client, 'name', None) or '-'
manufacturer_str = str(vehicle.make or '-')
@@ -900,7 +926,10 @@ def _build_work_order_service_records_pdf(work_order):
FOOTER_H = 72
MARGIN = 28
related_tasks = list(_work_order_related_tasks_queryset(work_order))
if related_tasks is None:
related_tasks = list(_work_order_related_tasks_queryset(work_order))
else:
related_tasks = list(related_tasks)
# Collect service records for all task vehicles (supports cross-crane work orders)
task_vehicle_ids = list({task.vehicle_id for task in related_tasks if task.vehicle_id})
service_rows = list(
@@ -1812,7 +1841,7 @@ def _build_work_order_docx_bytes(work_order):
return buffer.getvalue()
def _build_work_order_service_records_docx_bytes(work_order):
def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None):
from docx.shared import Cm
doc = _create_docx_document(SERVICE_REPORT_DOCX_TEMPLATE_NAME)
@@ -1821,7 +1850,10 @@ def _build_work_order_service_records_docx_bytes(work_order):
servicer_name = _user_display_name(work_order.creator) or '-'
completion_label = "Da ☒ Ne ☐" if work_order.status == 'closed' else "Da ☐ Ne ☒"
related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table'))
if related_tasks is None:
related_tasks = list(_work_order_related_tasks_queryset(work_order).select_related('work_hours_table'))
else:
related_tasks = list(related_tasks)
normalized_rows = []
for task in related_tasks:
table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None)
@@ -2792,27 +2824,38 @@ class WorkOrderViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=['get'], url_path='service-records-pdf')
def service_records_pdf(self, request, pk=None):
work_order = self.get_object()
_cleanup_expired_generated_pdfs()
cached = _get_cached_pdf(work_order, 'service_records')
if cached:
return _cached_pdf_file_response(cached, default_filename=_pdf_filename(work_order, 'service_records'))
task_id = request.query_params.get('task_id')
related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested')
pdf_bytes = _build_work_order_service_records_pdf(work_order)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed')
if not selected_task:
_cleanup_expired_generated_pdfs()
cached = _get_cached_pdf(work_order, 'service_records')
if cached:
return _cached_pdf_file_response(
cached,
default_filename=_service_records_pdf_filename(work_order),
)
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='requested')
pdf_bytes = _build_work_order_service_records_pdf(work_order, related_tasks=related_tasks)
if not selected_task:
_notify_pdf_request(user=request.user, work_order=work_order, doc_type='service_records', stage='completed')
response = HttpResponse(pdf_bytes, content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{_pdf_filename(work_order, "service_records")}"'
response['Content-Disposition'] = f'attachment; filename="{_service_records_pdf_filename(work_order, selected_task)}"'
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)
task_id = request.query_params.get('task_id')
related_tasks, selected_task = _resolve_service_report_tasks(work_order, task_id)
docx_bytes = _build_work_order_service_records_docx_bytes(work_order, related_tasks=related_tasks)
response = HttpResponse(
docx_bytes,
content_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
)
response['Content-Disposition'] = f'attachment; filename="{_docx_filename(work_order, "service_records")}"'
response['Content-Disposition'] = f'attachment; filename="{_service_records_docx_filename(work_order, selected_task)}"'
return response
@action(detail=True, methods=['post'], url_path='service-records-pdf-request')