fix: restore archive downloads and task report context
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

Restore the monthly fleet archive task and routes so generated ZIP downloads work again. Also populate service report DOCX headers from the selected task/work-order context and cover the direct and archived export flows with regression tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-08-06 20:37:30 +02:00
parent ed71bf500f
commit 925cd25d98
5 changed files with 196 additions and 207 deletions

View File

@@ -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)}
return {"status": "failed", "error": str(exc)}

View File

@@ -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,

View File

@@ -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/<uuid:archive_id>/download/', generated_archive_download, name='generated-archive-download'),
path('reports/generated-archives/<str:archive_id>/download/', generated_archive_download, name='generated-archive-download'),
]

View File

@@ -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]

View File

@@ -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'}
</button>
{reportType === 'servicer' && (
<button
type="button"
onClick={() => setBulkDownloadOpen(true)}
className="ml-2 rounded-md border border-border-hairline px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep"
>
Preuzmi sve
</button>
)}
</div>
<p className="border-b border-border-hairline bg-canvas-deep px-4 py-1.5 text-[11px] text-text-muted">
@@ -864,48 +825,6 @@ export default function TaskCalendarWidget({ tasks = [], notes = [], workOrders
</div>
</div>
)}
{bulkDownloadOpen && reportType === 'servicer' && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/30 px-4">
<div className="w-full max-w-md rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-2xl">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-text-main">Preuzmi sve</h3>
<p className="mt-1 text-xs text-text-muted">
{MONTH_NAMES[viewMonth]} {viewYear}
</p>
</div>
<button
type="button"
onClick={() => setBulkDownloadOpen(false)}
className="rounded p-1 text-text-muted hover:bg-canvas-deep"
aria-label="Zatvori"
>
x
</button>
</div>
<div className="mt-4 grid gap-2">
<button
type="button"
disabled={downloadingAllTasks || downloadingAllWorkOrders}
onClick={handleDownloadAllTasksArchive}
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50 disabled:opacity-60"
>
{downloadingAllTasks ? 'Generiranje...' : 'Preuzmi sve pojedinačne servisne taskove (SN)'}
</button>
<button
type="button"
disabled={downloadingAllTasks || downloadingAllWorkOrders}
onClick={handleDownloadAllWorkOrdersArchive}
className="rounded-lg border border-border-hairline px-3 py-2 text-left text-sm hover:bg-indigo-50 disabled:opacity-60"
>
{downloadingAllWorkOrders ? 'Generiranje...' : 'Preuzmi sve putne naloge + račune (PN)'}
</button>
</div>
</div>
</div>
)}
</div>
)}
</LeftSlideDrawer>