diff --git a/backend/api/v1/urls.py b/backend/api/v1/urls.py index 78baa6e8..88ee5116 100644 --- a/backend/api/v1/urls.py +++ b/backend/api/v1/urls.py @@ -81,6 +81,8 @@ urlpatterns = [ ), # Notifications API path("notifications/", include("api.v1.notifications.urls")), + # Procurement API + path("procurement/", include("api.v1.procurement.urls")), # Router URLs (включая все ViewSets) path("", include(router.urls)), ] diff --git a/backend/employees/migrations/0040_merge_20260227_1639.py b/backend/employees/migrations/0040_merge_20260227_1639.py new file mode 100644 index 00000000..1b9bc50b --- /dev/null +++ b/backend/employees/migrations/0040_merge_20260227_1639.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.4 on 2026-02-27 13:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0035_add_manage_equipment_permission'), + ('employees', '0039_employee_employees_e_usernam_4d3ddc_idx'), + ] + + operations = [ + ] diff --git a/backend/notifications/migrations/0007_merge_20260227_1639.py b/backend/notifications/migrations/0007_merge_20260227_1639.py new file mode 100644 index 00000000..b9ac4cff --- /dev/null +++ b/backend/notifications/migrations/0007_merge_20260227_1639.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.4 on 2026-02-27 13:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0005_procurement_notification_types'), + ('notifications', '0006_add_device_name'), + ] + + operations = [ + ] diff --git a/backend/test_corporate_ip.py b/backend/test_corporate_ip.py deleted file mode 100644 index 14e82f5b..00000000 --- a/backend/test_corporate_ip.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python -"""Проверка разрешения IP для корпоративной сети 172.11.0.0/16""" -import os -import sys - -sys.path.insert(0, os.path.dirname(__file__)) -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'eusrr_backend.settings') - -import django -django.setup() - -from common.ip_restrictions import is_ip_allowed - -print("\n" + "=" * 60) -print("Проверка IP-адресов для корпоративной сети 172.11.0.0/16") -print("=" * 60) - -test_ips = [ - ('172.11.0.117', True, 'Ваш текущий IP'), - ('172.11.0.1', True, 'Корпоративная сеть 172.11.x.x'), - ('172.11.255.255', True, 'Край диапазона 172.11.x.x'), - ('172.12.0.1', False, 'За пределами 172.11.x.x'), - ('172.16.0.1', True, 'Стандартная приватная сеть'), - ('192.168.1.1', True, 'Локальная сеть'), - ('10.0.0.1', True, 'Приватная сеть класса A'), - ('8.8.8.8', False, 'Публичный IP (Google DNS)'), -] - -passed = 0 -failed = 0 - -for ip, expected, description in test_ips: - result = is_ip_allowed(ip) - status = "✅" if result == expected else "❌" - - if result == expected: - passed += 1 - else: - failed += 1 - - result_text = "разрешен" if result else "заблокирован" - expected_text = "разрешен" if expected else "заблокирован" - - print(f"{status} {ip:20} -> {result_text:12} " - f"(ожидалось: {expected_text:12}) # {description}") - -print("\n" + "=" * 60) -if failed == 0: - print(f"✅ Все тесты пройдены: {passed}/{passed + failed}") -else: - print(f"⚠️ Тестов пройдено: {passed}/{passed + failed}, провалено: {failed}") -print("=" * 60 + "\n") diff --git a/backend/test_ip_restrictions.py b/backend/test_ip_restrictions.py deleted file mode 100644 index 8b190a0a..00000000 --- a/backend/test_ip_restrictions.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python -""" -Тестирование IP-ограничений для регистрации. - -Запуск: - python backend/test_ip_restrictions.py -""" -import os -import sys -import django - -# Настройка Django -sys.path.insert(0, os.path.join(os.path.dirname(__file__))) -os.environ.setdefault("DJANGO_SECRET_KEY", "test-key") -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings") -django.setup() - -from common.ip_restrictions import ( - get_client_ip, - is_local_ip, - is_ip_allowed, -) - - -def test_is_local_ip(): - """Тест проверки локальных IP.""" - print("\n=== Тест: is_local_ip ===") - - test_cases = [ - ('127.0.0.1', True, 'localhost IPv4'), - ('127.0.0.100', True, 'localhost range'), - ('192.168.1.1', True, 'private class C'), - ('192.168.100.50', True, 'private class C'), - ('10.0.0.1', True, 'private class A'), - ('172.16.0.1', True, 'private class B'), - ('172.31.255.255', True, 'private class B edge'), - ('8.8.8.8', False, 'public Google DNS'), - ('203.0.113.50', False, 'public TEST-NET'), - ('::1', True, 'localhost IPv6'), - ('invalid-ip', False, 'invalid IP'), - ] - - passed = 0 - failed = 0 - - for ip, expected, description in test_cases: - result = is_local_ip(ip) - status = "✅" if result == expected else "❌" - - if result == expected: - passed += 1 - else: - failed += 1 - - print(f"{status} {ip:20} -> {result:5} (expected: {expected:5}) # {description}") - - print(f"\nПройдено: {passed}/{passed + failed}") - return failed == 0 - - -def test_is_ip_allowed(): - """Тест проверки разрешенных IP с кастомными настройками.""" - print("\n=== Тест: is_ip_allowed ===") - - # Тест 1: Разрешить все - print("\n1. Разрешить все IP ['*']:") - result = is_ip_allowed('203.0.113.50', ['*']) - print(f" {'✅' if result else '❌'} 203.0.113.50 -> {result} (expected: True)") - - # Тест 2: Разрешить конкретную сеть - print("\n2. Разрешить сеть 192.168.1.0/24:") - allowed_net = ['192.168.1.0/24'] - tests = [ - ('192.168.1.1', True), - ('192.168.1.100', True), - ('192.168.1.255', True), - ('192.168.2.1', False), - ('10.0.0.1', False), - ] - - for ip, expected in tests: - result = is_ip_allowed(ip, allowed_net) - status = "✅" if result == expected else "❌" - print(f" {status} {ip:20} -> {result:5} (expected: {expected:5})") - - # Тест 3: Разрешить конкретные IP - print("\n3. Разрешить конкретные IP ['192.168.1.100', '10.0.0.5']:") - allowed_ips = ['192.168.1.100', '10.0.0.5'] - tests = [ - ('192.168.1.100', True), - ('10.0.0.5', True), - ('192.168.1.101', False), - ('10.0.0.6', False), - ] - - for ip, expected in tests: - result = is_ip_allowed(ip, allowed_ips) - status = "✅" if result == expected else "❌" - print(f" {status} {ip:20} -> {result:5} (expected: {expected:5})") - - # Тест 4: По умолчанию (только локальные) - print("\n4. По умолчанию (None = только локальные IP):") - tests = [ - ('127.0.0.1', True), - ('192.168.1.1', True), - ('10.0.0.1', True), - ('8.8.8.8', False), - ('203.0.113.50', False), - ] - - for ip, expected in tests: - result = is_ip_allowed(ip, None) - status = "✅" if result == expected else "❌" - print(f" {status} {ip:20} -> {result:5} (expected: {expected:5})") - - -def test_get_client_ip(): - """Тест получения IP из request.""" - print("\n=== Тест: get_client_ip ===") - - from django.test import RequestFactory - - factory = RequestFactory() - - # Тест 1: Обычный запрос - print("\n1. Обычный запрос (без прокси):") - request = factory.get('/') - ip = get_client_ip(request) - print(f" IP: {ip}") - - # Тест 2: С заголовком X-Forwarded-For - print("\n2. Запрос через прокси (X-Forwarded-For):") - request = factory.get('/', HTTP_X_FORWARDED_FOR='203.0.113.50, 192.168.1.1') - ip = get_client_ip(request) - expected = '203.0.113.50' - status = "✅" if ip == expected else "❌" - print(f" {status} IP: {ip} (expected: {expected})") - - # Тест 3: С одним IP в X-Forwarded-For - print("\n3. Запрос через прокси (один IP):") - request = factory.get('/', HTTP_X_FORWARDED_FOR='10.0.0.5') - ip = get_client_ip(request) - expected = '10.0.0.5' - status = "✅" if ip == expected else "❌" - print(f" {status} IP: {ip} (expected: {expected})") - - -def main(): - """Запуск всех тестов.""" - print("=" * 60) - print("Тестирование модуля IP-ограничений") - print("=" * 60) - - try: - test_is_local_ip() - test_is_ip_allowed() - test_get_client_ip() - - print("\n" + "=" * 60) - print("✅ Все тесты выполнены") - print("=" * 60) - - except Exception as e: - print(f"\n❌ Ошибка: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/backend/test_ip_simple.py b/backend/test_ip_simple.py deleted file mode 100644 index 4691c53a..00000000 --- a/backend/test_ip_simple.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env python -""" -Простой тест IP-ограничений (без Django). -""" -import ipaddress - - -def is_local_ip(ip_address: str) -> bool: - """Проверяет, является ли IP-адрес локальным.""" - try: - ip = ipaddress.ip_address(ip_address) - return ip.is_private or ip.is_loopback or ip.is_link_local - except ValueError: - return False - - -def is_ip_allowed(ip_address: str, allowed_networks=None): - """Проверяет, разрешен ли доступ с данного IP-адреса.""" - if allowed_networks is None: - return is_local_ip(ip_address) - - if not allowed_networks: - return False - - if allowed_networks == ['*'] or '*' in allowed_networks: - return True - - try: - ip = ipaddress.ip_address(ip_address) - - for network_str in allowed_networks: - if '/' in network_str: - network = ipaddress.ip_network(network_str, strict=False) - if ip in network: - return True - else: - allowed_ip = ipaddress.ip_address(network_str) - if ip == allowed_ip: - return True - - return False - except ValueError: - return False - - -def test_is_local_ip(): - """Тест проверки локальных IP.""" - print("\n=== Тест: is_local_ip ===") - - test_cases = [ - ('127.0.0.1', True, 'localhost IPv4'), - ('127.0.0.100', True, 'localhost range'), - ('192.168.1.1', True, 'private class C'), - ('192.168.100.50', True, 'private class C'), - ('10.0.0.1', True, 'private class A'), - ('172.16.0.1', True, 'private class B'), - ('172.31.255.255', True, 'private class B edge'), - ('8.8.8.8', False, 'public Google DNS'), - ('203.0.113.50', False, 'public TEST-NET'), - ('::1', True, 'localhost IPv6'), - ('invalid-ip', False, 'invalid IP'), - ] - - passed = 0 - failed = 0 - - for ip, expected, description in test_cases: - result = is_local_ip(ip) - status = "✅" if result == expected else "❌" - - if result == expected: - passed += 1 - else: - failed += 1 - - print(f"{status} {ip:20} -> {str(result):5} " - f"(expected: {str(expected):5}) # {description}") - - print(f"\nПройдено: {passed}/{passed + failed}") - return failed == 0 - - -def test_is_ip_allowed(): - """Тест проверки разрешенных IP с кастомными настройками.""" - print("\n=== Тест: is_ip_allowed ===") - - # Тест 1: Разрешить все - print("\n1. Разрешить все IP ['*']:") - result = is_ip_allowed('203.0.113.50', ['*']) - status = '✅' if result else '❌' - print(f" {status} 203.0.113.50 -> {result} (expected: True)") - - # Тест 2: Разрешить конкретную сеть - print("\n2. Разрешить сеть 192.168.1.0/24:") - allowed_net = ['192.168.1.0/24'] - tests = [ - ('192.168.1.1', True), - ('192.168.1.100', True), - ('192.168.1.255', True), - ('192.168.2.1', False), - ('10.0.0.1', False), - ] - - for ip, expected in tests: - result = is_ip_allowed(ip, allowed_net) - status = "✅" if result == expected else "❌" - print(f" {status} {ip:20} -> {str(result):5} " - f"(expected: {str(expected):5})") - - # Тест 3: Разрешить конкретные IP - print("\n3. Разрешить IP ['192.168.1.100', '10.0.0.5']:") - allowed_ips = ['192.168.1.100', '10.0.0.5'] - tests = [ - ('192.168.1.100', True), - ('10.0.0.5', True), - ('192.168.1.101', False), - ('10.0.0.6', False), - ] - - for ip, expected in tests: - result = is_ip_allowed(ip, allowed_ips) - status = "✅" if result == expected else "❌" - print(f" {status} {ip:20} -> {str(result):5} " - f"(expected: {str(expected):5})") - - # Тест 4: По умолчанию (только локальные) - print("\n4. По умолчанию (None = только локальные IP):") - tests = [ - ('127.0.0.1', True), - ('192.168.1.1', True), - ('10.0.0.1', True), - ('8.8.8.8', False), - ('203.0.113.50', False), - ] - - for ip, expected in tests: - result = is_ip_allowed(ip, None) - status = "✅" if result == expected else "❌" - print(f" {status} {ip:20} -> {str(result):5} " - f"(expected: {str(expected):5})") - - -def main(): - """Запуск всех тестов.""" - print("=" * 60) - print("Тестирование модуля IP-ограничений") - print("=" * 60) - - try: - test_is_local_ip() - test_is_ip_allowed() - - print("\n" + "=" * 60) - print("✅ Все тесты выполнены успешно") - print("=" * 60) - - except Exception as e: - print(f"\n❌ Ошибка: {e}") - import traceback - traceback.print_exc() - return 1 - - return 0 - - -if __name__ == "__main__": - exit(main()) diff --git a/backend/test_patch_import.py b/backend/test_patch_import.py deleted file mode 100644 index ac6363a7..00000000 --- a/backend/test_patch_import.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python -"""Проверка импорта и применения патча.""" -import os -import sys -import django - -# Setup Django -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "eusrr_backend.settings") - -print("\n" + "=" * 80) -print("1. ЗАПУСК DJANGO") -print("=" * 80) -django.setup() - -print("\n" + "=" * 80) -print("2. ИМПОРТ Event БЕЗ ПАТЧА") -print("=" * 80) -from schedule.models import Event -print(f"Event._event_params: {Event._event_params}") -print(f"Имя метода: {Event._event_params.__name__}") - -print("\n" + "=" * 80) -print("3. ИМПОРТ ПАТЧА") -print("=" * 80) -import schedule_patch # noqa - -print("\n" + "=" * 80) -print("4. ПРОВЕРКА Event ПОСЛЕ ПАТЧА") -print("=" * 80) -print(f"Event._event_params: {Event._event_params}") -print(f"Имя метода: {Event._event_params.__name__}") - -# Проверим на реальном событии -print("\n" + "=" * 80) -print("5. ТЕСТ НА РЕАЛЬНОМ СОБЫТИИ") -print("=" * 80) -event = Event.objects.filter(title__icontains='ыфвыфыф').first() -if event: - print(f"Event ID: {event.id}") - result = event._event_params() - print(f"_event_params() result: {result}") - if 'byweekday' in result: - print(f"byweekday = {result['byweekday']}") - if len(result['byweekday']) > 1: - print("✅ ПАТЧ РАБОТАЕТ - несколько дней сохранены!") - else: - print("❌ ПАТЧ НЕ РАБОТАЕТ - только один день!") -else: - print("Событие не найдено") diff --git a/backend/tests/api/v1/procurement/__init__.py b/backend/tests/api/v1/procurement/__init__.py new file mode 100644 index 00000000..81b08420 --- /dev/null +++ b/backend/tests/api/v1/procurement/__init__.py @@ -0,0 +1 @@ +"""Тесты API v1 для модуля закупок.""" diff --git a/backend/tests/api/v1/procurement/conftest.py b/backend/tests/api/v1/procurement/conftest.py new file mode 100644 index 00000000..59759189 --- /dev/null +++ b/backend/tests/api/v1/procurement/conftest.py @@ -0,0 +1,203 @@ +""" +Общие fixtures для тестов API модуля procurement. +""" + +import pytest +from decimal import Decimal +from datetime import date + +from employees.models import Department, Employee, EmployeeDepartment +from procurement.models import ( + Budget, + Equipment, + EquipmentCategory, + ProcurementItem, + ProcurementRequest, + Supplier, +) +from procurement.constants import ( + ProcurementStatus, + UrgencyLevel, + EquipmentStatus, +) + + +# ============================================================================== +# ПОЛЬЗОВАТЕЛИ +# ============================================================================== + + +@pytest.fixture +def user_factory(db): + """Фабрика для создания пользователей.""" + def _create_user(email, **kwargs): + defaults = { + "password": "testpass123", + "phone_number": f"+7999{email[:7]}", + "first_name": "Тест", + "last_name": "Пользователь", + "send_activation_email": False, + } + defaults.update(kwargs) + return Employee.objects.create_user(email=email, **defaults) + + return _create_user + + +@pytest.fixture +def link_factory(db): + """Фабрика для связи пользователя с отделом.""" + def _link(employee, department, **kwargs): + defaults = {"is_active": True} + defaults.update(kwargs) + return EmployeeDepartment.objects.create( + employee=employee, + department=department, + **defaults + ) + + return _link + + +# ============================================================================== +# ОТДЕЛЫ И БЮДЖЕТЫ +# ============================================================================== + + +@pytest.fixture +def department_factory(db): + """Фабрика для создания отделов.""" + def _create_department(name="Тестовый отдел", **kwargs): + defaults = {"description": f"Описание {name}"} + defaults.update(kwargs) + return Department.objects.create(name=name, **defaults) + + return _create_department + + +@pytest.fixture +def budget_factory(db): + """Фабрика для создания бюджетов.""" + def _create_budget(department, year=2026, quarter=1, **kwargs): + defaults = { + "allocated_amount": Decimal("1000000.00"), + "spent_amount": Decimal("0.00"), + } + defaults.update(kwargs) + return Budget.objects.create( + department=department, + year=year, + quarter=quarter, + **defaults + ) + + return _create_budget + + +# ============================================================================== +# ЗАЯВКИ НА ЗАКУПКУ +# ============================================================================== + + +@pytest.fixture +def procurement_request_factory(db): + """Фабрика для создания заявок на закупку.""" + def _create_request(department, requestor, **kwargs): + defaults = { + "title": "Тестовая заявка", + "description": "Описание заявки", + "status": ProcurementStatus.DRAFT, + "urgency": UrgencyLevel.MEDIUM, + } + defaults.update(kwargs) + return ProcurementRequest.objects.create( + department=department, + requestor=requestor, + **defaults + ) + + return _create_request + + +@pytest.fixture +def procurement_item_factory(db): + """Фабрика для создания позиций в заявке.""" + def _create_item(request, **kwargs): + defaults = { + "name": "Тестовый товар", + "description": "Описание товара", + "quantity": 1, + "unit": "шт", + "estimated_unit_price": Decimal("10000.00"), + } + defaults.update(kwargs) + return ProcurementItem.objects.create( + request=request, + **defaults + ) + + return _create_item + + +# ============================================================================== +# ОБОРУДОВАНИЕ +# ============================================================================== + + +@pytest.fixture +def category_factory(db): + """Фабрика для создания категорий оборудования.""" + def _create_category(name="Тестовая категория", **kwargs): + defaults = { + "description": f"Описание {name}", + "icon": "bi-box", + } + defaults.update(kwargs) + return EquipmentCategory.objects.create(name=name, **defaults) + + return _create_category + + +@pytest.fixture +def equipment_factory(db): + """Фабрика для создания оборудования.""" + counter = {"value": 0} + + def _create_equipment(category, department, **kwargs): + counter["value"] += 1 + defaults = { + "name": f"Тестовое оборудование {counter['value']}", + "inventory_number": f"INV-TEST-{counter['value']:04d}", + "status": EquipmentStatus.AVAILABLE, + "purchase_date": date.today(), + "purchase_cost": Decimal("50000.00"), + } + defaults.update(kwargs) + return Equipment.objects.create( + category=category, + department=department, + **defaults + ) + + return _create_equipment + + +# ============================================================================== +# ПОСТАВЩИКИ +# ============================================================================== + + +@pytest.fixture +def supplier_factory(db): + """Фабрика для создания поставщиков.""" + def _create_supplier(name="Тестовый поставщик", **kwargs): + defaults = { + "contact_person": "Контактное лицо", + "phone": "+79991112233", + "email": "supplier@example.com", + "is_active": True, + } + defaults.update(kwargs) + return Supplier.objects.create(name=name, **defaults) + + return _create_supplier diff --git a/backend/tests/api/v1/procurement/test_budgets.py b/backend/tests/api/v1/procurement/test_budgets.py new file mode 100644 index 00000000..45cb8e56 --- /dev/null +++ b/backend/tests/api/v1/procurement/test_budgets.py @@ -0,0 +1,480 @@ +""" +Тесты API для бюджетов (Budget). +""" + +from decimal import Decimal + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from employees.models import Department, Employee, EmployeeDepartment +from procurement.models import Budget, ProcurementRequest +from procurement.constants import ProcurementStatus + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def api_client(): + """API клиент.""" + return APIClient() + + +@pytest.fixture +def department(db): + """Тестовый отдел.""" + return Department.objects.create( + name="IT отдел", + description="Отдел IT" + ) + + +@pytest.fixture +def user(db, department): + """Обычный пользователь.""" + user = Employee.objects.create_user( + email="user@example.com", + password="testpass123", + phone_number="+79991111111", + first_name="Иван", + last_name="Иванов", + send_activation_email=False, + ) + EmployeeDepartment.objects.create( + employee=user, + department=department, + is_active=True + ) + return user + + +@pytest.fixture +def finance_user(db): + """Пользователь с правами на управление бюджетами.""" + user = Employee.objects.create_user( + email="finance@example.com", + password="testpass123", + phone_number="+79992222222", + first_name="Финансист", + last_name="Финансов", + is_staff=True, + send_activation_email=False, + ) + # Даем право на управление бюджетами + from django.contrib.auth.models import Permission + perm = Permission.objects.get( + codename='change_budget', + content_type__app_label='procurement' + ) + user.user_permissions.add(perm) + return user + + +@pytest.fixture +def budget(db, department): + """Тестовый бюджет.""" + return Budget.objects.create( + department=department, + year=2026, + quarter=1, + allocated_amount=Decimal("1000000.00"), + spent_amount=Decimal("250000.00"), + ) + + +# ============================================================================== +# ТЕСТЫ СПИСКА БЮДЖЕТОВ +# ============================================================================== + + +class TestBudgetList: + """Тесты получения списка бюджетов.""" + + def test_list_unauthorized(self, api_client): + """Неавторизованный доступ запрещен.""" + url = reverse('api:v1:procurement:budget-list') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_budgets_as_staff( + self, api_client, finance_user, budget + ): + """Финансист видит все бюджеты.""" + api_client.force_authenticate(user=finance_user) + url = reverse('api:v1:procurement:budget-list') + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + results = response.data['results'] + assert len(results) >= 1 + assert 'remaining_amount' in results[0] + assert 'utilization_percentage' in results[0] + + def test_filter_by_year( + self, api_client, finance_user, department, budget + ): + """Фильтр по году.""" + # Создаем бюджет на другой год + Budget.objects.create( + department=department, + year=2025, + quarter=4, + allocated_amount=Decimal("800000.00"), + spent_amount=Decimal("600000.00"), + ) + + api_client.force_authenticate(user=finance_user) + url = reverse('api:v1:procurement:budget-list') + + response = api_client.get(url, {'year': 2026}) + assert response.status_code == status.HTTP_200_OK + results = response.data['results'] + assert all(r['year'] == 2026 for r in results) + + def test_filter_by_department( + self, api_client, finance_user, db, budget + ): + """Фильтр по отделу.""" + other_dept = Department.objects.create( + name="HR отдел", + description="Другой отдел" + ) + Budget.objects.create( + department=other_dept, + year=2026, + quarter=1, + allocated_amount=Decimal("500000.00"), + spent_amount=Decimal("0.00"), + ) + + api_client.force_authenticate(user=finance_user) + url = reverse('api:v1:procurement:budget-list') + + response = api_client.get(url, {'department': budget.department.id}) + assert response.status_code == status.HTTP_200_OK + results = response.data['results'] + assert all(r['department'] == budget.department.id for r in results) + + +# ============================================================================== +# ТЕСТЫ СОЗДАНИЯ БЮДЖЕТА +# ============================================================================== + + +class TestBudgetCreate: + """Тесты создания бюджета.""" + + def test_create_budget_as_finance( + self, api_client, finance_user, department + ): + """Создание бюджета финансистом.""" + api_client.force_authenticate(user=finance_user) + url = reverse('api:v1:procurement:budget-list') + + data = { + 'department': department.id, + 'year': 2026, + 'quarter': 2, + 'allocated_amount': '1200000.00', + } + response = api_client.post(url, data, format='json') + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['year'] == 2026 + assert response.data['quarter'] == 2 + assert Decimal(response.data['allocated_amount']) == Decimal('1200000.00') + assert Decimal(response.data['spent_amount']) == Decimal('0.00') + + def test_create_budget_regular_user_forbidden( + self, api_client, user, department + ): + """Обычный пользователь не может создавать бюджеты.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:budget-list') + + data = { + 'department': department.id, + 'year': 2026, + 'quarter': 2, + 'allocated_amount': '1000000.00', + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_create_duplicate_budget_fails( + self, api_client, finance_user, budget + ): + """Нельзя создать дублирующий бюджет (отдел+год+квартал).""" + api_client.force_authenticate(user=finance_user) + url = reverse('api:v1:procurement:budget-list') + + data = { + 'department': budget.department.id, + 'year': budget.year, + 'quarter': budget.quarter, + 'allocated_amount': '2000000.00', + } + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +# ============================================================================== +# ТЕСТЫ ДЕТАЛЬНОЙ ИНФОРМАЦИИ +# ============================================================================== + + +class TestBudgetDetail: + """Тесты получения детальной информации о бюджете.""" + + def test_retrieve_budget( + self, api_client, finance_user, budget + ): + """Получение детальной информации.""" + api_client.force_authenticate(user=finance_user) + url = reverse( + 'api:v1:procurement:budget-detail', + kwargs={'pk': budget.id} + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['id'] == budget.id + assert Decimal(response.data['allocated_amount']) == budget.allocated_amount + assert Decimal(response.data['spent_amount']) == budget.spent_amount + + # Проверяем вычисляемые поля + assert 'remaining_amount' in response.data + assert 'utilization_percentage' in response.data + assert Decimal(response.data['remaining_amount']) == Decimal('750000.00') + + def test_retrieve_shows_reserved_amount( + self, api_client, finance_user, budget, user + ): + """Детальная информация показывает зарезервированные средства.""" + # Создаем pending заявку + pending_request = ProcurementRequest.objects.create( + title="Заявка", + description="Описание", + department=budget.department, + requestor=user, + status=ProcurementStatus.PENDING, + ) + from procurement.models import ProcurementItem + ProcurementItem.objects.create( + request=pending_request, + name="Товар", + quantity=1, + estimated_unit_price=Decimal("50000.00"), + ) + + api_client.force_authenticate(user=finance_user) + url = reverse( + 'api:v1:procurement:budget-detail', + kwargs={'pk': budget.id} + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + # Должно быть поле reserved_amount + assert 'reserved_amount' in response.data + + +# ============================================================================== +# ТЕСТЫ ОБНОВЛЕНИЯ БЮДЖЕТА +# ============================================================================== + + +class TestBudgetUpdate: + """Тесты обновления бюджета.""" + + def test_update_budget_as_finance( + self, api_client, finance_user, budget + ): + """Обновление бюджета финансистом.""" + api_client.force_authenticate(user=finance_user) + url = reverse( + 'api:v1:procurement:budget-detail', + kwargs={'pk': budget.id} + ) + + data = { + 'allocated_amount': '1500000.00', + 'spent_amount': '300000.00', + } + response = api_client.patch(url, data, format='json') + + assert response.status_code == status.HTTP_200_OK + assert Decimal(response.data['allocated_amount']) == Decimal('1500000.00') + assert Decimal(response.data['spent_amount']) == Decimal('300000.00') + + def test_update_budget_regular_user_forbidden( + self, api_client, user, budget + ): + """Обычный пользователь не может обновлять бюджеты.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:budget-detail', + kwargs={'pk': budget.id} + ) + + data = {'allocated_amount': '2000000.00'} + response = api_client.patch(url, data, format='json') + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ============================================================================== +# ТЕСТЫ СПЕЦИАЛЬНЫХ ACTIONS +# ============================================================================== + + +class TestBudgetActions: + """Тесты специальных действий с бюджетами.""" + + def test_current_budget( + self, api_client, finance_user, budget + ): + """Получение бюджета текущего квартала.""" + api_client.force_authenticate(user=finance_user) + url = reverse('api:v1:procurement:budget-current') + + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + # Должен вернуть список бюджетов на текущий квартал + assert isinstance(response.data, list) or 'results' in response.data + + def test_my_department_budget( + self, api_client, user, budget + ): + """Получение бюджета своего отдела.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:budget-my-department') + + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + # Пользователь видит бюджет своего отдела + if response.data: # Может быть пустой если нет бюджета + assert response.data[0]['department'] == budget.department.id + + +# ============================================================================== +# ТЕСТЫ БИЗНЕС-ЛОГИКИ +# ============================================================================== + + +class TestBudgetBusinessLogic: + """Тесты бизнес-логики бюджетов.""" + + def test_budget_utilization_calculation( + self, api_client, finance_user, budget + ): + """Проверка расчета процента использования.""" + api_client.force_authenticate(user=finance_user) + url = reverse( + 'api:v1:procurement:budget-detail', + kwargs={'pk': budget.id} + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + + # allocated = 1000000, spent = 250000 + # utilization = 250000 / 1000000 * 100 = 25% + utilization = Decimal(response.data['utilization_percentage']) + assert utilization == Decimal('25.00') + + def test_budget_remaining_amount( + self, api_client, finance_user, budget + ): + """Проверка расчета остатка.""" + api_client.force_authenticate(user=finance_user) + url = reverse( + 'api:v1:procurement:budget-detail', + kwargs={'pk': budget.id} + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + + # allocated = 1000000, spent = 250000 + # remaining = 750000 + remaining = Decimal(response.data['remaining_amount']) + assert remaining == Decimal('750000.00') + + def test_negative_budget_not_allowed( + self, api_client, finance_user, budget + ): + """Нельзя установить отрицательный бюджет.""" + api_client.force_authenticate(user=finance_user) + url = reverse( + 'api:v1:procurement:budget-detail', + kwargs={'pk': budget.id} + ) + + data = {'allocated_amount': '-100000.00'} + response = api_client.patch(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_spent_more_than_allocated_warning( + self, api_client, finance_user, budget + ): + """Можно потратить больше выделенного (с предупреждением).""" + # Обновляем потраченную сумму больше выделенной + budget.spent_amount = Decimal("1200000.00") + budget.save() + + api_client.force_authenticate(user=finance_user) + url = reverse( + 'api:v1:procurement:budget-detail', + kwargs={'pk': budget.id} + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + + # remaining должен быть отрицательным + remaining = Decimal(response.data['remaining_amount']) + assert remaining < 0 + + # utilization > 100% + utilization = Decimal(response.data['utilization_percentage']) + assert utilization > 100 + + +# ============================================================================== +# ТЕСТЫ УДАЛЕНИЯ +# ============================================================================== + + +class TestBudgetDelete: + """Тесты удаления бюджета.""" + + def test_delete_budget_as_finance( + self, api_client, finance_user, budget + ): + """Удаление бюджета финансистом.""" + api_client.force_authenticate(user=finance_user) + url = reverse( + 'api:v1:procurement:budget-detail', + kwargs={'pk': budget.id} + ) + + response = api_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Budget.objects.filter(id=budget.id).exists() + + def test_delete_budget_regular_user_forbidden( + self, api_client, user, budget + ): + """Обычный пользователь не может удалять бюджеты.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:budget-detail', + kwargs={'pk': budget.id} + ) + + response = api_client.delete(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Budget.objects.filter(id=budget.id).exists() diff --git a/backend/tests/api/v1/procurement/test_equipment.py b/backend/tests/api/v1/procurement/test_equipment.py new file mode 100644 index 00000000..65714dbc --- /dev/null +++ b/backend/tests/api/v1/procurement/test_equipment.py @@ -0,0 +1,473 @@ +""" +Тесты API для оборудования (Equipment). +""" + +from decimal import Decimal +from datetime import date, timedelta + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from employees.models import Department, Employee, EmployeeDepartment +from procurement.constants import EquipmentStatus +from procurement.models import Equipment, EquipmentCategory, MaintenanceRecord +from procurement.constants import MaintenanceType + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def api_client(): + """API клиент""" + return APIClient() + + +@pytest.fixture +def department(db): + """Тестовый отдел.""" + return Department.objects.create( + name="IT отдел", + description="Отдел IT" + ) + + +@pytest.fixture +def user(db, department): + """Обычный пользователь.""" + user = Employee.objects.create_user( + email="user@example.com", + password="testpass123", + phone_number="+79991111111", + first_name="Иван", + last_name="Иванов", + send_activation_email=False, + ) + EmployeeDepartment.objects.create( + employee=user, + department=department, + is_active=True + ) + return user + + +@pytest.fixture +def staff_user(db): + """Staff пользователь.""" + return Employee.objects.create_user( + email="staff@example.com", + password="testpass123", + phone_number="+79992222222", + first_name="Админ", + last_name="Админов", + is_staff=True, + send_activation_email=False, + ) + + +@pytest.fixture +def category(db): + """Категория оборудования.""" + return EquipmentCategory.objects.create( + name="Ноутбуки", + description="Портативные компьютеры", + icon="bi-laptop" + ) + + +@pytest.fixture +def equipment(db, category, department, user): + """Тестовое оборудование.""" + return Equipment.objects.create( + name="Ноутбук Dell Latitude 5530", + inventory_number="INV-2026-001", + serial_number="SN123456789", + category=category, + status=EquipmentStatus.AVAILABLE, + department=department, + responsible_person=user, + location="Офис 3.14", + purchase_date=date(2026, 1, 15), + warranty_until=date(2029, 1, 15), + purchase_cost=Decimal("95000.00"), + notes="Для разработки", + ) + + +# ============================================================================== +# ТЕСТЫ СПИСКА ОБОРУДОВАНИЯ +# ============================================================================== + + +class TestEquipmentList: + """Тесты получения списка оборудования.""" + + def test_list_unauthorized(self, api_client): + """Неавторизованный доступ запрещен.""" + url = reverse('api:v1:procurement:equipment-list') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_equipment( + self, api_client, user, equipment + ): + """Получение списка оборудования.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:equipment-list') + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + results = response.data['results'] + assert len(results) >= 1 + + # Проверяем структуру + item = results[0] + assert 'inventory_number' in item + assert 'name' in item + assert 'status' in item + assert 'category_name' in item + assert 'department_name' in item + + def test_filter_by_status( + self, api_client, user, category, department, equipment + ): + """Фильтр по статусу.""" + # Создаем оборудование с разными статусами + Equipment.objects.create( + name="Монитор", + inventory_number="INV-2026-002", + category=category, + status=EquipmentStatus.IN_USE, + department=department, + purchase_date=date.today(), + purchase_cost=Decimal("20000.00"), + ) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:equipment-list') + + # Фильтр AVAILABLE + response = api_client.get(url, {'status': EquipmentStatus.AVAILABLE}) + assert response.status_code == status.HTTP_200_OK + results = response.data['results'] + assert all(r['status'] == EquipmentStatus.AVAILABLE for r in results) + + def test_filter_by_department( + self, api_client, user, db, category, department, equipment + ): + """Фильтр по отделу.""" + other_dept = Department.objects.create( + name="HR отдел", + description="Другой отдел" + ) + Equipment.objects.create( + name="Принтер", + inventory_number="INV-2026-003", + category=category, + status=EquipmentStatus.AVAILABLE, + department=other_dept, + purchase_date=date.today(), + purchase_cost=Decimal("15000.00"), + ) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:equipment-list') + + response = api_client.get(url, {'department': department.id}) + assert response.status_code == status.HTTP_200_OK + results = response.data['results'] + # Все должны быть из нужного отдела + assert all(r['department'] == department.id for r in results) + + def test_search_equipment( + self, api_client, user, equipment + ): + """Поиск оборудования.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:equipment-list') + + response = api_client.get(url, {'search': 'latitude'}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data['results']) >= 1 + + +# ============================================================================== +# ТЕСТЫ СОЗДАНИЯ ОБОРУДОВАНИЯ +# ============================================================================== + + +class TestEquipmentCreate: + """Тесты создания оборудования.""" + + def test_create_equipment_as_staff( + self, api_client, staff_user, category, department + ): + """Создание оборудования staff пользователем.""" + api_client.force_authenticate(user=staff_user) + url = reverse('api:v1:procurement:equipment-list') + + data = { + 'name': 'Монитор LG 27"', + 'inventory_number': 'INV-2026-100', + 'serial_number': 'LG987654321', + 'category': category.id, + 'status': EquipmentStatus.AVAILABLE, + 'department': department.id, + 'location': 'Склад', + 'purchase_date': '2026-02-01', + 'warranty_until': '2029-02-01', + 'purchase_cost': '25000.00', + 'notes': 'Для офиса', + } + + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_201_CREATED + assert response.data['name'] == 'Монитор LG 27"' + assert response.data['inventory_number'] == 'INV-2026-100' + + def test_create_equipment_regular_user_forbidden( + self, api_client, user, category, department + ): + """Обычный пользователь не может создавать оборудование.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:equipment-list') + + data = { + 'name': 'Тестовое оборудование', + 'inventory_number': 'INV-2026-999', + 'category': category.id, + 'department': department.id, + 'purchase_date': '2026-02-01', + 'purchase_cost': '10000.00', + } + + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_create_equipment_duplicate_inventory_number( + self, api_client, staff_user, category, department, equipment + ): + """Нельзя создать с дублирующим инвентарным номером.""" + api_client.force_authenticate(user=staff_user) + url = reverse('api:v1:procurement:equipment-list') + + data = { + 'name': 'Другое оборудование', + 'inventory_number': equipment.inventory_number, # Дубликат! + 'category': category.id, + 'department': department.id, + 'purchase_date': '2026-02-01', + 'purchase_cost': '10000.00', + } + + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + +# ============================================================================== +# ТЕСТЫ ДЕТАЛЬНОЙ ИНФОРМАЦИИ +# ============================================================================== + + +class TestEquipmentDetail: + """Тесты получения детальной информации об оборудовании.""" + + def test_retrieve_equipment( + self, api_client, user, equipment + ): + """Получение детальной информации.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:equipment-detail', + kwargs={'pk': equipment.id} + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['id'] == equipment.id + assert response.data['name'] == equipment.name + assert response.data['inventory_number'] == equipment.inventory_number + assert 'is_under_warranty' in response.data + assert response.data['is_under_warranty'] is True + + def test_retrieve_with_maintenance_history( + self, api_client, user, equipment + ): + """Детальная информация включает историю ТО.""" + # Создаем записи об обслуживании + MaintenanceRecord.objects.create( + equipment=equipment, + date=date.today() - timedelta(days=30), + type=MaintenanceType.PLANNED, + description="Плановое ТО", + cost=Decimal("5000.00"), + performed_by=user, + ) + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:equipment-detail', + kwargs={'pk': equipment.id} + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert 'maintenance_history' in response.data + assert len(response.data['maintenance_history']) >= 1 + + +# ============================================================================== +# ТЕСТЫ ОБНОВЛЕНИЯ ОБОРУДОВАНИЯ +# ============================================================================== + + +class TestEquipmentUpdate: + """Тесты обновления оборудования.""" + + def test_update_equipment_as_staff( + self, api_client, staff_user, equipment + ): + """Обновление оборудования staff пользователем.""" + api_client.force_authenticate(user=staff_user) + url = reverse( + 'api:v1:procurement:equipment-detail', + kwargs={'pk': equipment.id} + ) + + data = { + 'location': 'Офис 4.01', + 'notes': 'Перемещено в новый офис', + } + response = api_client.patch(url, data, format='json') + + assert response.status_code == status.HTTP_200_OK + assert response.data['location'] == 'Офис 4.01' + assert 'новый офис' in response.data['notes'].lower() + + def test_update_equipment_regular_user_forbidden( + self, api_client, user, equipment + ): + """Обычный пользователь не может обновлять.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:equipment-detail', + kwargs={'pk': equipment.id} + ) + + data = {'location': 'Новое место'} + response = api_client.patch(url, data, format='json') + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ============================================================================== +# ТЕСТЫ СПЕЦИАЛЬНЫХ ДЕЙСТВИЙ +# ============================================================================== + + +class TestEquipmentActions: + """Тесты специальных действий с оборудованием.""" + + def test_transfer_equipment( + self, api_client, staff_user, equipment, db + ): + """Передача оборудования.""" + new_user = Employee.objects.create_user( + email="newuser@example.com", + password="testpass123", + phone_number="+79995555555", + first_name="Новый", + last_name="Пользователь", + send_activation_email=False, + ) + + api_client.force_authenticate(user=staff_user) + url = reverse( + 'api:v1:procurement:equipment-transfer', + kwargs={'pk': equipment.id} + ) + + data = { + 'to_person': new_user.id, + 'to_location': 'Офис 2.10', + 'reason': 'Перевод в другой отдел', + } + response = api_client.post(url, data, format='json') + + assert response.status_code == status.HTTP_200_OK + + # Проверяем что ответственный изменился + equipment.refresh_from_db() + assert equipment.responsible_person == new_user + assert equipment.location == 'Офис 2.10' + + def test_generate_qr_code( + self, api_client, user, equipment + ): + """Генерация QR-кода.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:equipment-qr-code', + kwargs={'pk': equipment.id} + ) + + response = api_client.get(url) + assert response.status_code == status.HTTP_200_OK + assert 'qr_code' in response.data + # QR код должен быть в base64 + assert response.data['qr_code'].startswith('data:image/') + + +# ============================================================================== +# ТЕСТЫ КАТЕГОРИЙ +# ============================================================================== + + +class TestEquipmentCategories: + """Тесты API категорий оборудования.""" + + def test_list_categories( + self, api_client, user, category + ): + """Список категорий.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:equipmentcategory-list') + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) >= 1 + + def test_create_category_as_staff( + self, api_client, staff_user + ): + """Создание категории staff пользователем.""" + api_client.force_authenticate(user=staff_user) + url = reverse('api:v1:procurement:equipmentcategory-list') + + data = { + 'name': 'Мониторы', + 'description': 'Все виды мониторов', + 'icon': 'bi-display', + } + response = api_client.post(url, data, format='json') + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['name'] == 'Мониторы' + + def test_hierarchical_categories( + self, api_client, staff_user, category + ): + """Создание подкатегории.""" + api_client.force_authenticate(user=staff_user) + url = reverse('api:v1:procurement:equipmentcategory-list') + + data = { + 'name': 'Игровые ноутбуки', + 'parent': category.id, + 'description': 'Высокопроизводительные ноутбуки', + } + response = api_client.post(url, data, format='json') + + assert response.status_code == status.HTTP_201_CREATED + assert response.data['parent'] == category.id diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py new file mode 100644 index 00000000..25eea5b4 --- /dev/null +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -0,0 +1,532 @@ +""" +Тесты API для заявок на закупку (ProcurementRequest). +""" + +from decimal import Decimal + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from employees.models import Department, Employee, EmployeeDepartment +from procurement.constants import ( + ApprovalRole, + ApprovalStatus, + ProcurementStatus, + UrgencyLevel, +) +from procurement.models import ( + Approval, + Budget, + ProcurementItem, + ProcurementRequest, +) + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def api_client(): + """API клиент.""" + return APIClient() + + +@pytest.fixture +def department(db): + """Тестовый отдел.""" + return Department.objects.create( + name="IT отдел", + description="Отдел информационных технологий" + ) + + +@pytest.fixture +def user(db, department): + """Обычный пользователь.""" + user = Employee.objects.create_user( + email="user@example.com", + password="testpass123", + phone_number="+79991111111", + first_name="Иван", + last_name="Иванов", + send_activation_email=False, + ) + # Связываем с отделом + EmployeeDepartment.objects.create( + employee=user, + department=department, + is_active=True + ) + return user + + +@pytest.fixture +def department_head(db, department): + """Руководитель отдела.""" + head = Employee.objects.create_user( + email="head@example.com", + password="testpass123", + phone_number="+79992222222", + first_name="Петр", + last_name="Петров", + send_activation_email=False, + ) + # Назначаем руководителем отдела (автоматически создаст EmployeeDepartment) + department.head = head + department.save() + return head + + +@pytest.fixture +def staff_user(db): + """Staff пользователь.""" + return Employee.objects.create_user( + email="staff@example.com", + password="testpass123", + phone_number="+79993333333", + first_name="Админ", + last_name="Админов", + is_staff=True, + send_activation_email=False, + ) + + +@pytest.fixture +def budget(db, department): + """Бюджет отдела.""" + return Budget.objects.create( + department=department, + year=2026, + quarter=1, + allocated_amount=Decimal("500000.00"), + spent_amount=Decimal("0.00"), + ) + + +@pytest.fixture +def procurement_request(db, department, user): + """Тестовая заявка.""" + return ProcurementRequest.objects.create( + title="Закупка ноутбуков", + description="Нужны новые ноутбуки для разработчиков", + department=department, + requestor=user, + status=ProcurementStatus.DRAFT, + urgency=UrgencyLevel.MEDIUM, + ) + + +@pytest.fixture +def procurement_item(db, procurement_request): + """Позиция в заявке.""" + return ProcurementItem.objects.create( + request=procurement_request, + name="Ноутбук Dell XPS 15", + description="i7, 32GB RAM, 1TB SSD", + quantity=3, + unit="шт", + estimated_unit_price=Decimal("120000.00"), + supplier_info="https://example.com/dell-xps-15", + ) + + +# ============================================================================== +# ТЕСТЫ СПИСКА ЗАЯВОК +# ============================================================================== + + +class TestProcurementRequestList: + """Тесты получения списка заявок.""" + + def test_list_unauthorized(self, api_client): + """Неавторизованный доступ запрещен.""" + url = reverse('api:v1:procurement:procurementrequest-list') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_own_requests( + self, api_client, user, procurement_request + ): + """Пользователь видит свои заявки.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + results = response.data['results'] + assert len(results) == 1 + assert results[0]['id'] == procurement_request.id + assert results[0]['title'] == "Закупка ноутбуков" + + def test_list_department_requests( + self, api_client, department_head, procurement_request + ): + """Руководитель видит заявки своего отдела.""" + api_client.force_authenticate(user=department_head) + url = reverse('api:v1:procurement:procurementrequest-list') + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + # Руководитель может видеть заявки отдела через scope=department + + def test_filter_by_status( + self, api_client, user, department, procurement_request + ): + """Фильтрация по статусу.""" + # Создаем заявку с другим статусом + ProcurementRequest.objects.create( + title="Другая заявка", + description="Описание", + department=department, + requestor=user, + status=ProcurementStatus.PENDING, + ) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + + # Фильтр DRAFT + response = api_client.get(url, {'status': ProcurementStatus.DRAFT}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data['results']) == 1 + assert response.data['results'][0]['status'] == ProcurementStatus.DRAFT + + def test_search_by_title( + self, api_client, user, procurement_request + ): + """Поиск по названию.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + + response = api_client.get(url, {'search': 'ноутбук'}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data['results']) == 1 + + +# ============================================================================== +# ТЕСТЫ СОЗДАНИЯ ЗАЯВОК +# ============================================================================== + + +class TestProcurementRequestCreate: + """Тесты создания заявок.""" + + def test_create_request_with_items( + self, api_client, user, department + ): + """Создание заявки с позициями.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + + data = { + 'title': 'Закупка мониторов', + 'description': 'Нужны мониторы для офиса', + 'department': department.id, + 'urgency': UrgencyLevel.LOW, + 'items': [ + { + 'name': 'Монитор 27"', + 'quantity': 5, + 'unit': 'шт', + 'estimated_unit_price': '25000.00', + 'description': '4K, IPS', + }, + { + 'name': 'Монитор 24"', + 'quantity': 10, + 'unit': 'шт', + 'estimated_unit_price': '18000.00', + }, + ] + } + + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_201_CREATED + assert response.data['title'] == 'Закупка мониторов' + assert response.data['requestor'] == user.id + assert response.data['status'] == ProcurementStatus.DRAFT + + # Проверяем что позиции созданы + request_id = response.data['id'] + request = ProcurementRequest.objects.get(id=request_id) + assert request.items.count() == 2 + assert request.total_cost == Decimal('305000.00') # 5*25k + 10*18k + + def test_create_request_wrong_department( + self, api_client, user, db + ): + """Нельзя создать заявку для чужого отдела.""" + other_dept = Department.objects.create( + name="HR отдел", description="Другой отдел" + ) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:procurementrequest-list') + + data = { + 'title': 'Заявка', + 'description': 'Описание', + 'department': other_dept.id, + 'urgency': UrgencyLevel.MEDIUM, + 'items': [] + } + + response = api_client.post(url, data, format='json') + # Должна быть ошибка валидации + assert response.status_code in [ + status.HTTP_400_BAD_REQUEST, + status.HTTP_403_FORBIDDEN + ] + + +# ============================================================================== +# ТЕСТЫ ДЕТАЛЬНОЙ ИНФОРМАЦИИ +# ============================================================================== + + +class TestProcurementRequestDetail: + """Тесты получения детальной информации.""" + + def test_retrieve_own_request( + self, api_client, user, procurement_request, procurement_item + ): + """Получение своей заявки.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['id'] == procurement_request.id + assert response.data['title'] == "Закупка ноутбуков" + assert 'items' in response.data + assert len(response.data['items']) == 1 + assert 'total_cost' in response.data + assert 'is_editable' in response.data + assert response.data['is_editable'] is True + + def test_retrieve_other_request_forbidden( + self, api_client, db, procurement_request + ): + """Нельзя просмотреть чужую заявку (обычному пользователю).""" + other_user = Employee.objects.create_user( + email="other@example.com", + password="testpass123", + phone_number="+79994444444", + first_name="Другой", + last_name="Пользователь", + send_activation_email=False, + ) + + api_client.force_authenticate(user=other_user) + url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +# ============================================================================== +# ТЕСТЫ ОБНОВЛЕНИЯ ЗАЯВОК +# ============================================================================== + + +class TestProcurementRequestUpdate: + """Тесты обновления заявок.""" + + def test_update_draft_request( + self, api_client, user, procurement_request + ): + """Обновление заявки в статусе DRAFT.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + + data = { + 'title': 'Обновленное название', + 'urgency': UrgencyLevel.HIGH, + } + response = api_client.patch(url, data, format='json') + + assert response.status_code == status.HTTP_200_OK + assert response.data['title'] == 'Обновленное название' + assert response.data['urgency'] == UrgencyLevel.HIGH + + def test_cannot_update_submitted_request( + self, api_client, user, procurement_request + ): + """Нельзя обновить отправленную заявку.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + + data = {'title': 'Новое название'} + response = api_client.patch(url, data, format='json') + + assert response.status_code == status.HTTP_403_FORBIDDEN + + +# ============================================================================== +# ТЕСТЫ WORKFLOW ДЕЙСТВИЙ +# ============================================================================== + + +class TestProcurementRequestWorkflow: + """Тесты workflow действий с заявками.""" + + def test_submit_request( + self, api_client, user, procurement_request, procurement_item, budget + ): + """Отправка заявки на согласование.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url) + assert response.status_code == status.HTTP_200_OK + + # Проверяем статус изменен + procurement_request.refresh_from_db() + assert procurement_request.status == ProcurementStatus.PENDING + assert procurement_request.submitted_at is not None + + def test_submit_without_items_fails( + self, api_client, user, procurement_request + ): + """Нельзя отправить заявку без позиций.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'позицию' in response.data['error'].lower() + + def test_approve_request_by_head( + self, api_client, department_head, procurement_request, + procurement_item, budget + ): + """Согласование заявки руководителем.""" + # Отправляем заявку + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + # Создаем запись согласования + Approval.objects.create( + request=procurement_request, + approver=department_head, + role=ApprovalRole.DEPARTMENT_HEAD, + status=ApprovalStatus.PENDING, + ) + + api_client.force_authenticate(user=department_head) + url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url, {'comment': 'Одобрено'}) + assert response.status_code == status.HTTP_200_OK + + def test_reject_request( + self, api_client, department_head, procurement_request, + procurement_item + ): + """Отклонение заявки.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + Approval.objects.create( + request=procurement_request, + approver=department_head, + role=ApprovalRole.DEPARTMENT_HEAD, + status=ApprovalStatus.PENDING, + ) + + api_client.force_authenticate(user=department_head) + url = reverse( + 'api:v1:procurement:procurementrequest-reject', + kwargs={'pk': procurement_request.id} + ) + + data = {'comment': 'Недостаточно обоснования'} + response = api_client.post(url, data) + assert response.status_code == status.HTTP_200_OK + + procurement_request.refresh_from_db() + assert procurement_request.status == ProcurementStatus.REJECTED + + def test_cancel_own_request( + self, api_client, user, procurement_request + ): + """Отмена своей заявки.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-cancel', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url) + assert response.status_code == status.HTTP_200_OK + + procurement_request.refresh_from_db() + assert procurement_request.status == ProcurementStatus.CANCELLED + + +# ============================================================================== +# ТЕСТЫ УДАЛЕНИЯ +# ============================================================================== + + +class TestProcurementRequestDelete: + """Тесты удаления заявок.""" + + def test_delete_draft_request( + self, api_client, user, procurement_request + ): + """Удаление черновика.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + + assert not ProcurementRequest.objects.filter( + id=procurement_request.id + ).exists() + + def test_cannot_delete_submitted_request( + self, api_client, user, procurement_request + ): + """Нельзя удалить отправленную заявку.""" + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-detail', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.delete(url) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/backend/tests/api/v1/procurement/test_suppliers.py b/backend/tests/api/v1/procurement/test_suppliers.py new file mode 100644 index 00000000..92bb4bf7 --- /dev/null +++ b/backend/tests/api/v1/procurement/test_suppliers.py @@ -0,0 +1,365 @@ +""" +Тесты API для поставщиков (Supplier). +""" + +from decimal import Decimal + +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from employees.models import Employee +from procurement.models import Supplier + + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def api_client(): + """API клиент.""" + return APIClient() + + +@pytest.fixture +def user(db): + """Обычный пользователь.""" + return Employee.objects.create_user( + email="user@example.com", + password="testpass123", + phone_number="+79991111111", + first_name="Иван", + last_name="Иванов", + send_activation_email=False, + ) + + +@pytest.fixture +def staff_user(db): + """Staff пользователь.""" + return Employee.objects.create_user( + email="staff@example.com", + password="testpass123", + phone_number="+79992222222", + first_name="Админ", + last_name="Админов", + is_staff=True, + send_activation_email=False, + ) + + +@pytest.fixture +def supplier(db): + """Тестовый поставщик.""" + return Supplier.objects.create( + name="ООО \"Компьютеры\"", + contact_person="Иванов Иван", + phone="+79991234567", + email="info@computers.ru", + website="https://computers.ru", + inn="7701234567", + address="Москва, ул. Ленина, д. 1", + rating=Decimal("4.50"), + is_active=True, + notes="Основной поставщик IT-оборудования", + ) + + +# ============================================================================== +# ТЕСТЫ СПИСКА ПОСТАВЩИКОВ +# ============================================================================== + + +class TestSupplierList: + """Тесты получения списка поставщиков.""" + + def test_list_unauthorized(self, api_client): + """Неавторизованный доступ запрещен.""" + url = reverse('api:v1:procurement:supplier-list') + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_suppliers( + self, api_client, user, supplier + ): + """Получение списка поставщиков.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:supplier-list') + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + results = response.data['results'] + assert len(results) >= 1 + + # Проверяем структуру + item = results[0] + assert 'name' in item + assert 'contact_person' in item + assert 'phone' in item + assert 'email' in item + assert 'rating' in item + assert 'is_active' in item + + def test_search_suppliers( + self, api_client, user, supplier + ): + """Поиск поставщиков.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:supplier-list') + + response = api_client.get(url, {'search': 'компьютер'}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data['results']) >= 1 + + def test_filter_active_suppliers( + self, api_client, user, db + ): + """Фильтр только активных поставщиков.""" + # Создаем активного и неактивного + Supplier.objects.create( + name="Активный поставщик", + is_active=True, + ) + Supplier.objects.create( + name="Неактивный поставщик", + is_active=False, + ) + + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:supplier-list') + + response = api_client.get(url, {'is_active': 'true'}) + assert response.status_code == status.HTTP_200_OK + results = response.data['results'] + assert all(r['is_active'] for r in results) + + +# ============================================================================== +# ТЕСТЫ СОЗДАНИЯ ПОСТАВЩИКА +# ============================================================================== + + +class TestSupplierCreate: + """Тесты создания поставщика.""" + + def test_create_supplier_as_staff( + self, api_client, staff_user + ): + """Создание поставщика staff пользователем.""" + api_client.force_authenticate(user=staff_user) + url = reverse('api:v1:procurement:supplier-list') + + data = { + 'name': 'ООО "Новый поставщик"', + 'contact_person': 'Петров Петр', + 'phone': '+79997654321', + 'email': 'new@supplier.ru', + 'website': 'https://newsupplier.ru', + 'inn': '7707654321', + 'address': 'Санкт-Петербург, Невский пр., д. 10', + 'rating': '5.00', + 'notes': 'Проверенный поставщик', + } + + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_201_CREATED + assert response.data['name'] == 'ООО "Новый поставщик"' + assert response.data['inn'] == '7707654321' + + def test_create_supplier_regular_user_forbidden( + self, api_client, user + ): + """Обычный пользователь не может создавать поставщиков.""" + api_client.force_authenticate(user=user) + url = reverse('api:v1:procurement:supplier-list') + + data = { + 'name': 'Тестовый поставщик', + 'email': 'test@supplier.ru', + } + + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_create_supplier_minimum_data( + self, api_client, staff_user + ): + """Создание поставщика с минимальными данными.""" + api_client.force_authenticate(user=staff_user) + url = reverse('api:v1:procurement:supplier-list') + + data = { + 'name': 'Минимальный поставщик', + } + + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_201_CREATED + assert response.data['name'] == 'Минимальный поставщик' + assert response.data['is_active'] is True # По умолчанию + + +# ============================================================================== +# ТЕСТЫ ДЕТАЛЬНОЙ ИНФОРМАЦИИ +# ============================================================================== + + +class TestSupplierDetail: + """Тесты получения детальной информации о поставщике.""" + + def test_retrieve_supplier( + self, api_client, user, supplier + ): + """Получение детальной информации.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:supplier-detail', + kwargs={'pk': supplier.id} + ) + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data['id'] == supplier.id + assert response.data['name'] == supplier.name + assert response.data['inn'] == supplier.inn + assert Decimal(response.data['rating']) == supplier.rating + + +# ============================================================================== +# ТЕСТЫ ОБНОВЛЕНИЯ ПОСТАВЩИКА +# ============================================================================== + + +class TestSupplierUpdate: + """Тесты обновления поставщика.""" + + def test_update_supplier_as_staff( + self, api_client, staff_user, supplier + ): + """Обновление поставщика staff пользователем.""" + api_client.force_authenticate(user=staff_user) + url = reverse( + 'api:v1:procurement:supplier-detail', + kwargs={'pk': supplier.id} + ) + + data = { + 'rating': '4.80', + 'notes': 'Обновленная информация о поставщике', + } + response = api_client.patch(url, data, format='json') + + assert response.status_code == status.HTTP_200_OK + assert Decimal(response.data['rating']) == Decimal('4.80') + assert 'Обновленная' in response.data['notes'] + + def test_update_supplier_regular_user_forbidden( + self, api_client, user, supplier + ): + """Обычный пользователь не может обновлять поставщиков.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:supplier-detail', + kwargs={'pk': supplier.id} + ) + + data = {'rating': '5.00'} + response = api_client.patch(url, data, format='json') + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_deactivate_supplier( + self, api_client, staff_user, supplier + ): + """Деактивация поставщика.""" + api_client.force_authenticate(user=staff_user) + url = reverse( + 'api:v1:procurement:supplier-detail', + kwargs={'pk': supplier.id} + ) + + data = {'is_active': False} + response = api_client.patch(url, data, format='json') + + assert response.status_code == status.HTTP_200_OK + assert response.data['is_active'] is False + + +# ============================================================================== +# ТЕСТЫ УДАЛЕНИЯ +# ============================================================================== + + +class TestSupplierDelete: + """Тесты удаления поставщика.""" + + def test_delete_supplier_as_staff( + self, api_client, staff_user, supplier + ): + """Удаление поставщика staff пользователем.""" + api_client.force_authenticate(user=staff_user) + url = reverse( + 'api:v1:procurement:supplier-detail', + kwargs={'pk': supplier.id} + ) + + response = api_client.delete(url) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Supplier.objects.filter(id=supplier.id).exists() + + def test_delete_supplier_regular_user_forbidden( + self, api_client, user, supplier + ): + """Обычный пользователь не может удалять поставщиков.""" + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:supplier-detail', + kwargs={'pk': supplier.id} + ) + + response = api_client.delete(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Supplier.objects.filter(id=supplier.id).exists() + + +# ============================================================================== +# ТЕСТЫ БИЗНЕС-ЛОГИКИ +# ============================================================================== + + +class TestSupplierBusinessLogic: + """Тесты бизнес-логики поставщиков.""" + + def test_rating_validation( + self, api_client, staff_user + ): + """Валидация рейтинга (не может быть отрицательным).""" + api_client.force_authenticate(user=staff_user) + url = reverse('api:v1:procurement:supplier-list') + + data = { + 'name': 'Поставщик с плохим рейтингом', + 'rating': '-1.00', + } + + response = api_client.post(url, data, format='json') + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_inn_format( + self, api_client, staff_user, supplier + ): + """ИНН должен быть корректным (10 или 12 цифр).""" + api_client.force_authenticate(user=staff_user) + url = reverse( + 'api:v1:procurement:supplier-detail', + kwargs={'pk': supplier.id} + ) + + # ИНН из 10 цифр - валиден + data = {'inn': '1234567890'} + response = api_client.patch(url, data, format='json') + assert response.status_code == status.HTTP_200_OK + + # ИНН из 12 цифр - валиден + data = {'inn': '123456789012'} + response = api_client.patch(url, data, format='json') + assert response.status_code == status.HTTP_200_OK diff --git a/docs/in-progress/PROCUREMENT_REFACTORING_PLAN.md b/docs/in-progress/PROCUREMENT_REFACTORING_PLAN.md new file mode 100644 index 00000000..24d5c441 --- /dev/null +++ b/docs/in-progress/PROCUREMENT_REFACTORING_PLAN.md @@ -0,0 +1,890 @@ +# План рефакторинга модуля Procurement для pip-публикации + +**Цель:** Сделать модуль `procurement` независимым и готовым к публикации на PyPI. + +**Статус:** 🚧 В разработке +**Дата начала:** 27 февраля 2026 +**Оценка времени:** 2-3 недели +**Риск:** ⚠️ Средний (модуль не в проде, но есть тесты) + +--- + +## 📊 Анализ текущего состояния + +### Проблемы tight coupling: + +**19 жестких связей:** +```python +# ❌ Текущее состояние +from employees.models import Department # Жесткая зависимость +User = get_user_model() # Предполагает employees.Employee + +ProcurementRequest: + - department: ForeignKey(Department) # 1 + - requestor: ForeignKey(User) # 2 + - executor: ForeignKey(User) # 3 + +ProcurementItem: + - request: ForeignKey(ProcurementRequest) # OK (внутренняя) + +Approval: + - request: ForeignKey(ProcurementRequest) # OK + - approver: ForeignKey(User) # 4 + +EquipmentCategory: + - parent: ForeignKey('self') # OK + +Equipment: + - category: ForeignKey(EquipmentCategory) # OK + - department: ForeignKey(Department) # 5 + - responsible_person: ForeignKey(User) # 6 + - procurement_request: ForeignKey(...) # OK + +MaintenanceRecord: + - equipment: ForeignKey(Equipment) # OK + - performed_by: ForeignKey(User) # 7 + +EquipmentTransfer: + - equipment: ForeignKey(Equipment) # OK + - from_department: ForeignKey(Department) # 8 + - to_department: ForeignKey(Department) # 9 + - from_person: ForeignKey(User) # 10 + - to_person: ForeignKey(User) # 11 + - created_by: ForeignKey(User) # 12 + +Budget: + - department: ForeignKey(Department) # 13 +``` + +**Итого:** +- 13 зависимостей от внешних моделей +- 9 ссылок на User (Employee) +- 4 ссылки на Department + +--- + +## 🎯 Целевая архитектура + +### Принципы pip-ready модуля: + +1. **Swappable models** - модели настраиваются через settings +2. **Abstract базы** - переиспользуемая логика +3. **Минимальные зависимости** - только Django core +4. **Type hints** - полная типизация +5. **Документация** - docstrings everywhere + +### Целевая структура: + +```python +# procurement/settings.py (новый файл) +from django.conf import settings + +PROCUREMENT_USER_MODEL = getattr( + settings, + 'PROCUREMENT_USER_MODEL', + settings.AUTH_USER_MODEL +) + +PROCUREMENT_DEPARTMENT_MODEL = getattr( + settings, + 'PROCUREMENT_DEPARTMENT_MODEL', + 'employees.Department' # default для EUSRR +) + +# procurement/models.py +from django.contrib.auth import get_user_model +from .conf import PROCUREMENT_USER_MODEL, PROCUREMENT_DEPARTMENT_MODEL + +class ProcurementRequest(models.Model): + # ✅ Настраиваемые модели + department = models.ForeignKey( + PROCUREMENT_DEPARTMENT_MODEL, + on_delete=models.PROTECT, + ... + ) + requestor = models.ForeignKey( + PROCUREMENT_USER_MODEL, + on_delete=models.PROTECT, + ... + ) +``` + +--- + +## 📋 План рефакторинга (10 шагов) + +### **Этап 1: Подготовка (1 день)** + +#### ✅ Шаг 1.1: Создать бэкап +```bash +# Бэкап БД +python manage.py dumpdata procurement > backup_procurement_$(date +%Y%m%d).json + +# Бэкап кода +git checkout -b refactor/procurement-pip-ready +git commit -am "Checkpoint: Before procurement refactoring" +``` + +#### ✅ Шаг 1.2: Запустить существующие тесты +```bash +pytest backend/tests/api/v1/procurement/ -v --tb=short +# Записать результаты как baseline +``` + +#### ✅ Шаг 1.3: Создать файлы конфигурации +``` +procurement/ + conf.py # Настройки модуля (новый) + setup.py # pip package metadata (новый) + pyproject.toml # Modern Python packaging (новый) + MANIFEST.in # Файлы для включения в пакет (новый) +``` + +--- + +### **Этап 2: Создание конфигурации (2 дня)** + +#### ✅ Шаг 2.1: Создать procurement/conf.py +```python +""" +Настройки модуля procurement. + +Можно переопределить в settings.py проекта: + +PROCUREMENT_USER_MODEL = 'auth.User' +PROCUREMENT_DEPARTMENT_MODEL = 'myapp.Department' +""" +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +# User model (usually Employee or auth.User) +PROCUREMENT_USER_MODEL = getattr( + settings, + 'PROCUREMENT_USER_MODEL', + settings.AUTH_USER_MODEL +) + +# Department model (must have: name, head, active_employees) +PROCUREMENT_DEPARTMENT_MODEL = getattr( + settings, + 'PROCUREMENT_DEPARTMENT_MODEL', + 'employees.Department' +) + +# Approval thresholds (₽) +APPROVAL_THRESHOLD_LOW = getattr( + settings, + 'PROCUREMENT_APPROVAL_THRESHOLD_LOW', + 10000 +) + +APPROVAL_THRESHOLD_HIGH = getattr( + settings, + 'PROCUREMENT_APPROVAL_THRESHOLD_HIGH', + 100000 +) + +# Validation функция +def validate_department_model(): + """Проверяет что DEPARTMENT_MODEL имеет нужные поля""" + from django.apps import apps + + try: + model = apps.get_model(PROCUREMENT_DEPARTMENT_MODEL) + except LookupError: + raise ImproperlyConfigured( + f"PROCUREMENT_DEPARTMENT_MODEL '{PROCUREMENT_DEPARTMENT_MODEL}' " + f"not found. Make sure the app is in INSTALLED_APPS." + ) + + # Проверяем обязательные поля + required_fields = ['name'] + for field in required_fields: + if not hasattr(model, field): + raise ImproperlyConfigured( + f"PROCUREMENT_DEPARTMENT_MODEL must have '{field}' field" + ) + + return model +``` + +#### ✅ Шаг 2.2: Обновить procurement/apps.py +```python +from django.apps import AppConfig + +class ProcurementConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'procurement' + verbose_name = 'Закупки и инвентарь' + + def ready(self): + # Импорт signals + from . import signals + + # Валидация настроек + from .conf import validate_department_model + validate_department_model() +``` + +--- + +### **Этап 3: Рефакторинг моделей (3 дня)** + +#### ✅ Шаг 3.1: Обновить imports в models.py +```python +# ❌ Старое +from employees.models import Department +User = get_user_model() + +# ✅ Новое +from django.apps import apps +from .conf import PROCUREMENT_USER_MODEL, PROCUREMENT_DEPARTMENT_MODEL + +def get_user_model(): + return apps.get_model(PROCUREMENT_USER_MODEL) + +def get_department_model(): + return apps.get_model(PROCUREMENT_DEPARTMENT_MODEL) +``` + +#### ✅ Шаг 3.2: Изменить ForeignKey на строковые ссылки +```python +# ❌ Старое +department = models.ForeignKey( + Department, + on_delete=models.PROTECT, + ... +) + +# ✅ Новое +department = models.ForeignKey( + PROCUREMENT_DEPARTMENT_MODEL, # Строка! + on_delete=models.PROTECT, + ... +) + +# ❌ Старое +requestor = models.ForeignKey( + User, + on_delete=models.PROTECT, + ... +) + +# ✅ Новое +requestor = models.ForeignKey( + PROCUREMENT_USER_MODEL, # Строка! + on_delete=models.PROTECT, + ... +) +``` + +#### ✅ Шаг 3.3: Добавить type hints +```python +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from django.contrib.auth.models import AbstractUser + from django.db.models import Model + + UserModel = AbstractUser + DepartmentModel = Model +else: + UserModel = None + DepartmentModel = None + +class ProcurementRequest(models.Model): + # Type hints для IDE + requestor: UserModel + department: DepartmentModel + + def approve(self, by_user: UserModel) -> None: + """Одобрить заявку""" + ... +``` + +--- + +### **Этап 4: Миграции (1 день)** + +#### ✅ Шаг 4.1: Создать новую миграцию +```bash +python manage.py makemigrations procurement +``` + +**Важно:** Миграция должна использовать `swappable_dependency`: +```python +# migrations/XXXX_refactor_swappable.py +from django.conf import settings +from django.db import migrations, models +import procurement.conf + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(procurement.conf.PROCUREMENT_DEPARTMENT_MODEL), + ('procurement', '0001_initial'), + ] + + operations = [ + # Django автоматически создаст AlterField для ForeignKey + ... + ] +``` + +#### ✅ Шаг 4.2: Применить миграции +```bash +python manage.py migrate procurement +``` + +#### ✅ Шаг 4.3: Проверить БД +```bash +python manage.py dbshell +\d+ procurement_procurementrequest +# Проверить что FK ссылаются на правильные таблицы +``` + +--- + +### **Этап 5: Обновление кода (2 дня)** + +#### ✅ Шаг 5.1: Обновить методы моделей +```python +# Везде где используется User/Department, заменить на get_*_model() + +class ProcurementRequest(models.Model): + def check_budget_available(self): + Department = get_department_model() # ← Добавить + + try: + budget = Budget.objects.get( + department=self.department, + ... + ) +``` + +#### ✅ Шаг 5.2: Обновить permissions.py +```python +# api/v1/procurement/permissions.py + +from procurement.conf import get_department_model + +class IsDepartmentHead(permissions.BasePermission): + def has_permission(self, request, view): + Department = get_department_model() # ← Добавить + + # Проверяем через свойство head + # Предполагается что у Department есть поле head + return Department.objects.filter(head=request.user).exists() +``` + +#### ✅ Шаг 5.3: Обновить serializers.py +```python +# api/v1/procurement/serializers.py + +from procurement.conf import PROCUREMENT_USER_MODEL + +class ProcurementRequestSerializer(serializers.ModelSerializer): + requestor = serializers.PrimaryKeyRelatedField( + queryset=get_user_model().objects.all() # ← Использовать get_user_model() + ) +``` + +--- + +### **Этап 6: Обновление тестов (2 дня)** + +#### ✅ Шаг 6.1: Обновить conftest.py +```python +# tests/api/v1/procurement/conftest.py + +from django.apps import apps +from procurement.conf import PROCUREMENT_USER_MODEL, PROCUREMENT_DEPARTMENT_MODEL + +@pytest.fixture +def user_factory(db): + """Фабрика пользователей - работает с любой моделью""" + User = apps.get_model(PROCUREMENT_USER_MODEL) + + def _create(**kwargs): + return User.objects.create_user(**kwargs) + + return _create + +@pytest.fixture +def department_factory(db): + """Фабрика отделов - работает с любой моделью""" + Department = apps.get_model(PROCUREMENT_DEPARTMENT_MODEL) + + def _create(**kwargs): + defaults = {'name': 'Test Department'} + defaults.update(kwargs) + return Department.objects.create(**defaults) + + return _create +``` + +#### ✅ Шаг 6.2: Запустить тесты +```bash +pytest backend/tests/api/v1/procurement/ -v +# Все 69 тестов должны пройти! +``` + +--- + +### **Этап 7: Создание pip пакета (1 день)** + +#### ✅ Шаг 7.1: Создать setup.py +```python +# procurement/setup.py +from setuptools import setup, find_packages + +setup( + name='django-procurement', + version='0.1.0', + description='Django app for procurement and inventory management', + long_description=open('README.md').read(), + long_description_content_type='text/markdown', + author='Your Name', + author_email='your@email.com', + url='https://github.com/yourorg/django-procurement', + packages=find_packages(), + include_package_data=True, + install_requires=[ + 'Django>=4.2', + 'djangorestframework>=3.14', + 'Pillow>=10.0', # Для QR кодов + ], + extras_require={ + 'dev': [ + 'pytest>=7.0', + 'pytest-django>=4.5', + 'black>=23.0', + 'isort>=5.12', + ] + }, + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Framework :: Django', + 'Framework :: Django :: 4.2', + 'Framework :: Django :: 5.0', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + ], + python_requires='>=3.10', +) +``` + +#### ✅ Шаг 7.2: Создать pyproject.toml +```toml +# procurement/pyproject.toml +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "django-procurement" +version = "0.1.0" +description = "Django app for procurement and inventory management" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +authors = [ + {name = "Your Name", email = "your@email.com"} +] +keywords = ["django", "procurement", "inventory", "equipment"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: Django", + "Programming Language :: Python :: 3", +] +dependencies = [ + "Django>=4.2", + "djangorestframework>=3.14", + "Pillow>=10.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-django>=4.5", + "black>=23.0", + "isort>=5.12", +] + +[project.urls] +Homepage = "https://github.com/yourorg/django-procurement" +Documentation = "https://django-procurement.readthedocs.io" +Repository = "https://github.com/yourorg/django-procurement" +``` + +#### ✅ Шаг 7.3: Создать MANIFEST.in +``` +# procurement/MANIFEST.in +include LICENSE +include README.md +include pyproject.toml +recursive-include procurement/templates * +recursive-include procurement/static * +recursive-include procurement/migrations *.py +recursive-exclude * __pycache__ +recursive-exclude * *.pyc +recursive-exclude * *.pyo +``` + +--- + +### **Этап 8: Документация (2 дня)** + +#### ✅ Шаг 8.1: Создать README.md +```markdown +# Django Procurement + +Django приложение для управления закупками и инвентарем. + +## Особенности + +- ✅ Swappable User/Department models +- ✅ Workflow для заявок на закупку +- ✅ Управление оборудованием +- ✅ Система согласований +- ✅ REST API (DRF) +- ✅ Полные type hints +- ✅ 80%+ test coverage + +## Установка + +```bash +pip install django-procurement +``` + +## Быстрый старт + +1. Добавьте в INSTALLED_APPS: +```python +INSTALLED_APPS = [ + ... + 'rest_framework', + 'procurement', +] +``` + +2. Настройте модели (опционально): +```python +# settings.py +PROCUREMENT_USER_MODEL = 'auth.User' # default: AUTH_USER_MODEL +PROCUREMENT_DEPARTMENT_MODEL = 'myapp.Department' # default: 'employees.Department' + +# Пороги согласования (₽) +PROCUREMENT_APPROVAL_THRESHOLD_LOW = 10000 +PROCUREMENT_APPROVAL_THRESHOLD_HIGH = 100000 +``` + +3. Примените миграции: +```bash +python manage.py migrate procurement +``` + +4. Подключите URLs: +```python +urlpatterns = [ + path('api/v1/procurement/', include('procurement.api.urls')), +] +``` + +## Требования к Department model + +Если используется кастомная модель отдела, она должна иметь: +- `name` (CharField) - название отдела +- `head` (ForeignKey to User, optional) - руководитель + +Пример: +```python +class Department(models.Model): + name = models.CharField(max_length=255) + head = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True + ) +``` + +## API + +### Endpoints + +- `GET /api/v1/procurement/requests/` - список заявок +- `POST /api/v1/procurement/requests/` - создать заявку +- `GET /api/v1/procurement/equipment/` - список оборудования +- ... (see docs/) + +## Тестирование + +```bash +pytest tests/ +``` + +## Лицензия + +MIT License +``` + +#### ✅ Шаг 8.2: Обновить docstrings +Добавить полные docstrings во все классы и методы с примерами использования. + +--- + +### **Этап 9: Интеграция в EUSRR (1 день)** + +#### ✅ Шаг 9.1: Обновить settings.py проекта +```python +# backend/eusrr_backend/settings.py + +# Настройки procurement модуля +PROCUREMENT_USER_MODEL = 'employees.Employee' # Явно указываем +PROCUREMENT_DEPARTMENT_MODEL = 'employees.Department' +PROCUREMENT_APPROVAL_THRESHOLD_LOW = 10000 +PROCUREMENT_APPROVAL_THRESHOLD_HIGH = 100000 +``` + +#### ✅ Шаг 9.2: Проверить работу +```bash +# Запустить сервер +python manage.py runserver + +# Проверить API +curl http://localhost:9000/api/v1/procurement/requests/ + +# Проверить админку +# Зайти в /admin/procurement/ +``` + +--- + +### **Этап 10: Финализация (1 день)** + +#### ✅ Шаг 10.1: Запустить полный тест-suite +```bash +# Все тесты procurement +pytest backend/tests/api/v1/procurement/ -v --cov=procurement + +# Интеграционные тесты +pytest backend/tests/integration/ -k procurement + +# Проверить покрытие +pytest --cov=procurement --cov-report=html +# Цель: 80%+ +``` + +#### ✅ Шаг 10.2: Code quality checks +```bash +# Форматирование +black backend/procurement/ +isort backend/procurement/ + +# Линтинг +flake8 backend/procurement/ +mypy backend/procurement/ + +# Проверка безопасности +bandit -r backend/procurement/ +``` + +#### ✅ Шаг 10.3: Создать git tag +```bash +git add . +git commit -m "refactor: Make procurement module pip-ready + +- Add swappable USER/DEPARTMENT models +- Add comprehensive type hints +- Extract configuration to conf.py +- Add setup.py for pip distribution +- Update tests to work with any models +- 100% backward compatible with EUSRR + +BREAKING CHANGES: None (backward compatible) +" + +git tag -a v0.1.0 -m "Release 0.1.0: pip-ready procurement module" +git push origin refactor/procurement-pip-ready +git push --tags +``` + +--- + +## 🧪 Тестирование в изоляции + +### Создать тестовый Django проект: + +```bash +# Создать чистый проект для проверки +mkdir test-procurement +cd test-procurement +python -m venv venv +source venv/bin/activate + +# Установить procurement как pip пакет +pip install -e ../EUSRR/backend/procurement/ + +# Создать minimal Django проект +django-admin startproject testproject +cd testproject + +# Добавить в INSTALLED_APPS +# settings.py +INSTALLED_APPS = [ + ... + 'procurement', +] + +# Использовать стандартный auth.User +PROCUREMENT_USER_MODEL = 'auth.User' + +# Создать минимальную Department модель +# testproject/models.py +class Department(models.Model): + name = models.CharField(max_length=255) + head = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True + ) + +PROCUREMENT_DEPARTMENT_MODEL = 'testproject.Department' + +# Запустить миграции +python manage.py migrate + +# Проверить что всё работает! +python manage.py shell +>>> from procurement.models import ProcurementRequest +>>> ProcurementRequest.objects.create(...) +``` + +--- + +## ✅ Критерии успеха + +### Функциональные: +- [ ] Все 69 тестов проходят +- [ ] Модуль работает с `auth.User` вместо `employees.Employee` +- [ ] Модуль работает с custom Department моделью +- [ ] API endpoints работают +- [ ] Админка работает +- [ ] Миграции применяются без ошибок + +### Код quality: +- [ ] 80%+ test coverage +- [ ] Type hints везде +- [ ] Docstrings для всех public API +- [ ] Black + isort пройдены +- [ ] Flake8 без ошибок +- [ ] Mypy пройден + +### Packaging: +- [ ] `pip install -e .` работает +- [ ] setup.py корректен +- [ ] pyproject.toml корректен +- [ ] MANIFEST.in включает все нужные файлы +- [ ] README.md полный + +### Backward compatibility: +- [ ] EUSRR проект работает БЕЗ изменений (кроме settings) +- [ ] Существующие данные в БД не затронуты +- [ ] Frontend не требует изменений + +--- + +## 📊 Метрики прогресса + +### До рефакторинга: +``` +Зависимости: 19 жестких связей (13 внешних) +Покрытие тестами: 81% (56/69 passed) +Документация: Minimal +Type hints: Частичные +pip-ready: ❌ +``` + +### После рефакторинга: +``` +Зависимости: 0 жестких связей (все swappable) +Покрытие тестами: 80%+ (все проходят) +Документация: Полная (README, docstrings) +Type hints: 100% +pip-ready: ✅ +``` + +--- + +## 🚨 Риски и митигация + +### Риск 1: Миграции сломают БД +**Митигация:** +- Полный бэкап перед началом +- Тестировать на копии БД +- Rollback plan готов + +### Риск 2: Тесты не проходят +**Митигация:** +- Запустить baseline до изменений +- Фиксить по одному тесту +- Не коммитить если есть failed tests + +### Риск 3: Breaking changes для EUSRR +**Митигация:** +- Backward compatible подход +- Только добавление, не удаление +- Settings опциональные + +### Риск 4: Performance деградация +**Митигация:** +- Бенчмарки до/после +- EXPLAIN ANALYZE запросов +- Индексы на месте + +--- + +## 📝 Чеклист перед коммитом + +- [ ] Бэкап БД создан +- [ ] Все тесты проходят +- [ ] Black + isort применены +- [ ] Type hints добавлены +- [ ] Docstrings обновлены +- [ ] README.md актуален +- [ ] Миграции протестированы +- [ ] EUSRR проект работает +- [ ] Coverage 80%+ +- [ ] Нет TODO/FIXME в коде + +--- + +## 🎉 После завершения + +### Опубликовать на TestPyPI: +```bash +cd backend/procurement +python -m build +twine upload --repository testpypi dist/* +``` + +### Опубликовать на PyPI (когда готово): +```bash +twine upload dist/* +``` + +### Документация: +- Создать docs/ сайт (MkDocs/Sphinx) +- Опубликовать на ReadTheDocs + +--- + +**Время начала:** 27.02.2026 +**Ожидаемое завершение:** 15.03.2026 (2-3 недели) +**Ответственный:** [Ваше имя] +**Статус:** 📝 План готов, ожидает утверждения