16 Commits

Author SHA1 Message Date
mariomitte
242c8dc9b2 .zip error creation due to ownership
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
detected with ai agent
2026-09-08 14:45:37 +02:00
mariomitte
1172dfabd7 fix: include user-owned work orders in monthly archive
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
Ensure monthly ZIP generation includes work orders owned by the user even when the task is assigned to someone else, and add a regression spec covering the user-owned work-order case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 11:11:25 +02:00
mariomitte
fc2a683b9c fix: restore monthly archive temp file builders
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
The monthly ZIP job was still being requested normally, but the backend archive task could not create its temp archive files because the helper functions were missing. Restore those builders so the archive task can complete and emit the final generated_archive notification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 10:32:49 +02:00
mariomitte
f243bad6db fix: restore archive completion notification polling
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
Track monthly archive generation by generated_archive_id and poll notifications until completed/failed (up to timeout) instead of short PDF polling only. This restores completed ZIP notifications for calendar archive actions when generation takes longer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 08:33:20 +02:00
mariomitte
334d1acd37 fix: restore monthly archive download exports
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
Re-add missing monthly archive export functions in fleetDashboardStore used by TaskCalendarWidget so the frontend Docker build resolves imports and starts cleanly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 06:59:53 +02:00
mariomitte
dd27e59c2c fix: restore generated archive download export
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
Re-add downloadGeneratedArchiveByUrl in fleet dashboard store so notification detail modal imports resolve during frontend build.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 06:54:39 +02:00
mariomitte
3931c9065b fix: restore missing fleet dashboard store exports
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
Re-add missing travel-expenses and service-note store exports used by the invoices PDF page, and include travel_expenses_table in service-context fallback. This fixes the frontend Docker build failure caused by missing named exports.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-09-08 06:46:16 +02:00
mariomitte
5af66c31b6 fix: add total rows to service-record hours tables
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
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>
2026-09-07 16:37:37 +02:00
mariomitte
b6fe8d8969 Refactor fleet module and add cleanup script
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
- Update Celery configuration in celery.py
- Modify base settings for improved performance
- Enhance task management in fleet tasks.py
- Revise fleet views.py for better data handling
- Add cleanup_tmp_archives.sh script for temporary file management
- Adjust docker-compose.prod.yml for consistency
- Optimize fleetDashboardStore.js with reduced code complexity
2026-09-05 08:35:39 +02:00
mariomitte
b985c285f9 fix: apply EXIF orientation to service record images in PDF/DOCX
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
Smartphone photos often have EXIF orientation metadata (tags 6, 8, 3)
that rotates the display but doesn't transform the pixel data. When
PDFs/DOCX embedded images without applying this metadata, they appear
rotated 90/180/270 degrees.

Use ImageOps.exif_transpose(img) before resize/convert in both
_compress_image_for_pdf() and _compress_image_for_docx() to read EXIF
orientation and transpose the actual pixel data accordingly. This is
a standard Pillow function and is a no-op for images without EXIF.

Add regression test test_compress_image_for_docx_handles_exif_orientation
to verify images with EXIF tag 0x0112 (orientation=6) are transposed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 11:08:16 +02:00
mariomitte
aa690d4ac4 fix: increase image megapixel limit from 8 to 30 MP to include standard phone photos
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
Previous limit of 8 MP was rejecting standard smartphone photos (12-20 MP),
causing service record PDFs/DOCX documents to be generated without photos.

This was a regression from the image timeout fix: the megapixel guard was
designed to prevent processing of pathologically large files (preventing
worker timeouts), but the threshold was set too aggressively.

Increase limit to 30 MP to allow standard device cameras while still
rejecting extreme outliers that would cause timeout/OOM.

Add regression test test_compress_image_for_docx_keeps_standard_phone_photos
to verify 12 MP photos are accepted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 10:54:40 +02:00
mariomitte
52408bc5a9 fix: BILINEAR+BaseException catch to fix image worker timeout in PDF/DOCX
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
Root cause was WORKER TIMEOUT, not OOM:
- Gunicorn sends SIGABRT to workers that exceed 60s timeout
- SIGABRT handler calls sys.exit(1) raising SystemExit(BaseException)
- except Exception does NOT catch SystemExit, so the worker crashes

Two fixes:
1. except BaseException — catches SystemExit so the worker survives
   and gracefully skips the image (returns None) instead of dying
2. Image.BILINEAR instead of LANCZOS — orders of magnitude faster
   for large images, ensures processing completes well within 60s
3. Reduce max_width 1600->800, megapixel limit 20->8 MP, optimize=True removed
   (these reduce processing time further)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 10:43:27 +02:00
mariomitte
8e9d23fcfa fix: thumbnail-first + megapixel guard to prevent image OOM in PDF/DOCX
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
Previous draft()+convert()+thumbnail() ordering still caused OOM for
non-JPEG formats (PNG/HEIC) because draft() is a no-op for those formats,
and convert('RGB') forces a full pixel decode regardless.

Replace with thumbnail()-first ordering:
- thumbnail() internally calls draft() for JPEG before decoding
- thumbnail() performs in-place resize without allocating a second
  full-resolution buffer
- convert('RGB') is then called on the already-small image (safe for
  any format)

Add _COMPRESS_IMAGE_MAX_MEGAPIXELS guard (20 MP): read image dimensions
from headers only (no pixel decode) and return None for images that
exceed the limit. This prevents OOM even for pathologically large files
where draft() provides no benefit (e.g. PNG, TIFF, HEIC).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 10:10:01 +02:00
mariomitte
a7220d27f5 fix: use draft()+thumbnail() to prevent OOM kill on large image resize
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
PIL img.resize() on high-resolution photos was exhausting worker RAM,
causing Gunicorn to SIGKILL the worker mid-request (production OOM crash).

Replace the explicit resize() path in both _compress_image_for_pdf() and
_compress_image_for_docx() with:
  - Image.draft() — hints the JPEG decoder to decode at a lower resolution
  - Image.thumbnail() — in-place resize that avoids allocating a second
    full-resolution buffer

This keeps peak memory proportional to the output size instead of the
original file size, preventing the worker from being killed when
processing multi-megapixel service-record photos.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 09:51:21 +02:00
mariomitte
1e68c663f2 fix: scope inferred work-order tasks to the current work order
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
Prevent service-record inference from pulling tasks from other work orders
that share the same vehicle. This keeps task-service-context, service
reports, and related work-order widgets isolated to the selected nalog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 08:32:41 +02:00
mariomitte
2826ee9bab fix: invalidate stale service records PDF cache on service record changes
Ensure admin/API deletes and updates of service records, photos, and
attachments invalidate the cached service-records PDF. Also harden cached
PDF lookup/download paths so a missing file is treated as stale cache
instead of a 500.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-31 07:32:05 +02:00
11 changed files with 466 additions and 107 deletions

View File

@@ -7,7 +7,7 @@ Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promje
```bash ```bash
cd /opt/erp/app cd /opt/erp/app
git fetch origin git fetch origin
git reset --hard origin/main git reset --hard origin/main ili git pull --ff-only origin main
# rebuild backend + worker + beat + frontend # rebuild backend + worker + beat + frontend
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend

View File

@@ -2,11 +2,33 @@
import os import os
from celery import Celery from celery import Celery
from celery import Task
import gc
import logging
# Postavi Django settings modul # Postavi Django settings modul
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
app = Celery('core') app = Celery('core')
logger = logging.getLogger(__name__)
class ResourceAwareTask(Task):
"""Celery base task s hookovima za memory-heavy async operacije."""
abstract = True
def on_success(self, retval, task_id, args, kwargs):
logger.info("Celery task uspješan: %s (%s)", self.name, task_id)
super().on_success(retval, task_id, args, kwargs)
def on_failure(self, exc, task_id, args, kwargs, einfo):
logger.exception("Celery task neuspješan: %s (%s): %s", self.name, task_id, exc)
super().on_failure(exc, task_id, args, kwargs, einfo)
def after_return(self, status, retval, task_id, args, kwargs, einfo):
gc.collect()
super().after_return(status, retval, task_id, args, kwargs, einfo)
# Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_' # Koristi konfiguraciju iz settings.py s prefiksom 'CELERY_'
app.config_from_object('django.conf:settings', namespace='CELERY') app.config_from_object('django.conf:settings', namespace='CELERY')

View File

@@ -154,6 +154,10 @@ REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
CELERY_BROKER_URL = REDIS_URL CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL CELERY_RESULT_BACKEND = REDIS_URL
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TASK_ACKS_LATE = True
CELERY_WORKER_MAX_TASKS_PER_CHILD = 20
CELERY_WORKER_MAX_MEMORY_PER_CHILD = 300000
# Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse) # Ako želiš koristiti Redis kao brzi cache sustav unutar Djanga (izvrsno za ERP performanse)
# Za ovo ti je potreban paket 'django-redis' u requirements.txt # Za ovo ti je potreban paket 'django-redis' u requirements.txt
@@ -175,6 +179,14 @@ CELERY_BEAT_SCHEDULE = {
'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task', 'task': 'modules.fleet.tasks.cleanup_expired_generated_pdfs_task',
'schedule': crontab(minute=0), 'schedule': crontab(minute=0),
}, },
'cleanup-expired-generated-archives-hourly': {
'task': 'modules.fleet.tasks.cleanup_expired_generated_archives_task',
'schedule': crontab(minute=10),
},
'cleanup-stale-tmp-archives-daily': {
'task': 'modules.fleet.tasks.cleanup_stale_tmp_archives_task',
'schedule': crontab(hour=3, minute=30),
},
'notify-upcoming-tasks-daily': { 'notify-upcoming-tasks-daily': {
'task': 'modules.task_management.tasks.notify_upcoming_tasks', 'task': 'modules.task_management.tasks.notify_upcoming_tasks',
'schedule': crontab(hour=8, minute=0), 'schedule': crontab(hour=8, minute=0),

View File

@@ -2,10 +2,12 @@ import uuid
import re import re
from datetime import time from datetime import time
from django.db import models from django.db import models
from django.db.models.signals import post_save, pre_delete
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone from django.utils import timezone
from django.dispatch import receiver
from core.base_models import BaseModel from core.base_models import BaseModel
from decimal import Decimal from decimal import Decimal
@@ -142,6 +144,67 @@ class VehicleServiceAttachment(BaseModel):
def __str__(self): def __str__(self):
return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})" return f"Attachment {self.pk} for {self.service_record} ({self.file.name if self.file else 'no-file'})"
def _service_record_work_order_id(instance):
if isinstance(instance, VehicleServiceRecord):
task = getattr(instance, 'task', None)
if task and getattr(task, 'work_order_id', None):
return task.work_order_id
if getattr(instance, 'task_id', None):
return VehicleServiceRecord.objects.filter(pk=instance.pk).values_list('task__work_order_id', flat=True).first()
return None
service_record = getattr(instance, 'service_record', None)
if service_record and getattr(service_record, 'task', None) and getattr(service_record.task, 'work_order_id', None):
return service_record.task.work_order_id
service_record_id = getattr(instance, 'service_record_id', None)
if not service_record_id:
return None
return VehicleServiceRecord.objects.filter(pk=service_record_id).values_list('task__work_order_id', flat=True).first()
def _invalidate_service_records_pdf_cache(work_order_id):
if not work_order_id:
return
cached_pdfs = GeneratedWorkOrderPdf.objects.filter(
is_active=True,
work_order_id=work_order_id,
pdf_type='service_records',
)
for cached in cached_pdfs:
file_name = getattr(cached.file, 'name', '')
if file_name:
try:
if cached.file.storage.exists(file_name):
cached.file.delete(save=False)
except (FileNotFoundError, OSError, ValueError):
pass
cached.file = None
cached.is_active = False
cached.status = 'failed'
cached.error_message = 'PDF cache invalidiran zbog promjene servisnih zapisa.'
cached.save(update_fields=['file', 'is_active', 'status', 'error_message', 'updated_at'])
@receiver(post_save, sender='fleet.VehicleServiceRecord')
@receiver(pre_delete, sender='fleet.VehicleServiceRecord')
def _invalidate_service_records_pdf_for_service_record(sender, instance, **kwargs):
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
@receiver(post_save, sender='fleet.VehicleServicePhoto')
@receiver(pre_delete, sender='fleet.VehicleServicePhoto')
def _invalidate_service_records_pdf_for_service_photo(sender, instance, **kwargs):
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
@receiver(post_save, sender='fleet.VehicleServiceAttachment')
@receiver(pre_delete, sender='fleet.VehicleServiceAttachment')
def _invalidate_service_records_pdf_for_service_attachment(sender, instance, **kwargs):
_invalidate_service_records_pdf_cache(_service_record_work_order_id(instance))
class Vehicle(BaseModel): class Vehicle(BaseModel):
ASSET_TYPE_CHOICES = [ ASSET_TYPE_CHOICES = [
('vehicle', _("Vozilo")), ('vehicle', _("Vozilo")),

View File

@@ -1,9 +1,11 @@
# backend/modules/fleet/tasks.py # backend/modules/fleet/tasks.py
from celery import shared_task from celery import shared_task
from core.celery import ResourceAwareTask
from django.core.mail import send_mail from django.core.mail import send_mail
from django.core.mail import EmailMessage from django.core.mail import EmailMessage
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from django.core.files import File
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
import logging import logging
from io import BytesIO from io import BytesIO
@@ -835,13 +837,12 @@ def process_work_order_invoice_ocr(invoice_id):
return {"status": "ok", "invoice_id": str(invoice.pk)} return {"status": "ok", "invoice_id": str(invoice.pk)}
@shared_task @shared_task(base=ResourceAwareTask)
def build_monthly_archive_cached_task(generated_archive_id): def build_monthly_archive_cached_task(generated_archive_id):
from .models import GeneratedFleetArchive from .models import GeneratedFleetArchive
from .services import NotificationService
from .views import ( from .views import (
_build_monthly_service_tasks_archive_content, _build_monthly_service_tasks_archive_to_temp_file,
_build_monthly_work_orders_archive_content, _build_monthly_work_orders_archive_to_temp_file,
_notify_monthly_archive_request, _notify_monthly_archive_request,
) )
@@ -854,25 +855,27 @@ def build_monthly_archive_cached_task(generated_archive_id):
if generated is None: if generated is None:
return {"status": "failed", "error": "Generated archive record not found"} return {"status": "failed", "error": "Generated archive record not found"}
tmp_archive_path = None
try: try:
if generated.archive_type == 'service_tasks': if generated.archive_type == 'service_tasks':
archive_content = _build_monthly_service_tasks_archive_content( tmp_archive_path = _build_monthly_service_tasks_archive_to_temp_file(
user=generated.requested_by, user=generated.requested_by,
year=generated.year, year=generated.year,
month=generated.month, month=generated.month,
) )
else: else:
archive_content = _build_monthly_work_orders_archive_content( tmp_archive_path = _build_monthly_work_orders_archive_to_temp_file(
user=generated.requested_by, user=generated.requested_by,
year=generated.year, year=generated.year,
month=generated.month, month=generated.month,
) )
if not archive_content: if tmp_archive_path is None or tmp_archive_path.stat().st_size <= 0:
raise ValueError('ZIP arhiva je prazna.') raise ValueError('ZIP arhiva je prazna.')
filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip" filename = generated.filename or f"{generated.archive_type}-{generated.year}-{generated.month}.zip"
generated.file.save(filename, ContentFile(archive_content), save=False) with tmp_archive_path.open('rb') as temp_handle:
generated.file.save(filename, File(temp_handle), save=False)
generated.status = 'ready' generated.status = 'ready'
generated.error_message = '' generated.error_message = ''
generated.save(update_fields=['file', 'status', 'error_message', 'updated_at']) generated.save(update_fields=['file', 'status', 'error_message', 'updated_at'])
@@ -902,6 +905,9 @@ def build_monthly_archive_cached_task(generated_archive_id):
) )
logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc) logger.exception("Greška kod build_monthly_archive_cached_task: %s", exc)
return {"status": "failed", "error": str(exc)} return {"status": "failed", "error": str(exc)}
finally:
if tmp_archive_path is not None:
tmp_archive_path.unlink(missing_ok=True)
@shared_task @shared_task
@@ -924,6 +930,14 @@ def cleanup_expired_generated_archives_task():
return {"deleted": deleted} return {"deleted": deleted}
@shared_task
def cleanup_stale_tmp_archives_task():
from .views import _cleanup_stale_tmp_archives
deleted = _cleanup_stale_tmp_archives()
return {"deleted": deleted}
@shared_task @shared_task
def cleanup_expired_generated_pdfs_task(): def cleanup_expired_generated_pdfs_task():
now = timezone.now() now = timezone.now()

View File

@@ -26,7 +26,12 @@ from modules.fleet.models import (
) )
from modules.task_management.models import Task, TaskWorkHoursTable from modules.task_management.models import Task, TaskWorkHoursTable
from modules.fleet.tasks import build_monthly_archive_cached_task from modules.fleet.tasks import build_monthly_archive_cached_task
from modules.fleet.views import _build_monthly_servicer_report_rows, _calculate_daily_quantity_from_hours from modules.fleet.views import (
_build_monthly_servicer_report_rows,
_calculate_daily_quantity_from_hours,
_compress_image_for_docx,
_work_order_related_tasks_queryset,
)
def create_test_image(filename='test.jpg', size=(40, 40), color='red'): def create_test_image(filename='test.jpg', size=(40, 40), color='red'):
@@ -236,6 +241,58 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertIn('additional_costs_table', payload) self.assertIn('additional_costs_table', payload)
self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00') self.assertEqual(payload['additional_costs_table']['total_for_payout'], '5.00')
def test_compress_image_for_docx_keeps_standard_phone_photos(self):
file = BytesIO()
Image.new('RGB', (4000, 3000), color='blue').save(file, format='JPEG', quality=85)
file.seek(0)
uploaded = SimpleUploadedFile('phone-12mp.jpg', file.getvalue(), content_type='image/jpeg')
result = _compress_image_for_docx(uploaded)
self.assertIsNotNone(result)
self.assertGreater(len(result.getvalue()), 0)
def test_compress_image_for_docx_handles_exif_orientation(self):
exif = Image.Exif()
exif[0x0112] = 6
file = BytesIO()
Image.new('RGB', (3000, 2000), color='green').save(file, format='JPEG', quality=85, exif=exif.tobytes())
file.seek(0)
uploaded = SimpleUploadedFile('rotated-phone.jpg', file.getvalue(), content_type='image/jpeg')
result = _compress_image_for_docx(uploaded)
self.assertIsNotNone(result)
self.assertGreater(len(result.getvalue()), 0)
def test_work_order_related_tasks_queryset_excludes_other_work_orders_for_same_vehicle(self):
other_work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT160726',
purpose='kontrola',
)
other_task = Task.objects.create(
title='Drugi nalog isti stroj',
assigned_to=self.user,
vehicle=self.vehicle,
work_order=other_work_order,
scheduled_date=date(2033, 12, 25),
)
VehicleServiceRecord.objects.create(
vehicle=self.vehicle,
task=other_task,
performed_by=self.user,
description='Ne smije se pojaviti u prvom nalogu.',
service_title='Krivi nalog',
)
resolved_ids = list(_work_order_related_tasks_queryset(self.work_order).values_list('id', flat=True))
self.assertIn(self.task.pk, resolved_ids)
self.assertNotIn(other_task.pk, resolved_ids)
def test_invoice_upload_immediately_appears_in_additional_costs_table(self): def test_invoice_upload_immediately_appears_in_additional_costs_table(self):
create_response = self.client.post( create_response = self.client.post(
reverse('work-order-invoice-list'), reverse('work-order-invoice-list'),
@@ -298,6 +355,52 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertFalse(cached.is_active) self.assertFalse(cached.is_active)
self.assertEqual(cached.status, 'failed') self.assertEqual(cached.status, 'failed')
def test_service_record_delete_invalidates_service_records_pdf_cache(self):
cached = GeneratedWorkOrderPdf.objects.create(
work_order=self.work_order,
requested_by=self.user,
pdf_type='service_records',
status='ready',
filename='MT150726.work-order-service-records.pdf',
expires_at=timezone.now() + timedelta(hours=1),
)
cached.file.save(
'MT150726.work-order-service-records.pdf',
ContentFile(b'%PDF-1.4 cached service records'),
save=True,
)
VehicleServiceRecord.objects.filter(pk=self.service_record.pk).delete()
cached.refresh_from_db()
self.assertFalse(cached.is_active)
self.assertEqual(cached.status, 'failed')
self.assertFalse(cached.file.name)
def test_service_records_pdf_rebuilds_when_cached_file_is_missing(self):
cached = GeneratedWorkOrderPdf.objects.create(
work_order=self.work_order,
requested_by=self.user,
pdf_type='service_records',
status='ready',
filename='MT150726.work-order-service-records.pdf',
expires_at=timezone.now() + timedelta(hours=1),
)
cached.file.save(
'MT150726.work-order-service-records.pdf',
ContentFile(b'%PDF-1.4 cached service records'),
save=True,
)
cached.file.storage.delete(cached.file.name)
response = self.client.get(f"/api/fleet/work-orders/{self.work_order.pk}/service-records-pdf/")
self.assertEqual(response.status_code, 200, response.content)
self.assertEqual(response['Content-Type'], 'application/pdf')
cached.refresh_from_db()
self.assertFalse(cached.is_active)
self.assertEqual(cached.status, 'failed')
def test_service_records_docx_contains_embedded_service_photos(self): def test_service_records_docx_contains_embedded_service_photos(self):
VehicleServicePhoto.objects.create( VehicleServicePhoto.objects.create(
service_record=self.service_record, service_record=self.service_record,
@@ -394,6 +497,67 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertIn('MT170726', text) self.assertIn('MT170726', text)
self.assertNotIn('MT150726', 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): 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') response = self.client.get('/api/fleet/reports/monthly-service-tasks-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content) self.assertEqual(response.status_code, 200, response.content)
@@ -475,6 +639,40 @@ class WorkOrderImagesEndpointTests(TestCase):
self.assertNotIn('Liebherr LTM 1090', second_header) self.assertNotIn('Liebherr LTM 1090', second_header)
self.assertNotIn('MT150726', second_header) self.assertNotIn('MT150726', second_header)
def test_monthly_work_orders_archive_includes_work_orders_for_user_even_without_assigned_task(self):
other_user = get_user_model().objects.create_user(
username=f'wo-other-{uuid.uuid4().hex[:8]}',
email=f'wo-other-{uuid.uuid4().hex[:8]}@example.test',
password='test1234',
)
linked_work_order = WorkOrder.objects.create(
vehicle=self.vehicle,
creator=self.user,
display_code='MT170726',
purpose='kontrola',
)
Task.objects.create(
title='Zadatak drugog servisera',
assigned_to=other_user,
vehicle=self.vehicle,
work_order=linked_work_order,
scheduled_date=date(2033, 12, 20),
)
WorkOrderInvoice.objects.create(
work_order=linked_work_order,
naziv_racuna='Prosinac račun osoba',
datum='2033-12-12',
image=create_test_pdf('racun-prosinac-osoba.pdf'),
created_by=self.user,
)
response = self.client.get('/api/fleet/reports/monthly-work-orders-archive/?year=2033&month=12')
self.assertEqual(response.status_code, 200, response.content)
archive = zipfile.ZipFile(BytesIO(response.content))
self.assertIn('MT150726.work-order.pdf', archive.namelist())
self.assertIn('MT170726.work-order.pdf', archive.namelist())
def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self): def test_monthly_work_orders_archive_contains_work_orders_and_invoices_folder(self):
WorkOrderInvoice.objects.create( WorkOrderInvoice.objects.create(
work_order=self.work_order, work_order=self.work_order,

View File

@@ -5,6 +5,7 @@ from io import StringIO
import base64 import base64
import csv import csv
import mimetypes import mimetypes
import tempfile
import threading import threading
import zipfile import zipfile
import re import re
@@ -304,14 +305,6 @@ def _format_decimal_fixed(value, *, default='0.00', places=2):
return format(normalized.quantize(quantizer), 'f') return format(normalized.quantize(quantizer), 'f')
def _normalize_datetime_for_compare(value):
if not isinstance(value, datetime):
return value
if timezone.is_naive(value):
return timezone.make_aware(value, timezone.get_current_timezone())
return timezone.localtime(value)
def _work_order_travel_expenses_context(work_order): def _work_order_travel_expenses_context(work_order):
related_tasks = list(_work_order_related_tasks_queryset(work_order)) related_tasks = list(_work_order_related_tasks_queryset(work_order))
trip_entries = [] trip_entries = []
@@ -326,16 +319,8 @@ def _work_order_travel_expenses_context(work_order):
if trip_entries: if trip_entries:
entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')] entry_dates = [entry['date'] for entry in trip_entries if entry.get('date')]
start_candidates = [ start_candidates = [entry['start_dt'] for entry in trip_entries if entry.get('start_dt')]
_normalize_datetime_for_compare(entry.get('start_dt')) end_candidates = [entry['end_dt'] for entry in trip_entries if entry.get('end_dt')]
for entry in trip_entries
if entry.get('start_dt')
]
end_candidates = [
_normalize_datetime_for_compare(entry.get('end_dt'))
for entry in trip_entries
if entry.get('end_dt')
]
if entry_dates: if entry_dates:
trip_start_date = min(entry_dates) trip_start_date = min(entry_dates)
trip_end_date = max(entry_dates) trip_end_date = max(entry_dates)
@@ -1350,6 +1335,7 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
"Kilometri\nvozila", "Kilometri\nvozila",
] ]
table3_rows = [table3_headers] table3_rows = [table3_headers]
total_hours_work = 0.0
total_hours_travel = 0.0 total_hours_travel = 0.0
total_vehicle_km = 0.0 total_vehicle_km = 0.0
@@ -1366,13 +1352,24 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
row['places'], row['places'],
row['vehicle_km'], row['vehicle_km'],
]) ])
total_hours_work += _parse_decimal(row['work_hours'])
total_hours_travel += _parse_decimal(row['travel_hours']) total_hours_travel += _parse_decimal(row['travel_hours'])
total_vehicle_km += _parse_decimal(row['vehicle_km']) total_vehicle_km += _parse_decimal(row['vehicle_km'])
else: else:
for _ in range(12): for _ in range(12):
table3_rows.append(["-", "-", "-", "-", "-", "-", "-", "Polazak: -\nDolazak: -", "-"]) 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 = draw_table(
y, y,
table3_rows, table3_rows,
@@ -1384,15 +1381,11 @@ def _build_work_order_service_records_pdf(work_order, related_tasks=None):
('GRID', (0, 0), (-1, -1), 0.5, colors.black), ('GRID', (0, 0), (-1, -1), 0.5, colors.black),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (0, -1), 'CENTER'), ('ALIGN', (0, 0), (0, -1), 'CENTER'),
('ALIGN', (1, 0), (6, -1), 'CENTER'), ('ALIGN', (1, 0), (8, -1), 'CENTER'),
('SPAN', (0, -1), (5, -1)),
('ALIGN', (0, -1), (5, -1), 'LEFT'),
('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'), ('FONTNAME', (0, -1), (0, -1), 'Vera-Bold'),
('FONTNAME', (6, -1), (6, -1), 'Vera-Bold'), ('FONTNAME', (5, -1), (8, -1), 'Vera-Bold'),
('FONTNAME', (8, -1), (8, -1), 'Vera-Bold'),
('LEFTPADDING', (0, 0), (-1, -1), 3), ('LEFTPADDING', (0, 0), (-1, -1), 3),
('RIGHTPADDING', (0, 0), (-1, -1), 3), ('RIGHTPADDING', (0, 0), (-1, -1), 3),
('LEFTPADDING', (0, -1), (5, -1), 12),
('VALIGN', (7, 1), (7, -2), 'TOP'), ('VALIGN', (7, 1), (7, -2), 'TOP'),
('ALIGN', (7, 1), (7, -2), 'LEFT'), ('ALIGN', (7, 1), (7, -2), 'LEFT'),
]), ]),
@@ -2281,6 +2274,8 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
for index, header in enumerate(headers): for index, header in enumerate(headers):
hours_table.rows[0].cells[index].text = header hours_table.rows[0].cells[index].text = header
_docx_remove_rows_after(hours_table, keep_rows=1) _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: if normalized_rows:
for row in normalized_rows: for row in normalized_rows:
cells = hours_table.add_row().cells cells = hours_table.add_row().cells
@@ -2297,6 +2292,16 @@ def _build_work_order_service_records_docx_bytes(work_order, related_tasks=None)
cells = hours_table.add_row().cells cells = hours_table.add_row().cells
for index in range(9): for index in range(9):
cells[index].text = '-' 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_move_table_after_paragraph_text(doc, hours_table, 'Tablica radnih sati')
_docx_cleanup_service_report_template(doc) _docx_cleanup_service_report_template(doc)
_docx_remove_empty_page_break_paragraphs(doc) _docx_remove_empty_page_break_paragraphs(doc)
@@ -2933,6 +2938,25 @@ def _generated_archive_filename_for_user(user, *, year, month, archive_type):
return f"{prefix}-{month:02d}-{year}-{suffix}.zip" return f"{prefix}-{month:02d}-{year}-{suffix}.zip"
def _build_monthly_archive_to_temp_file(*, user, year, month, archive_type):
if archive_type == 'service_tasks':
archive_content = _build_monthly_service_tasks_archive_content(user=user, year=year, month=month)
else:
archive_content = _build_monthly_work_orders_archive_content(user=user, year=year, month=month)
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_file:
temp_file.write(archive_content)
return Path(temp_file.name)
def _build_monthly_service_tasks_archive_to_temp_file(*, user, year, month):
return _build_monthly_archive_to_temp_file(user=user, year=year, month=month, archive_type='service_tasks')
def _build_monthly_work_orders_archive_to_temp_file(*, user, year, month):
return _build_monthly_archive_to_temp_file(user=user, year=year, month=month, archive_type='work_orders')
def _unique_zip_entry_name(entry_name, used_names): def _unique_zip_entry_name(entry_name, used_names):
candidate = entry_name candidate = entry_name
entry_path = Path(entry_name) entry_path = Path(entry_name)
@@ -3006,28 +3030,7 @@ def _build_monthly_service_tasks_archive_content(*, user, year, month):
def _build_monthly_work_orders_archive_content(*, user, year, month): def _build_monthly_work_orders_archive_content(*, user, year, month):
from modules.task_management.models import Task ordered_work_order_ids = _user_monthly_work_order_ids(user=user, year=year, month=month)
monthly_tasks = (
Task.objects
.filter(
assigned_to=user,
is_active=True,
scheduled_date__year=year,
scheduled_date__month=month,
work_order__isnull=False,
work_order__is_active=True,
)
.select_related('work_order')
.order_by('scheduled_date', 'created_at')
)
ordered_work_order_ids = []
seen_work_order_ids = set()
for task in monthly_tasks:
if not task.work_order_id or task.work_order_id in seen_work_order_ids:
continue
seen_work_order_ids.add(task.work_order_id)
ordered_work_order_ids.append(task.work_order_id)
if not ordered_work_order_ids: if not ordered_work_order_ids:
raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'}) raise DRFValidationError({'detail': 'Nema putnih naloga za odabrani mjesec.'})
@@ -3154,26 +3157,80 @@ def _get_cached_generated_archive(*, user, archive_type, year, month):
) )
def _user_monthly_work_order_ids(*, user, year, month):
from modules.task_management.models import Task
task_work_order_ids = set(
Task.objects
.filter(
is_active=True,
scheduled_date__year=year,
scheduled_date__month=month,
work_order__isnull=False,
work_order__is_active=True,
)
.filter(
Q(assigned_to=user)
| Q(work_order__creator=user)
| Q(vehicle__assigned_servicer=user)
)
.values_list('work_order_id', flat=True)
.distinct()
)
work_order_ids = set(
WorkOrder.objects
.filter(
is_active=True,
date__year=year,
date__month=month,
)
.filter(
Q(creator=user)
| Q(vehicle__assigned_servicer=user)
)
.values_list('id', flat=True)
.distinct()
)
return list(dict.fromkeys([*task_work_order_ids, *work_order_ids]))
def _latest_monthly_archive_source_update(*, user, archive_type, year, month): def _latest_monthly_archive_source_update(*, user, archive_type, year, month):
from modules.task_management.models import Task from modules.task_management.models import Task
task_work_order_ids = _user_monthly_work_order_ids(user=user, year=year, month=month)
base_tasks = Task.objects.filter( base_tasks = Task.objects.filter(
assigned_to=user,
is_active=True, is_active=True,
scheduled_date__year=year, scheduled_date__year=year,
scheduled_date__month=month, scheduled_date__month=month,
work_order__isnull=False, work_order__isnull=False,
work_order__is_active=True, work_order__is_active=True,
).filter(
Q(assigned_to=user)
| Q(work_order__creator=user)
| Q(vehicle__assigned_servicer=user)
)
base_work_orders = WorkOrder.objects.filter(
is_active=True,
date__year=year,
date__month=month,
).filter(
Q(creator=user)
| Q(vehicle__assigned_servicer=user)
) )
latest_candidates = [ latest_candidates = [
base_tasks.aggregate(value=Max('updated_at')).get('value'), base_tasks.aggregate(value=Max('updated_at')).get('value'),
base_tasks.aggregate(value=Max('work_order__updated_at')).get('value'), base_tasks.aggregate(value=Max('work_order__updated_at')).get('value'),
base_tasks.aggregate(value=Max('work_order__vehicle__updated_at')).get('value'), base_tasks.aggregate(value=Max('work_order__vehicle__updated_at')).get('value'),
base_tasks.aggregate(value=Max('work_hours_table__updated_at')).get('value'), base_tasks.aggregate(value=Max('work_hours_table__updated_at')).get('value'),
base_work_orders.aggregate(value=Max('updated_at')).get('value'),
base_work_orders.aggregate(value=Max('vehicle__updated_at')).get('value'),
] ]
if archive_type == 'work_orders': if archive_type == 'work_orders':
latest_candidates.append( latest_candidates.append(
base_tasks.aggregate(value=Max('work_order__invoices__updated_at')).get('value') WorkOrderInvoice.objects.filter(
work_order_id__in=task_work_order_ids,
is_active=True,
).aggregate(value=Max('updated_at')).get('value')
) )
latest_values = [value for value in latest_candidates if value is not None] latest_values = [value for value in latest_candidates if value is not None]
if not latest_values: if not latest_values:

View File

@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
TARGET_DIR="${TMP_ARCHIVE_DIR:-/tmp/erp-generated-archives}"
RETENTION_DAYS="${TMP_RETENTION_DAYS:-1}"
if [ ! -d "$TARGET_DIR" ]; then
exit 0
fi
find "$TARGET_DIR" -type f -name '*.zip' -mtime +"$RETENTION_DAYS" -delete
find "$TARGET_DIR" -type d -empty -delete

View File

@@ -25,6 +25,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_started condition: service_started
media-permissions:
condition: service_completed_successfully
worker: worker:
build: build:
@@ -37,7 +39,7 @@ services:
environment: environment:
DEBUG: "False" DEBUG: "False"
DJANGO_SETTINGS_MODULE: core.settings.production DJANGO_SETTINGS_MODULE: core.settings.production
command: celery -A core worker --loglevel=info command: celery -A core worker --loglevel=info --concurrency=2 --prefetch-multiplier=1 --max-tasks-per-child=20 --max-memory-per-child=300000
volumes: volumes:
- media_volume:/app/media - media_volume:/app/media
depends_on: depends_on:
@@ -45,6 +47,8 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_started condition: service_started
media-permissions:
condition: service_completed_successfully
beat: beat:
build: build:
@@ -65,6 +69,8 @@ services:
condition: service_started condition: service_started
worker: worker:
condition: service_started condition: service_started
media-permissions:
condition: service_completed_successfully
flower: flower:
build: build:

View File

@@ -21,6 +21,17 @@ services:
# i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe. # i aktiviramo LRU algoritam koji automatski briše najstarije ključeve ako se limit prijeđe.
command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru command: redis-server --appendonly yes --maxmemory 100mb --maxmemory-policy allkeys-lru
media-permissions:
image: alpine:3.22
container_name: 004erpmediafix
command: >
sh -c "mkdir -p /app/media/fleet/generated_archives /app/media/fleet/generated_pdfs
&& chown -R 1001:1001 /app/media
&& chmod -R u+rwX,g+rwX /app/media"
volumes:
- media_volume:/app/media
restart: "no"
worker: worker:
container_name: 004erpworker container_name: 004erpworker
build: build:
@@ -36,6 +47,7 @@ services:
depends_on: depends_on:
db: { condition: service_healthy } db: { condition: service_healthy }
redis: { condition: service_started } redis: { condition: service_started }
media-permissions: { condition: service_completed_successfully }
deploy: deploy:
resources: resources:
limits: limits:

View File

@@ -467,19 +467,16 @@ export const $dashboardStats = computed(
const servicesToday = tasks.filter( const servicesToday = tasks.filter(
(t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis') (t) => t.scheduled_date === today && (t.status === 'aktivan' || t.status === 'servis')
).length; ).length;
const totalWorkOrders = workOrders.length; const warnings = serviceRecords.filter((record) => {
// Aktivni taskovi bez dodijeljenog putnog naloga if (record.next_service_due_at == null || record.mileage == null) return false;
const tasksWithoutWorkOrder = tasks.filter( return Number(record.next_service_due_at) - Number(record.mileage) <= 1000;
(t) => (t.status === 'aktivan' || t.status === 'servis' || t.status === 'spreman_za_zavrsetak') }).length;
&& !t.work_order
).length;
return { return {
openWorkOrders, openWorkOrders,
closedWorkOrders, closedWorkOrders,
totalWorkOrders,
servicesToday, servicesToday,
tasksWithoutWorkOrder, warnings,
}; };
} }
); );
@@ -970,30 +967,10 @@ export async function downloadWorkOrderInvoicesPdf(workOrderId) {
return payload; return payload;
} }
export async function downloadWorkOrderServiceRecordsPdf(workOrderId, taskId = null, taskOptions = {}) { export async function downloadWorkOrderServiceRecordsPdf(workOrderId) {
if (!workOrderId) { if (!workOrderId) {
throw new Error('Work order ID je obavezan.'); throw new Error('Work order ID je obavezan.');
} }
if (taskId) {
try {
const blob = await api.get(
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf/?task_id=${encodeURIComponent(taskId)}`,
{ responseType: 'blob' },
);
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
const taskTitle = String(taskOptions?.taskTitle || '').trim()
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9_-]+/g, '')
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.pdf`);
showToast('PDF servisnih zapisa za odabrani task je preuzet.', 'success');
return;
} catch (err) {
const msg = err?.message || 'Preuzimanje PDF-a servisnih zapisa za task nije uspjelo.';
showToast(msg, 'error');
throw err;
}
}
showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info'); showToast('Kreiran je zahtjev za PDF servisnih zapisa putnog naloga.', 'info');
const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {}); const payload = await api.post(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-pdf-request/`, {});
if (payload?.status === 'ready' && payload?.download_url) { if (payload?.status === 'ready' && payload?.download_url) {
@@ -1022,28 +999,14 @@ export async function downloadWorkOrderDocx(workOrderId) {
} }
} }
export async function downloadWorkOrderServiceRecordsDocx(workOrderId, taskId = null, taskOptions = {}) { export async function downloadWorkOrderServiceRecordsDocx(workOrderId) {
if (!workOrderId) { if (!workOrderId) {
throw new Error('Work order ID je obavezan.'); throw new Error('Work order ID je obavezan.');
} }
const query = taskId ? `?task_id=${encodeURIComponent(taskId)}` : '';
try { try {
const blob = await api.get( const blob = await api.get(`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/`, { responseType: 'blob' });
`fleet/work-orders/${encodeURIComponent(workOrderId)}/service-records-docx/${query}`, saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
{ responseType: 'blob' }, showToast('DOCX servisnih zapisa je preuzet.', 'success');
);
if (taskId) {
const workOrderDisplayCode = String(taskOptions?.workOrderDisplayCode || '').trim().toUpperCase() || String(workOrderId);
const taskTitle = String(taskOptions?.taskTitle || '').trim()
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9_-]+/g, '')
.replace(/^[_\-.]+|[_\-.]+$/g, '') || `task-${taskId}`;
saveBlobToFile(blob, `${workOrderDisplayCode}.SN-${taskTitle}.docx`);
showToast('DOCX servisnih zapisa za odabrani task je preuzet.', 'success');
} else {
saveBlobToFile(blob, `${workOrderId}.work-order-service-records.docx`);
showToast('DOCX servisnih zapisa je preuzet.', 'success');
}
} catch (err) { } catch (err) {
const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.'; const msg = err?.message || 'Preuzimanje DOCX servisnih zapisa nije uspjelo.';
showToast(msg, 'error'); showToast(msg, 'error');