diff --git a/backend/modules/fleet/tasks.py b/backend/modules/fleet/tasks.py index 9d017da..5ee21d8 100644 --- a/backend/modules/fleet/tasks.py +++ b/backend/modules/fleet/tasks.py @@ -836,23 +836,72 @@ def process_work_order_invoice_ocr(invoice_id): @shared_task -def cleanup_expired_generated_pdfs_task(): - now = timezone.now() - expired = GeneratedWorkOrderPdf.objects.filter( - is_active=True, - expires_at__isnull=False, - expires_at__lte=now, +def build_monthly_archive_cached_task(generated_archive_id): + from .models import GeneratedFleetArchive + from .services import NotificationService + from .views import ( + _build_monthly_service_tasks_archive_content, + _build_monthly_work_orders_archive_content, + _notify_monthly_archive_request, ) - deleted = 0 - for item in expired: - if item.file: - item.file.delete(save=False) - item.is_active = False - item.status = 'failed' - item.error_message = 'PDF cache istekao.' - item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) - deleted += 1 - return {"deleted": deleted} + + generated = ( + GeneratedFleetArchive.objects + .select_related('requested_by') + .filter(pk=generated_archive_id, is_active=True) + .first() + ) + if generated is None: + return {"status": "failed", "error": "Generated archive record not found"} + + try: + if generated.archive_type == 'service_tasks': + archive_content = _build_monthly_service_tasks_archive_content( + user=generated.requested_by, + year=generated.year, + month=generated.month, + ) + else: + archive_content = _build_monthly_work_orders_archive_content( + user=generated.requested_by, + year=generated.year, + month=generated.month, + ) + + if not archive_content: + raise ValueError('ZIP arhiva je prazna.') + + filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip" + generated.file.save(filename, ContentFile(archive_content), save=False) + generated.status = 'ready' + generated.error_message = '' + generated.save(update_fields=['file', 'status', 'error_message', 'updated_at']) + + if generated.requested_by: + _notify_monthly_archive_request( + user=generated.requested_by, + archive_type=generated.archive_type, + stage='completed', + year=generated.year, + month=generated.month, + generated_archive=generated, + ) + return {"status": "ready", "generated_archive_id": str(generated.pk)} + except Exception as exc: + generated.status = 'failed' + generated.error_message = str(exc) + generated.save(update_fields=['status', 'error_message', 'updated_at']) + if generated.requested_by: + _notify_monthly_archive_request( + user=generated.requested_by, + archive_type=generated.archive_type, + stage='failed', + year=generated.year, + month=generated.month, + generated_archive=generated, + ) + logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc) + return {"status": "failed", "error": str(exc)} @shared_task @@ -875,6 +924,26 @@ def cleanup_expired_generated_archives_task(): return {"deleted": deleted} +@shared_task +def cleanup_expired_generated_pdfs_task(): + now = timezone.now() + expired = GeneratedWorkOrderPdf.objects.filter( + is_active=True, + expires_at__isnull=False, + expires_at__lte=now, + ) + deleted = 0 + for item in expired: + if item.file: + item.file.delete(save=False) + item.is_active = False + item.status = 'failed' + item.error_message = 'PDF cache istekao.' + item.save(update_fields=['is_active', 'status', 'error_message', 'updated_at']) + deleted += 1 + return {"deleted": deleted} + + @shared_task def build_work_order_pdf_cached_task(generated_pdf_id): from .views import _build_work_order_pdf, _build_work_order_service_records_pdf @@ -954,98 +1023,4 @@ def build_work_order_pdf_cached_task(generated_pdf_id): }, ) logger.exception("Greška kod build_work_order_pdf_cached_task: %s", exc) - return {"status": "failed", "error": str(exc)} - - -@shared_task -def build_monthly_archive_cached_task(generated_archive_id): - from .services import NotificationService - from .views import ( - _build_monthly_service_tasks_archive_content, - _build_monthly_work_orders_archive_content, - _generated_archive_filename_for_user, - ) - - generated = ( - GeneratedFleetArchive.objects - .select_related('requested_by') - .filter(pk=generated_archive_id, is_active=True) - .first() - ) - if generated is None: - return {'error': 'Generated ZIP zapis nije pronađen.'} - - requested_by = generated.requested_by - if requested_by is None: - generated.status = 'failed' - generated.error_message = 'Korisnik koji je zatražio ZIP arhivu nije dostupan.' - generated.save(update_fields=['status', 'error_message', 'updated_at']) - return {'status': 'failed', 'error': generated.error_message} - - try: - if generated.archive_type == 'work_orders': - archive_bytes = _build_monthly_work_orders_archive_content( - user=requested_by, - year=generated.year, - month=generated.month, - ) - else: - archive_bytes = _build_monthly_service_tasks_archive_content( - user=requested_by, - year=generated.year, - month=generated.month, - ) - - filename = generated.filename or _generated_archive_filename_for_user( - requested_by, - year=generated.year, - month=generated.month, - archive_type=generated.archive_type, - ) - generated.file.save(filename, ContentFile(archive_bytes), save=False) - generated.status = 'ready' - generated.error_message = '' - generated.save(update_fields=['file', 'status', 'error_message', 'updated_at']) - - NotificationService.create_notification( - recipient=requested_by, - title='ZIP arhiva spremna', - message=f"ZIP arhiva je spremna za preuzimanje ({generated.month:02d}.{generated.year}.).", - level='success', - send_email=False, - metadata={ - 'entity_type': 'fleet_archive', - 'archive_type': generated.archive_type, - 'stage': 'completed', - 'year': generated.year, - 'month': generated.month, - 'generated_archive_id': str(generated.pk), - 'download_url': f"fleet/reports/generated-archives/{generated.pk}/download/", - 'filename': generated.filename or filename, - 'expires_at': generated.expires_at.isoformat() if generated.expires_at else None, - 'section': 'service-records', - }, - ) - return {'status': 'ready', 'generated_archive_id': str(generated.pk)} - except Exception as exc: - generated.status = 'failed' - generated.error_message = str(exc) - generated.save(update_fields=['status', 'error_message', 'updated_at']) - NotificationService.create_notification( - recipient=requested_by, - title='Greška kod ZIP arhive', - message=f"Generiranje ZIP arhive nije uspjelo ({generated.month:02d}.{generated.year}.).", - level='warning', - send_email=False, - metadata={ - 'entity_type': 'fleet_archive', - 'archive_type': generated.archive_type, - 'stage': 'failed', - 'year': generated.year, - 'month': generated.month, - 'generated_archive_id': str(generated.pk), - 'section': 'service-records', - }, - ) - logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc) - return {'status': 'failed', 'error': str(exc)} \ No newline at end of file + return {"status": "failed", "error": str(exc)} \ No newline at end of file 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 472224a..ef8ea87 100644 --- a/backend/modules/fleet/tests/test_work_order_images_endpoint.py +++ b/backend/modules/fleet/tests/test_work_order_images_endpoint.py @@ -60,7 +60,12 @@ class WorkOrderImagesEndpointTests(TestCase): self.vehicle = Vehicle.objects.create( asset_type='crane', registration_number='WO-IMG-001', + make='Liebherr', + model='LTM 1090', crane_serial_number='123456', + superstructure_working_hours=111, + chassis_working_hours=222, + current_mileage=33344, ) self.work_order = WorkOrder.objects.create( vehicle=self.vehicle, @@ -301,6 +306,23 @@ class WorkOrderImagesEndpointTests(TestCase): self.assertIn('24.12.2033', document_xml) self.assertEqual(document_xml.count('w:type="page"'), 1) + def test_task_specific_service_records_docx_uses_dynamic_docx_header_values(self): + 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)) + header_xml = archive.read('word/header1.xml').decode('utf-8') + self.assertIn('Liebherr LTM 1090', header_xml) + self.assertIn('123456', header_xml) + self.assertIn('111', header_xml) + self.assertIn('222', header_xml) + self.assertIn('33344', header_xml) + self.assertIn('MT150726', header_xml) + self.assertNotIn('MT250226', header_xml) + self.assertNotIn('LTM-1350', header_xml) + 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) @@ -310,6 +332,26 @@ class WorkOrderImagesEndpointTests(TestCase): names = archive.namelist() self.assertIn('MT150726.SN-Test_servisni_zadatak.docx', names) + def test_monthly_service_tasks_archive_docx_uses_dynamic_header_values(self): + response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12') + self.assertEqual(response.status_code, 200, response.content) + + archive = zipfile.ZipFile(BytesIO(response.content)) + docx_name = 'MT150726.SN-Test_servisni_zadatak.docx' + self.assertIn(docx_name, archive.namelist()) + + docx_bytes = archive.read(docx_name) + docx_archive = zipfile.ZipFile(BytesIO(docx_bytes)) + header_xml = docx_archive.read('word/header1.xml').decode('utf-8', errors='ignore') + self.assertIn('Liebherr LTM 1090', header_xml) + self.assertIn('123456', header_xml) + self.assertIn('111', header_xml) + self.assertIn('222', header_xml) + self.assertIn('33344', header_xml) + self.assertIn('MT150726', header_xml) + self.assertNotIn('MT250226', header_xml) + self.assertNotIn('LTM-1350', header_xml) + def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self): WorkOrderInvoice.objects.create( work_order=self.work_order, diff --git a/backend/modules/fleet/urls.py b/backend/modules/fleet/urls.py index a6739ee..43b18f6 100644 --- a/backend/modules/fleet/urls.py +++ b/backend/modules/fleet/urls.py @@ -3,8 +3,10 @@ from django.urls import path from .views import ( CraneViewSet, VehicleViewSet, WorkOrderViewSet, VehicleServiceRecordViewSet, WorkOrderInvoiceViewSet, ServiceContextNoteViewSet, VehicleNotificationViewSet, VehicleServicePhotoViewSet, VehicleServiceAttachmentViewSet, pusher_auth, - monthly_servicer_report_docx, monthly_costs_report_docx, monthly_service_tasks_archive, monthly_work_orders_archive, monthly_service_tasks_archive_request, - monthly_work_orders_archive_request, generated_archive_download, MonthlyServicerDayEntryViewSet, + monthly_servicer_report_docx, monthly_costs_report_docx, MonthlyServicerDayEntryViewSet, + monthly_service_tasks_archive, monthly_service_tasks_archive_request, + monthly_work_orders_archive, monthly_work_orders_archive_request, + generated_archive_download, ) router = DefaultRouter() @@ -26,8 +28,8 @@ urlpatterns += [ path('reports/monthly-servicer/', monthly_servicer_report_docx, name='monthly-servicer-report'), path('reports/monthly-costs/', monthly_costs_report_docx, name='monthly-costs-report'), path('reports/monthly-service-tasks-archive/', monthly_service_tasks_archive, name='monthly-service-tasks-archive'), - path('reports/monthly-work-orders-archive/', monthly_work_orders_archive, name='monthly-work-orders-archive'), path('reports/monthly-service-tasks-archive-request/', monthly_service_tasks_archive_request, name='monthly-service-tasks-archive-request'), + path('reports/monthly-work-orders-archive/', monthly_work_orders_archive, name='monthly-work-orders-archive'), path('reports/monthly-work-orders-archive-request/', monthly_work_orders_archive_request, name='monthly-work-orders-archive-request'), - path('reports/generated-archives//download/', generated_archive_download, name='generated-archive-download'), + path('reports/generated-archives//download/', generated_archive_download, name='generated-archive-download'), ] \ No newline at end of file diff --git a/backend/modules/fleet/views.py b/backend/modules/fleet/views.py index 043d593..9d2c693 100644 --- a/backend/modules/fleet/views.py +++ b/backend/modules/fleet/views.py @@ -1839,6 +1839,59 @@ def _docx_remove_empty_page_break_paragraphs(document): body.remove(element) +def _build_service_report_docx_context(work_order, related_tasks): + primary_task = related_tasks[0] if len(related_tasks) == 1 else None + context_work_order = getattr(primary_task, 'work_order', None) if primary_task else None + if context_work_order is None: + context_work_order = work_order + + context_vehicle = getattr(primary_task, 'vehicle', None) if primary_task else None + if context_vehicle is None: + context_vehicle = getattr(context_work_order, 'vehicle', None) or work_order.vehicle + + model_parts = [ + str(getattr(context_vehicle, 'make', '') or '').strip(), + str(getattr(context_vehicle, 'model', '') or '').strip(), + ] + model_label = " ".join(part for part in model_parts if part) or '-' + return { + 'work_order': context_work_order, + 'vehicle': context_vehicle, + 'client_name': getattr(getattr(context_vehicle, 'client', None), 'name', None) or '-', + 'location': getattr(context_work_order, 'location', None) or '-', + 'servicer_name': _user_display_name(getattr(context_work_order, 'creator', None)) or '-', + 'servicer_vehicle_make_model': getattr(context_work_order, 'servicer_vehicle_make_model', None) or '-', + 'servicer_vehicle_registration': getattr(context_work_order, 'servicer_vehicle_registration', None) or '-', + 'completion_label': "Da ☒ Ne ☐" if getattr(context_work_order, 'status', None) == 'closed' else "Da ☐ Ne ☒", + 'model': model_label, + 'serial': str(getattr(context_vehicle, 'crane_serial_number', None) or '-'), + 'upgrade_hours': str(getattr(context_vehicle, 'superstructure_working_hours', None) or '-'), + 'chassis_hours': str(getattr(context_vehicle, 'chassis_working_hours', None) or '-'), + 'mileage': str(getattr(context_vehicle, 'current_mileage', None) or '-'), + 'work_order_display_code': _work_order_display_code(context_work_order), + } + + +def _populate_service_report_docx_header(document, context): + for section in getattr(document, 'sections', []): + header = getattr(section, 'header', None) + if header is None: + continue + header_tables = list(getattr(header, 'tables', [])) + if len(header_tables) >= 2: + table_meta = header_tables[0] + _set_docx_cell_text(table_meta, 1, 0, context['model']) + _set_docx_cell_text(table_meta, 1, 2, context['serial']) + _set_docx_cell_text(table_meta, 1, 4, context['upgrade_hours']) + _set_docx_cell_text(table_meta, 1, 5, context['chassis_hours']) + _set_docx_cell_text(table_meta, 1, 6, context['mileage']) + _set_docx_cell_text(table_meta, 1, 8, context['work_order_display_code']) + + table_title = header_tables[1] + _set_docx_cell_text(table_title, 0, 0, context['client_name']) + _set_docx_cell_text(table_title, 0, 1, 'Izvještaj servisera') + + def _build_work_order_docx_bytes(work_order): vehicle = work_order.vehicle creator = work_order.creator @@ -1893,15 +1946,13 @@ 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) - 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 ☒" 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) + context = _build_service_report_docx_context(work_order, related_tasks) + _populate_service_report_docx_header(doc, context) normalized_rows = [] for task in related_tasks: table_data = getattr(getattr(task, 'work_hours_table', None), 'data', None) @@ -1961,17 +2012,17 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None) if len(doc.tables) >= 3: 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) + _set_docx_cell_text(info_table, 1, 0, context['client_name']) + _set_docx_cell_text(info_table, 1, 1, context['location']) + _set_docx_cell_text(info_table, 3, 0, context['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, 0, context['servicer_vehicle_make_model']) + _set_docx_cell_text(transport_table, 1, 1, context['servicer_vehicle_registration']) _set_docx_cell_text(transport_table, 1, 2, '-') - _set_docx_cell_text(transport_table, 1, 4, completion_label) + _set_docx_cell_text(transport_table, 1, 4, context['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] diff --git a/frontend/src/components/dashboard/TaskCalendarWidget.jsx b/frontend/src/components/dashboard/TaskCalendarWidget.jsx index 9c2b63b..58d7a1c 100644 --- a/frontend/src/components/dashboard/TaskCalendarWidget.jsx +++ b/frontend/src/components/dashboard/TaskCalendarWidget.jsx @@ -1,10 +1,8 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; import { getStatusLabel } from '../../stores/taskStore'; import { - downloadMonthlyServiceTasksArchive, downloadMonthlyCostsReport, downloadMonthlyServiserReport, - downloadMonthlyWorkOrdersArchive, fetchMonthlyCostInvoices, fetchMonthlyServicerEntries, upsertMonthlyServicerEntry, @@ -91,9 +89,6 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders const [manualEntryDraft, setManualEntryDraft] = useState(null); const [manualEntryError, setManualEntryError] = useState(''); const [savingManualEntry, setSavingManualEntry] = useState(false); - const [bulkDownloadOpen, setBulkDownloadOpen] = useState(false); - const [downloadingAllTasks, setDownloadingAllTasks] = useState(false); - const [downloadingAllWorkOrders, setDownloadingAllWorkOrders] = useState(false); const reportOpen = reportType !== null; const panelWidth = reportOpen ? 920 : 300; @@ -346,9 +341,6 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders function toggleReport(type) { setReportType((prev) => (prev === type ? null : type)); setManualEntryTarget(null); - if (type !== 'servicer') { - setBulkDownloadOpen(false); - } } async function handleDownload() { @@ -365,28 +357,6 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders } } - async function handleDownloadAllTasksArchive() { - if (downloadingAllTasks) return; - setDownloadingAllTasks(true); - try { - await downloadMonthlyServiceTasksArchive(viewYear, viewMonth + 1); - setBulkDownloadOpen(false); - } finally { - setDownloadingAllTasks(false); - } - } - - async function handleDownloadAllWorkOrdersArchive() { - if (downloadingAllWorkOrders) return; - setDownloadingAllWorkOrders(true); - try { - await downloadMonthlyWorkOrdersArchive(viewYear, viewMonth + 1); - setBulkDownloadOpen(false); - } finally { - setDownloadingAllWorkOrders(false); - } - } - function handleServicerRowClick(row) { if (!row?.clickable) return; setManualEntryTarget(row); @@ -661,17 +631,8 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders disabled={downloading} className="rounded-md bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-60" > - {downloading ? 'Preuzimanje...' : reportType === 'servicer' ? 'Preuzmi mjesečni izvještaj' : 'Preuzmi DOCX'} + {downloading ? 'Preuzimanje...' : 'Preuzmi DOCX'} - {reportType === 'servicer' && ( - - )}

@@ -864,48 +825,6 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders )} - - {bulkDownloadOpen && reportType === 'servicer' && ( -

-
-
-
-

Preuzmi sve

-

- {MONTH_NAMES[viewMonth]} {viewYear} -

-
- -
- -
- - -
-
-
- )} )}