From 5af66c31b67131f563078b87e9b567ebcb810de2 Mon Sep 17 00:00:00 2001 From: mariomitte Date: Mon, 7 Sep 2026 16:37:37 +0200 Subject: [PATCH] fix: add total rows to service-record hours tables Add a final 'Ukupno' row to the exported work-hours tables so PDF and DOCX service records include both work and travel totals. This keeps the generated reports aligned with the underlying task data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/test_work_order_images_endpoint.py | 61 +++++ backend/modules/fleet/views.py | 215 +++++++----------- 2 files changed, 148 insertions(+), 128 deletions(-) diff --git a/backend/modules/fleet/tests/test_work_order_images_endpoint.py b/backend/modules/fleet/tests/test_work_order_images_endpoint.py index 1c5ba37..f08692e 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -497,6 +497,67 @@ class WorkOrderImagesEndpointTests(TestCase): self.assertIn('MT170726', text) self.assertNotIn('MT150726', text) + def test_service_records_docx_includes_work_and_travel_total_row(self): + TaskWorkHoursTable.objects.create( + task=self.task, + data={ + 'rows': [{ + 'day': 'PON', + 'date': '01.12.2033', + 'work_time_from': '08:00', + 'work_time_to': '12:30', + 'travel_time_from': '07:00', + 'travel_time_to': '08:00', + 'break_hours': '0,5', + 'work_hours': '4,0', + 'travel_hours': '1,0', + 'departure_place': 'Zagreb', + 'arrival_place': 'Split', + 'vehicle_km': '120', + }] + }, + ) + + response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-docx/?task_id={self.task.pk}") + self.assertEqual(response.status_code, 200, response.content) + + archive = zipfile.ZipFile(BytesIO(response.content)) + document_xml = archive.read('word/document.xml').decode('utf-8', errors='ignore') + self.assertIn('Ukupno', document_xml) + self.assertIn('4,0', document_xml) + self.assertIn('1,0', document_xml) + + def test_service_records_pdf_includes_work_and_travel_total_row(self): + TaskWorkHoursTable.objects.create( + task=self.task, + data={ + 'rows': [{ + 'day': 'PON', + 'date': '01.12.2033', + 'work_time_from': '08:00', + 'work_time_to': '12:30', + 'travel_time_from': '07:00', + 'travel_time_to': '08:00', + 'break_hours': '0,5', + 'work_hours': '4,0', + 'travel_hours': '1,0', + 'departure_place': 'Zagreb', + 'arrival_place': 'Split', + 'vehicle_km': '120', + }] + }, + ) + + response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-pdf/?task_id={self.task.pk}") + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(response['Content-Type'], 'application/pdf') + + reader = PdfReader(BytesIO(response.content)) + text = "\n".join((page.extract_text() or '') for page in reader.pages) + self.assertIn('UKUPNO', text) + self.assertIn('4,0', text) + self.assertIn('1,0', text) + def test_monthly_service_tasks_archive_returns_zip_with_task_docx(self): response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12') self.assertEqual(response.status_code, 200, response.content) diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 1e10e91..7ed7272 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -5,7 +5,6 @@ from io import StringIO import base64 import csv import mimetypes -import tempfile import threading import zipfile import re @@ -384,8 +383,6 @@ def _parse_format(value): GENERATED_PDF_TTL_HOURS = 24 GENERATED_ARCHIVE_TTL_DAYS = 7 -TMP_ARCHIVE_RETENTION_HOURS = 24 -TMP_ARCHIVE_DIRNAME = 'erp-generated-archives' def _work_order_display_code(work_order): @@ -1337,6 +1334,7 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None): "Kilometri\nvozila", ] table3_rows = [table3_headers] + total_hours_work = 0.0 total_hours_travel = 0.0 total_vehicle_km = 0.0 @@ -1353,13 +1351,24 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None): row['places'], row['vehicle_km'], ]) + total_hours_work += _parse_decimal(row['work_hours']) total_hours_travel += _parse_decimal(row['travel_hours']) total_vehicle_km += _parse_decimal(row['vehicle_km']) else: for _ in range(12): table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"]) - table3_rows.append(["UKUPNO", "", "", "", "", "", _format_decimal(total_hours_travel), "", str(int(total_vehicle_km) if total_vehicle_km.is_integer() else total_vehicle_km).replace('.', ',')]) + table3_rows.append([ + "UKUPNO", + "", + "", + "", + "", + _format_decimal(total_hours_work), + _format_decimal(total_hours_travel), + "", + str(int(total_vehicle_km) if total_vehicle_km.is_integer() else total_vehicle_km).replace('.', ','), + ]) y = draw_table( y, table3_rows, @@ -1371,15 +1380,11 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None): ('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('ALIGN', (0, 0), (0, -1), 'CENTER'), - ('ALIGN', (1, 0), (6, -1), 'CENTER'), - ('SPAN', (0, -1), (5, -1)), - ('ALIGN', (0, -1), (5, -1), 'LEFT'), + ('ALIGN', (1, 0), (8, -1), 'CENTER'), ('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'), - ('FONTNAME', (6, -1), (6, -1), 'Vera-Bold'), - ('FONTNAME', (8, -1), (8, -1), 'Vera-Bold'), + ('FONTNAME', (5, -1), (8, -1), 'Vera-Bold'), ('LEFTPADDING', (0, 0), (-1, -1), 3), ('RIGHTPADDING', (0, 0), (-1, -1), 3), - ('LEFTPADDING', (0, -1), (5, -1), 12), ('VALIGN', (7, 1), (7, -2), 'TOP'), ('ALIGN', (7, 1), (7, -2), 'LEFT'), ]), @@ -2268,6 +2273,8 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None) for index, header in enumerate(headers): hours_table.rows[0].cells[index].text = header _docx_remove_rows_after(hours_table, keep_rows=1) + total_work_hours = sum(_parse_report_decimal_value(row['work_hours']) for row in normalized_rows) + total_travel_hours = sum(_parse_report_decimal_value(row['travel_hours']) for row in normalized_rows) if normalized_rows: for row in normalized_rows: cells = hours_table.add_row().cells @@ -2284,6 +2291,16 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None) cells = hours_table.add_row().cells for index in range(9): cells[index].text = '-' + cells = hours_table.add_row().cells + cells[0].text = 'Ukupno' + cells[1].text = '' + cells[2].text = '' + cells[3].text = '' + cells[4].text = '' + cells[5].text = _format_decimal_display(total_work_hours, default='0') + cells[6].text = _format_decimal_display(total_travel_hours, default='0') + cells[7].text = '' + cells[8].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) @@ -2935,40 +2952,27 @@ def _unique_zip_entry_name(entry_name, used_names): return candidate -def _tmp_archive_dir(): - return Path(tempfile.gettempdir()) / TMP_ARCHIVE_DIRNAME +def _parse_year_month_params(request): + from datetime import date as _dt_date + + raw_year = request.data.get('year') if isinstance(getattr(request, 'data', None), dict) else None + raw_month = request.data.get('month') if isinstance(getattr(request, 'data', None), dict) else None + if raw_year in (None, ''): + raw_year = request.query_params.get('year', _dt_date.today().year) + if raw_month in (None, ''): + raw_month = request.query_params.get('month', _dt_date.today().month) + + try: + year = int(raw_year) + month = int(raw_month) + if not (1 <= month <= 12): + raise ValueError() + except (ValueError, TypeError): + raise DRFValidationError({'detail': 'Nevažeći year/month parametar.'}) + return year, month -def _create_tmp_archive_path(*, prefix): - target_dir = _tmp_archive_dir() - target_dir.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile( - mode='wb', - suffix='.zip', - prefix=prefix, - dir=target_dir, - delete=False, - ) as handle: - return Path(handle.name) - - -def _cleanup_stale_tmp_archives(*, retention_hours=TMP_ARCHIVE_RETENTION_HOURS): - target_dir = _tmp_archive_dir() - if not target_dir.exists(): - return 0 - cutoff_ts = timezone.now().timestamp() - (retention_hours * 3600) - deleted = 0 - for item in target_dir.glob('*.zip'): - try: - if item.stat().st_mtime <= cutoff_ts: - item.unlink(missing_ok=True) - deleted += 1 - except OSError: - logger.exception("Greška pri čišćenju privremene ZIP datoteke %s", item) - return deleted - - -def _build_monthly_service_tasks_archive_to_temp_file(*, user, year, month): +def _build_monthly_service_tasks_archive_content(*, user, year, month): from modules.task_management.models import Task tasks = list( @@ -2988,26 +2992,24 @@ def _build_monthly_service_tasks_archive_to_temp_file(*, user, year, month): raise DRFValidationError({'detail': 'Nema servisnih taskova za odabrani mjesec.'}) used_names = set() - tmp_path = _create_tmp_archive_path(prefix='monthly-service-tasks-') - try: - with zipfile.ZipFile(str(tmp_path), mode='w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) 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) - if tmp_path.stat().st_size <= 0: - raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) - return tmp_path - except Exception: - tmp_path.unlink(missing_ok=True) - raise + 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: + raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) + return archive_content -def _build_monthly_work_orders_archive_to_temp_file(*, user, year, month): +def _build_monthly_work_orders_archive_content(*, user, year, month): from modules.task_management.models import Task monthly_tasks = ( @@ -3046,73 +3048,27 @@ def _build_monthly_work_orders_archive_to_temp_file(*, user, year, month): raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'}) used_names = set() - tmp_path = _create_tmp_archive_path(prefix='monthly-work-orders-') - try: - with zipfile.ZipFile(str(tmp_path), mode='w', compression=zipfile.ZIP_DEFLATED, allowZip64=True) 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) + 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): - if not invoice.image: - continue - invoice_filename = Path(str(getattr(invoice.image, 'name', '') or f"invoice-{index}.bin")).name - archive_path = _unique_zip_entry_name(f"Racuni/{display_code}/{invoice_filename}", used_names) - try: - invoice.image.open('rb') - with archive.open(archive_path, mode='w') as dest: - while True: - chunk = invoice.image.read(1024 * 1024) - if not chunk: - break - dest.write(chunk) - finally: - invoice.image.close() - if tmp_path.stat().st_size <= 0: - raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) - return tmp_path - except Exception: - tmp_path.unlink(missing_ok=True) - raise + 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) - -def _parse_year_month_params(request): - from datetime import date as _dt_date - - raw_year = request.data.get('year') if isinstance(getattr(request, 'data', None), dict) else None - raw_month = request.data.get('month') if isinstance(getattr(request, 'data', None), dict) else None - if raw_year in (None, ''): - raw_year = request.query_params.get('year', _dt_date.today().year) - if raw_month in (None, ''): - raw_month = request.query_params.get('month', _dt_date.today().month) - - try: - year = int(raw_year) - month = int(raw_month) - if not (1 <= month <= 12): - raise ValueError() - except (ValueError, TypeError): - raise DRFValidationError({'detail': 'Nevažeći year/month parametar.'}) - return year, month - - -def _build_monthly_service_tasks_archive_content(*, user, year, month): - tmp_path = _build_monthly_service_tasks_archive_to_temp_file(user=user, year=year, month=month) - try: - return tmp_path.read_bytes() - finally: - tmp_path.unlink(missing_ok=True) - - -def _build_monthly_work_orders_archive_content(*, user, year, month): - tmp_path = _build_monthly_work_orders_archive_to_temp_file(user=user, year=year, month=month) - try: - return tmp_path.read_bytes() - finally: - tmp_path.unlink(missing_ok=True) + archive_content = archive_buffer.getvalue() + if not archive_content: + raise DRFValidationError({'detail': 'Nije moguće kreirati ZIP za odabrani mjesec.'}) + return archive_content def _cleanup_expired_generated_archive_records(): @@ -3129,7 +3085,6 @@ def _cleanup_expired_generated_archive_records(): item.status = 'failed' item.error_message = 'ZIP arhiva je istekla.' item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) - _cleanup_stale_tmp_archives() def _notify_monthly_archive_request(*, user, archive_type, stage, year, month, generated_archive=None): @@ -3421,8 +3376,11 @@ def generated_archive_download(request, archive_id): raise DRFValidationError({'detail': 'ZIP arhiva nije dostupna ili je istekla.'}) generated_archive.file.open('rb') - if generated_archive.file.size <= 0: + try: + archive_bytes = generated_archive.file.read() + finally: generated_archive.file.close() + if not archive_bytes: raise DRFValidationError({'detail': 'ZIP arhiva je prazna ili nedostupna.'}) filename = generated_archive.filename or _generated_archive_filename_for_user( request.user, @@ -3430,9 +3388,10 @@ def generated_archive_download(request, archive_id): month=generated_archive.month, archive_type=generated_archive.archive_type, ) - response = FileResponse(generated_archive.file, content_type='application/zip') + response = HttpResponse(archive_bytes, content_type='application/zip') response['Content-Disposition'] = f'attachment; filename="{filename}"' response['Cache-Control'] = 'private, max-age=3600' + response['Content-Length'] = str(len(archive_bytes)) return response