prvi kod za live uporabu
Some checks failed
ERP CI Pipeline / test (push) Has been cancelled

This commit is contained in:
mariomitte
2026-07-09 21:21:17 +02:00
parent 857bb65d52
commit 5d39e048ba
239 changed files with 25151 additions and 0 deletions

View File

View File

@@ -0,0 +1,43 @@
# backend/modules/invoicing/admin.py
from django.contrib import admin
from .models import InvoiceTransaction
@admin.register(InvoiceTransaction)
class InvoiceTransactionEntryAdmin(admin.ModelAdmin):
"""
Konfiguracija prikaza financijskih zapisa u Admin panelu.
"""
# Prikaz stupaca u listi
list_display = ('invoice_number', 'work_order_ref', 'amount', 'created_at', 'is_active')
# Omogućuje brzo filtriranje
list_filter = ('created_at', 'is_active', 'work_order')
list_select_related = ('work_order__vehicle',)
# Omogućuje pretragu
search_fields = ('invoice_number', 'work_order__id', 'work_order__vehicle__registration_number')
# Polja koja se ne smiju mijenjati u adminu
readonly_fields = ('id', 'created_at', 'updated_at')
# Grupiranje polja za lakši pregled
fieldsets = (
(None, {
'fields': ('invoice_number', 'work_order', 'amount', 'payment_date', 'is_active')
}),
('Sustavni podaci', {
'fields': ('id', 'created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
@admin.display(description="Povezani putni nalog")
def work_order_ref(self, obj):
if not obj.work_order_id:
return ""
short_id = str(obj.work_order_id).split('-')[0].upper()
registration = getattr(obj.work_order.vehicle, 'registration_number', '')
if registration:
return f"WO-{short_id} ({registration})"
return f"WO-{short_id}"

View File

@@ -0,0 +1,8 @@
# /backend/modules/invoicing/apps.py
from django.apps import AppConfig
class FleetConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'modules.invoicing'
verbose_name = "Invoicing"

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.15 on 2026-06-29 13:34
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='FinancialEntry',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('invoice_number', models.CharField(max_length=50)),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
],
options={
'verbose_name': 'Financijski zapis',
'verbose_name_plural': 'Financijski zapisi',
},
),
]

View File

@@ -0,0 +1,56 @@
# Generated by Django 5.2.15 on 2026-06-30 05:55
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('crm', '0001_initial'),
('invoicing', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Invoice',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('invoice_number', models.CharField(max_length=50, unique=True, verbose_name='Broj fakture')),
('amount', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Iznos')),
('currency', models.CharField(default='EUR', max_length=3)),
('tax_rate', models.DecimalField(decimal_places=2, default=25.0, max_digits=4)),
('status', models.CharField(choices=[('draft', 'Nacrt'), ('sent', 'Poslano'), ('paid', 'Plaćeno'), ('cancelled', 'Stornirano')], default='draft', max_length=20)),
('due_date', models.DateField(verbose_name='Rok plaćanja')),
('client', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='crm.client', verbose_name='Klijent')),
('creator', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='invoices', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Faktura',
'verbose_name_plural': 'Fakture',
},
),
migrations.CreateModel(
name='InvoiceItem',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('description', models.CharField(max_length=255, verbose_name='Opis stavke')),
('quantity', models.DecimalField(decimal_places=2, default=1, max_digits=10, verbose_name='Količina')),
('unit_price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Cijena po jedinici')),
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='invoicing.invoice', verbose_name='Faktura')),
],
options={
'verbose_name': 'Stavka fakture',
'verbose_name_plural': 'Stavke fakture',
},
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.2.15 on 2026-06-30 17:54
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoicing', '0002_invoice_invoiceitem'),
]
operations = [
migrations.CreateModel(
name='InvoiceTransaction',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='Unikatni identifikator entiteta (UUID).', primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Vrijeme kreiranja')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Vrijeme zadnje izmjene')),
('is_active', models.BooleanField(default=True, verbose_name='Aktivan zapis')),
('invoice_number', models.CharField(max_length=50)),
('amount', models.DecimalField(decimal_places=2, max_digits=10)),
('invoice', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction', to='invoicing.invoice', verbose_name='Transaction')),
],
options={
'verbose_name': 'Financijski zapis',
'verbose_name_plural': 'Financijski zapisi',
},
),
migrations.DeleteModel(
name='FinancialEntry',
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.2.15 on 2026-06-30 18:12
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoicing', '0003_invoicetransaction_delete_financialentry'),
]
operations = [
migrations.AddField(
model_name='invoicetransaction',
name='payment_date',
field=models.DateField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AlterField(
model_name='invoicetransaction',
name='invoice',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='invoicing.invoice', verbose_name='Faktura'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.2.15 on 2026-06-30 18:13
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('invoicing', '0004_invoicetransaction_payment_date_and_more'),
]
operations = [
migrations.AlterField(
model_name='invoicetransaction',
name='payment_date',
field=models.DateField(default=django.utils.timezone.now),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.15 on 2026-07-07
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fleet', '0012_workorder_purpose_choices'),
('invoicing', '0005_alter_invoicetransaction_payment_date'),
]
operations = [
migrations.AddField(
model_name='invoicetransaction',
name='work_order',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='invoice_transactions',
to='fleet.workorder',
verbose_name='Putni nalog',
),
),
]

View File

@@ -0,0 +1,123 @@
# backend/modules/invoicing/models.py
import datetime
from django.utils import timezone
from core.base_models import BaseModel
from django.db import models, transaction
from django.utils.translation import gettext_lazy as _
from django.conf import settings
from .utils import generate_invoice_number
class Invoice(BaseModel):
# Identifikacija (UUID i created_at dolaze iz BaseModel)
invoice_number = models.CharField(max_length=50, unique=True, verbose_name=_("Broj fakture"))
# Relacije
creator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="invoices"
)
client = models.ForeignKey(
'crm.Client',
on_delete=models.PROTECT,
verbose_name=_("Klijent")
)
# Financijski detalji
amount = models.DecimalField(max_digits=12, decimal_places=2, verbose_name=_("Iznos"))
currency = models.CharField(max_length=3, default="EUR")
tax_rate = models.DecimalField(max_digits=4, decimal_places=2, default=25.00)
# Statusi
status = models.CharField(
max_length=20,
choices=[
('draft', 'Nacrt'),
('sent', 'Poslano'),
('paid', 'Plaćeno'),
('cancelled', 'Stornirano')
],
default='draft'
)
due_date = models.DateField(verbose_name=_("Rok plaćanja"))
class Meta:
# Nasljeđujemo ordering iz BaseModel, ali ga možemo override-ati
verbose_name = _("Faktura")
verbose_name_plural = _("Fakture")
def __str__(self):
return f"{self.invoice_number} - {self.client}"
def get_total_amount(self):
return self.items.aggregate(total=models.Sum(models.F('quantity') * models.F('unit_price')))['total'] or 0
def save(self, *args, **kwargs):
if not self.invoice_number:
self.invoice_number = generate_invoice_number()
super().save(*args, **kwargs)
class InvoiceItem(BaseModel):
# Relacija prema fakturi
invoice = models.ForeignKey(
Invoice,
on_delete=models.CASCADE,
related_name='items',
verbose_name=_("Faktura")
)
# Detalji stavke
description = models.CharField(
max_length=255,
verbose_name=_("Opis stavke")
)
quantity = models.DecimalField(
max_digits=10,
decimal_places=2,
default=1,
verbose_name=_("Količina")
)
unit_price = models.DecimalField(
max_digits=12,
decimal_places=2,
verbose_name=_("Cijena po jedinici")
)
# Automatski izračun (property ne zauzima prostor u bazi)
@property
def total(self):
return self.quantity * self.unit_price
class Meta:
verbose_name = _("Stavka fakture")
verbose_name_plural = _("Stavke fakture")
def __str__(self):
# Koristimo prednost BaseModel __str__ metode
return f"{self.description} ({self.quantity} x {self.unit_price})"
class InvoiceTransaction(BaseModel):
# Polja specifična za fakturiranje
invoice = models.ForeignKey(
Invoice,
on_delete=models.PROTECT,
related_name='transactions',
verbose_name=_("Faktura")
)
work_order = models.ForeignKey(
'fleet.WorkOrder',
on_delete=models.SET_NULL,
related_name='invoice_transactions',
null=True,
blank=True,
verbose_name=_("Putni nalog"),
)
invoice_number = models.CharField(max_length=50)
amount = models.DecimalField(max_digits=10, decimal_places=2)
payment_date = models.DateField(default=timezone.now)
class Meta:
verbose_name = "Financijski zapis"
verbose_name_plural = "Financijski zapisi"

View File

@@ -0,0 +1,57 @@
from django.db import transaction
from core.serializers import BaseSerializer
from rest_framework import serializers
from .models import Invoice, InvoiceItem, InvoiceTransaction
from .services import InvoiceService #
class InvoiceItemSerializer(serializers.ModelSerializer):
class Meta:
model = InvoiceItem
fields = ['description', 'quantity', 'unit_price']
class InvoiceSerializer(serializers.ModelSerializer):
items = InvoiceItemSerializer(many=True) # Nested lista stavki
class Meta:
model = Invoice
fields = ['invoice_number', 'client', 'due_date', 'status', 'items', 'amount']
read_only_fields = ['invoice_number', 'creator', 'status', 'amount']
extra_kwargs = {
'invoice_number': {'required': False, 'allow_null': True},
'creator': {'required': False, 'allow_null': True}
}
def create(self, validated_data):
"""
Delegiramo stvaranje servisu i vraćamo instancu Invoice.
"""
items = validated_data.pop('items', [])
# Servisu šaljemo items u validated_data kao što servis očekuje
validated_data['items'] = items
# Pokušavamo dohvatiti korisnika iz contexta (ako postoji)
request = self.context.get('request')
user = getattr(request, 'user', None)
# Pozivamo servis unutar transaction.atomic (servis već radi atomic, ali dodatno osiguranje nije štetno)
invoice = InvoiceService.create_full_invoice(user=user, **validated_data)
return invoice
class InvoiceTransactionSerializer(BaseSerializer):
invoice = serializers.PrimaryKeyRelatedField(queryset=Invoice.objects.all())
def validate_amount(self, value):
request = self.context.get('request')
# Ako request ne postoji (npr. unit test), tretiramo ga kao običnog korisnika (sigurnost prvo)
user = getattr(request, 'user', None)
# Ako user ne postoji ili nije staff, i iznos je <= 0 -> greška
if value <= 0 and (not user or not user.is_staff):
raise serializers.ValidationError("Iznos računa mora biti veći od 0.")
return value
class Meta:
model = InvoiceTransaction
fields = ['invoice', 'work_order', 'invoice_number', 'amount', 'payment_date', 'is_active']
read_only_fields = ['id', 'created_at', 'updated_at']

View File

@@ -0,0 +1,77 @@
from django.utils import timezone
from modules.invoicing.models import Invoice, InvoiceItem
from modules.invoicing.utils import generate_invoice_number
from django.db import transaction
from django.contrib.auth import get_user_model
from rest_framework.exceptions import ValidationError
import logging
from decimal import Decimal
from modules.invoicing import tasks # premještamo ovdje ako treba
logger = logging.getLogger(__name__)
class InvoiceService:
@staticmethod
def create_full_invoice(user=None, **validated_data):
# 1. Obavezno dohvati stavke
items_data = validated_data.pop('items', [])
fields_to_remove = ['amount', 'creator', 'invoice_number']
for field in fields_to_remove:
validated_data.pop(field, None)
# 2. Sigurna provjera korisnika
if not user:
User = get_user_model()
try:
user = User.objects.get(username="paperless_bot")
except User.DoesNotExist:
raise ValidationError("Sistemski korisnik 'paperless_bot' ne postoji.")
with transaction.atomic():
# 3. Računanje iznosa (sigurno rukovanje s items_data)
total = sum(Decimal(item['quantity']) * Decimal(item['unit_price']) for item in items_data)
# 4. Kreiraj fakturu
invoice = Invoice.objects.create(
**validated_data,
amount=total,
creator=user,
invoice_number=generate_invoice_number()
)
# 5. Kreiraj stavke
for item_data in items_data:
InvoiceItem.objects.create(invoice=invoice, **item_data)
# 6. Pokreni task nakon commit-a
transaction.on_commit(lambda: tasks.process_paperless_document_task.delay(invoice.id))
return invoice
@staticmethod
def create_invoice_entry(data, user=None):
"""
Kreira financijski zapis (transaction) na temelju validated data.
Očekuje se da data['invoice'] može biti instanca ili PK.
"""
from modules.invoicing.models import InvoiceTransaction, Invoice
invoice = data.get("invoice")
if isinstance(invoice, int):
invoice = Invoice.objects.get(pk=invoice)
elif hasattr(invoice, "pk") is False:
raise ValidationError("Nevažeći invoice.")
amount = data.get("amount")
payment_date = data.get("payment_date", None)
work_order = data.get("work_order")
tx = InvoiceTransaction.objects.create(
invoice=invoice,
work_order=work_order,
invoice_number=invoice.invoice_number,
amount=amount,
payment_date=payment_date or timezone.now()
)
return tx

View File

@@ -0,0 +1,81 @@
import uuid
from celery import shared_task
from datetime import date, timedelta
from infrastructure.paperless_client import PaperlessGateway
from core.utils import format_currency, parse_iso_date
from django.contrib.auth import get_user_model
import logging
from decimal import Decimal
from modules.crm.models import Client
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3)
def process_paperless_document_task(self, document_id):
"""
Celery zadatak koji povezuje infrastrukturu i poslovnu logiku.
"""
gateway = PaperlessGateway() # rename so we don't overwrite 'client'
try:
User = get_user_model()
# 1. Dohvat iz infrastrukture
raw_data = gateway.get_document_metadata(document_id)
raw_created = parse_iso_date(raw_data.get("created"))
correspondent_name = raw_data.get("correspondent")
# pokušaj dohvatiti crm klijenta
try:
crm_client = Client.objects.get(name=correspondent_name)
except Client.DoesNotExist:
logger.error(f"Klijent {correspondent_name} nije pronađen!")
# Kreiramo fallback klijenta ali popunimo UNIQUE polja jedinstvenim vrijednostima
unique_email = f"client+{uuid.uuid4()}@example.test"
unique_tax_id = str(uuid.uuid4())
crm_client, created = Client.objects.get_or_create(
name=correspondent_name,
defaults={
"email": unique_email,
"tax_id": unique_tax_id,
}
)
if created:
logger.info(f"Fallback klijent kreiran za '{correspondent_name}' (id={crm_client.id}).")
# 2. Transformacija i čišćenje podataka
total_raw = raw_data.get("total_amount", "0")
try:
amount_decimal = Decimal(str(total_raw))
except Exception:
amount_decimal = Decimal("0")
processed_data = {
"client": crm_client,
"invoice_number": raw_data.get("invoice_number") or raw_data.get("correspondent"),
"due_date": (raw_created or date.today()) + timedelta(days=30),
"amount": amount_decimal,
"created_at": raw_created,
}
from modules.invoicing.services import InvoiceService
lookup_field = User.USERNAME_FIELD
system_user = User.objects.filter(**{lookup_field: "bot@erp.hr"}).first()
if not system_user:
system_user = User(**{lookup_field: "bot@erp.hr"})
system_user.email = "bot@erp.hr"
system_user.first_name = "Paperless"
system_user.last_name = "Bot"
system_user.set_unusable_password()
system_user.save()
# Pozovi servis uvijek nakon što garantujemo da system_user postoji
InvoiceService.create_full_invoice(user=system_user, **processed_data)
logger.info(f"Dokument {document_id} uspješno obrađen.")
except Exception as exc:
logger.error(f"Greška pri obradi dokumenta {document_id}: {exc}")
# Automatski retry ako padne
raise self.retry(exc=exc, countdown=60)

View File

@@ -0,0 +1,112 @@
# backend/modules/invoicing/tests/conftest.py
import pytest
from django.contrib.auth import get_user_model
from modules.invoicing.models import Invoice
from unittest.mock import MagicMock
from django.conf import settings
from core.celery import app as celery_app
import modules.invoicing.tasks as tasks
import uuid
User = get_user_model()
@pytest.fixture
def user():
# Dodan username
return User.objects.create_user(email="test@test.hr", password="password")
@pytest.fixture
def api_client(user):
from rest_framework.test import APIClient
client = APIClient()
client.force_authenticate(user=user)
return client
@pytest.fixture
def staff_user():
return User.objects.create_superuser(email='admin@erp.hr', password='password', first_name='Admin', last_name='User')
@pytest.fixture
def regular_user():
return User.objects.create_user(email='user@erp.hr', password='password', first_name='User', last_name='User')
@pytest.fixture
def serviser_a():
return User.objects.create_user(email="a@test.hr", password="password", first_name='A', last_name='User')
@pytest.fixture
def serviser_b():
return User.objects.create_user(email="b@test.hr", password="password", first_name='B', last_name='User')
@pytest.fixture
def test_client():
from modules.crm.models import Client
return Client.objects.create(name="Testni Klijent", tax_id="12345678901")
@pytest.fixture
def invoice_a(serviser_a, test_client): # Dodali smo test_client
from modules.invoicing.utils import generate_invoice_number
return Invoice.objects.create(
invoice_number=generate_invoice_number(),
creator=serviser_a,
client=test_client, # OVDJE JE BIO PROBLEM (bio je null)
amount=100.00,
due_date="2026-12-31"
)
@pytest.fixture(autouse=True)
def paperless_settings(settings):
# Postavi privremene vrijednosti za vrijeme trajanja testa
settings.PAPERLESS_API_URL = "http://mock-paperless.local"
settings.PAPERLESS_API_TOKEN = "mock-token"
@pytest.fixture(autouse=True)
def force_celery_eager(settings):
settings.CELERY_TASK_ALWAYS_EAGER = True
settings.CELERY_TASK_EAGER_PROPAGATES = True
# Ovo je ključ: prisilno ažuriraj konfiguraciju Celery aplikacije
celery_app.conf.update(
task_always_eager=True,
task_eager_propagates=True,
)
@pytest.fixture(autouse=True)
def mock_paperless_gateway(monkeypatch):
"""
Koristi monkeypatch za zamjenu PaperlessGateway klase.
"""
mock_client = MagicMock()
mock_client.get_document_metadata.return_value = {
"correspondent": "INV-2026-TEST",
"total_amount": "100.00",
"created": "2026-07-01"
}
# Kreiramo tvornicu koja vraća naš mock
MockGateway = MagicMock(return_value=mock_client)
# Zamijeni klasu unutar tasks modula
monkeypatch.setattr(tasks, "PaperlessGateway", MockGateway)
yield MockGateway
@pytest.fixture
def mock_client(db):
"""Kreira klijenta kojeg koristi PaperlessGateway u testovima."""
from modules.crm.models import Client
return Client.objects.create(name="INV-2026-TEST") # Ime mora odgovarati mocku iz conftest.py
@pytest.fixture
def create_unique_client(db):
"""Factory funkcija za kreiranje unikatnog klijenta."""
from modules.crm.models import Client
def _create_client(name):
return Client.objects.create(
name=name,
tax_id=str(uuid.uuid4()), # Garantira unikatnost
email=f"{uuid.uuid4()}@test.hr" # Garantira unikatnost
)
return _create_client

View File

@@ -0,0 +1,46 @@
import uuid
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
from decimal import Decimal
from datetime import date
from modules.crm.models import Client
from modules.invoicing.models import Invoice
class InvoiceAPITests(TestCase):
def setUp(self):
self.api_client = APIClient()
User = get_user_model()
# Kreiraj test korisnika (ako treba autentikacija)
self.user = User.objects.create_user(
username=f"user_{uuid.uuid4().hex[:8]}",
email=f"user+{uuid.uuid4().hex[:8]}@example.test",
password="pass"
)
# Kreiraj jedinstvenog klijenta (email s uuid kako bismo izbjegli duplicate key)
self.client_obj = Client.objects.create(
name="API Test Client",
email=f"client+{uuid.uuid4()}@example.test"
)
# autentikacija ako je endpoint zaštićen
self.api_client.force_authenticate(user=self.user)
def test_api_creates_invoice_with_id(self):
url = reverse('invoice-list') # prilagodi ako je druga ruta
payload = {
"client": self.client_obj.id,
"due_date": date.today().isoformat(),
"items": [
{"description": "Test item", "quantity": "1.00", "unit_price": "10.00"}
]
}
response = self.api_client.post(url, payload, format='json')
assert response.status_code in (200, 201)
# provjeri da se faktura stvorila i da vraća id/kod
created = Invoice.objects.filter(client=self.client_obj).first()
assert created is not None
assert created.amount == Decimal("10.00")

View File

@@ -0,0 +1,18 @@
import pytest
from modules.invoicing.models import Invoice, InvoiceItem
@pytest.mark.django_db
def test_invoice_str_representation(invoice_a):
assert str(invoice_a) == f"{invoice_a.invoice_number} - {invoice_a.client}"
@pytest.mark.django_db
def test_invoice_total_calculation(invoice_a):
InvoiceItem.objects.create(invoice=invoice_a, description="Test", quantity=2, unit_price=50.00)
InvoiceItem.objects.create(invoice=invoice_a, description="Test2", quantity=1, unit_price=25.00)
assert invoice_a.get_total_amount() == 125.00
@pytest.mark.django_db
def test_invoice_number_autogenerate():
# Testiramo da li se automatski generira ako nije zadan
from modules.invoicing.models import Invoice
# (Za ovo ti treba 'creator' i 'client' fixture-i)

View File

@@ -0,0 +1,10 @@
import pytest
from rest_framework import status
@pytest.mark.django_db
def test_invoice_access_restriction(api_client, regular_user, invoice_a):
# invoice_a pripada serviseru_a, a api_client je regular_user
url = f'/api/invoicing/invoices/{invoice_a.id}/'
response = api_client.get(url)
# Trebao bi dobiti 404 ili 403 ovisno o tvojoj logici
assert response.status_code in [status.HTTP_404_NOT_FOUND, status.HTTP_403_FORBIDDEN]

View File

@@ -0,0 +1,22 @@
import pytest
from modules.invoicing.serializers import InvoiceSerializer
from unittest.mock import MagicMock
@pytest.mark.django_db
def test_invoice_serializer_valid(regular_user, test_client):
data = {
"client": test_client.id,
"due_date": "2026-12-31",
"status": "draft",
"items": [
{"description": "Servis", "quantity": 1, "unit_price": 100.00}
]
}
# Context je ključan za pristup useru
request = MagicMock()
request.user = regular_user
serializer = InvoiceSerializer(data=data, context={'request': request})
assert serializer.is_valid(), serializer.errors
invoice = serializer.save()
assert invoice.amount == 100.00

View File

@@ -0,0 +1,40 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from datetime import date
from decimal import Decimal
from modules.crm.models import Client
from modules.invoicing.models import Invoice, InvoiceItem
from modules.invoicing.services import InvoiceService
class InvoiceServiceTests(TestCase):
def setUp(self):
User = get_user_model()
self.creator = User.objects.create_user(username="creator", email="c@example.com", password="pass")
self.client = Client.objects.create(name="Test Client")
def test_create_full_invoice_creates_invoice_and_items(self):
items = [
{"description": "Item A", "quantity": Decimal("2.00"), "unit_price": Decimal("10.00")},
{"description": "Item B", "quantity": Decimal("1.00"), "unit_price": Decimal("5.50")},
]
validated_data = {
"client": self.client,
"due_date": date.today(),
"items": items,
}
invoice = InvoiceService.create_full_invoice(user=self.creator, **validated_data)
# Basic assertions
self.assertIsInstance(invoice, Invoice)
self.assertEqual(invoice.creator, self.creator)
self.assertEqual(invoice.client, self.client)
self.assertEqual(invoice.items.count(), 2)
# Total should be 2*10 + 1*5.5 = 25.5
self.assertEqual(invoice.amount, Decimal("25.50"))
item_descriptions = [i.description for i in invoice.items.all()]
self.assertIn("Item A", item_descriptions)
self.assertIn("Item B", item_descriptions)

View File

@@ -0,0 +1,40 @@
import uuid
from django.test import TestCase
from unittest.mock import patch
from datetime import datetime
from django.contrib.auth import get_user_model
from modules.crm.models import Client
from modules.invoicing import tasks
class TasksTests(TestCase):
def setUp(self):
# Kreiramo klijenta koji vraća PaperlessGateway; email jedinstven radi izbjegavanja duplikata
self.client = Client.objects.create(
name="Paperless Corp",
email=f"paperless+{uuid.uuid4()}@example.test"
)
User = get_user_model()
# Kreiramo sistemskog korisnika koji već postoji (trebao bi se koristiti)
self.system_user = User.objects.create_user(
username="bot@erp.hr",
email="bot@erp.hr",
password="pass"
)
@patch("modules.invoicing.tasks.PaperlessGateway.get_document_metadata")
@patch("modules.invoicing.tasks.InvoiceService.create_full_invoice")
def test_process_paperless_document_calls_service(self, mock_create_full_invoice, mock_get_metadata):
# simuliramo odgovor infrastrukture
mock_get_metadata.return_value = {
"created": "2026-01-01T00:00:00Z",
"correspondent": "Paperless Corp",
"total_amount": "100.00"
}
# Pozovemo task sinkrono pomoću .apply (ispravno mapira argumente)
tasks.process_paperless_document_task.apply(args=(123,))
# Očekujemo da je pozvan servis za izradu fakture
mock_create_full_invoice.assert_called()

View File

@@ -0,0 +1,28 @@
from django.test import TestCase
from datetime import date
from decimal import Decimal
from django.contrib.auth import get_user_model
from modules.invoicing.models import Invoice
from modules.invoicing.utils import generate_invoice_number
from modules.crm.models import Client
class UtilsTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="creator", email="c@example.com", password="pass")
self.client = Client.objects.create(name="Client Utils")
def test_generate_invoice_number_increments(self):
year = date.today().year
Invoice.objects.create(
invoice_number=f"INV-{year}-0001",
creator=self.user,
client=self.client,
amount=Decimal("1.00"),
due_date=date.today()
)
next_num = generate_invoice_number()
# očekujemo INV-<year>-0002
self.assertTrue(next_num.endswith("-0002"))

View File

@@ -0,0 +1,46 @@
from django.test import TestCase, RequestFactory
from django.contrib.auth import get_user_model
from unittest.mock import patch
from modules.invoicing.views import InvoiceTransactionEntryViewSet
from modules.invoicing.serializers import InvoiceTransactionSerializer
from modules.invoicing.models import InvoiceTransaction
from modules.invoicing.models import Invoice
from modules.crm.models import Client
from datetime import date
from decimal import Decimal
class ViewsTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username="u1", email="u1@example.com", password="pass")
self.client = Client.objects.create(name="Client Views")
self.invoice = Invoice.objects.create(
invoice_number="INV-TEST-0001",
creator=self.user,
client=self.client,
amount=Decimal("10.00"),
due_date=date.today()
)
self.factory = RequestFactory()
self.viewset = InvoiceTransactionEntryViewSet()
@patch("modules.invoicing.services.InvoiceService.create_invoice_entry")
def test_perform_create_calls_service(self, mock_create_invoice_entry):
# simuliramo serializer.validated_data i poziv perform_create
serializer = InvoiceTransactionSerializer(data={
"invoice": self.invoice.id,
"invoice_number": self.invoice.invoice_number,
"amount": "5.00",
"is_active": True
}, context={"request": self.factory.get("/")})
serializer.is_valid(raise_exception=True)
# Poziv perform_create koji bi trebao delegirati na servis
self.viewset.request = self.factory.post("/")
self.viewset.request.user = self.user
# Ako kod poziva InvoiceService.create_invoice_entry, mock se aktivira.
self.viewset.perform_create(serializer)
mock_create_invoice_entry.assert_called()

View File

@@ -0,0 +1,8 @@
from rest_framework.routers import DefaultRouter
from .views import InvoiceViewSet, InvoiceTransactionEntryViewSet
router = DefaultRouter()
router.register(r'invoices', InvoiceViewSet, basename='invoice')
router.register(r'transaction', InvoiceTransactionEntryViewSet, basename='financial-transaction-entry')
urlpatterns = router.urls

View File

@@ -0,0 +1,26 @@
import datetime
from django.apps import apps
from django.db import transaction
def generate_invoice_number():
"""
Generira novi broj fakture u formatu INV-YYYY-XXXX.
Koristi transakciju kako bi se spriječilo dupliciranje brojeva.
"""
with transaction.atomic():
Invoice = apps.get_model('invoicing', 'Invoice')
current_year = datetime.date.today().year
# Zaključavamo tablicu za čitanje kako bismo izbjegli Race Condition
last_invoice = Invoice.objects.filter(
invoice_number__startswith=f"INV-{current_year}-"
).select_for_update().order_by('-invoice_number').first()
if last_invoice:
last_num = int(last_invoice.invoice_number.split('-')[-1])
new_num = last_num + 1
else:
new_num = 1
return f"INV-{current_year}-{new_num:04d}"

View File

@@ -0,0 +1,75 @@
# backend/modules/invoicing/views.py
import logging
from rest_framework import viewsets, permissions, status
from rest_framework.response import Response
from rest_framework.exceptions import ValidationError
from .models import InvoiceTransaction, Invoice
from .serializers import InvoiceSerializer, InvoiceTransactionSerializer
from .services import InvoiceService
# Postavljanje loggera za modul
logger = logging.getLogger(__name__)
class InvoiceViewSet(viewsets.ModelViewSet):
queryset = Invoice.objects.all().select_related('client', 'creator')
serializer_class = InvoiceSerializer
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
validated_data = serializer.validated_data
# 2. Delegiraj SVE servisu
from .services import InvoiceService
invoice_instance = InvoiceService.create_full_invoice(
user=self.request.user,
**validated_data
)
serializer.instance = invoice_instance
def get_queryset(self):
"""
Filtriraj samo aktivne zapise
"""
# Korisnik vidi samo fakture koje je on kreirao
# (ili koje su povezane s njegovim profilom, ako si klijent)
if self.request.user.is_staff:
return Invoice.objects.all()
return Invoice.objects.filter(creator=self.request.user, is_active=True)
class InvoiceTransactionEntryViewSet(viewsets.ModelViewSet):
"""
ViewSet za upravljanje financijskim zapisima.
Korištenjem InvoiceService-a, ViewSet ostaje lagan i fokusiran samo na HTTP sloj.
"""
queryset = InvoiceTransaction.objects.filter(is_active=True).select_related('invoice', 'work_order').order_by('-created_at')
serializer_class = InvoiceTransactionSerializer
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
"""
Presrećemo proces kreiranja kako bismo koristili InvoiceService.
"""
try:
# Proslijeđujemo validirane podatke i korisnika u servis
InvoiceService.create_invoice_entry(
data=serializer.validated_data,
user=self.request.user
)
except ValidationError as e:
# Ponovno podižemo iznimku kako bi DRF ispravno vratio 400 Bad Request
logger.warning(f"Validacijska greška pri kreiranju računa od strane {self.request.user}: {e}")
raise e
except Exception as e:
logger.error(f"Kritična greška pri kreiranju financijskog zapisa: {str(e)}", exc_info=True)
raise ValidationError({"error": "Došlo je do greške prilikom spremanja zapisa."})
def perform_destroy(self, instance):
"""
Umjesto fizičkog brisanja, radimo soft-delete ako je potrebno.
"""
instance.is_active = False
instance.save()
logger.info(f"Financijski zapis {instance.id} je deaktiviran od strane {self.request.user}")