diff --git a/BUDGET_SYSTEM.md b/BUDGET_SYSTEM.md new file mode 100644 index 00000000..2adcb0fd --- /dev/null +++ b/BUDGET_SYSTEM.md @@ -0,0 +1,246 @@ +# Система бюджетов отделов в модуле закупок + +## Где выставляется бюджет? + +### 1️⃣ **Django Admin (Основное место)** + +``` +http://localhost:9000/admin/procurement/budget/ +``` + +**Процесс:** +1. Перейти в Django Admin → Procurement → Budget +2. Нажать "Add budget" +3. Заполнить форму: + - **Department** — выбрать отдел (IT, HR, финансы и т.д.) + - **Year** — год (2025, 2026) + - **Quarter** — квартал (1, 2, 3, 4) + - **Allocated amount** — сумма выделенного бюджета в рублях + - **Spent amount** — (заполняется автоматически, если оставить 0) + +### 2️⃣ **REST API (для приложений)** + +**Создание бюджета:** +```bash +POST /api/procurement/budgets/ +Content-Type: application/json + +{ + "department": 5, # ID отдела + "year": 2025, + "quarter": 4, + "allocated_amount": "500000.00" +} +``` + +**Редактирование:** +```bash +PATCH /api/procurement/budgets/1/ +{ + "allocated_amount": "600000.00" +} +``` + +**Получение текущего квартала:** +```bash +GET /api/procurement/budgets/current_quarter/ +``` + +**Получить бюджет своего отдела:** +```bash +GET /api/procurement/budgets/my-department/ +``` + +## Структура модели Budget + +```python +class Budget(models.Model): + department # ForeignKey на Department + year # 2025, 2026, ... + quarter # 1, 2, 3, 4 + allocated_amount # Выделено денег (основное число!) + spent_amount # Потрачено (меняется при одобрении заявок) + created_at # Когда создан + updated_at # Когда обновлён +``` + +**Ключевое свойство:** `(department, year, quarter)` — уникальный набор! +``` +Нельзя создать два бюджета для одного отдела на один период! +``` + +## Примеры бюджетов + +### Компания с 3 отделами на Q4 2025: + +``` +┌────────────────┬──────┬─────────┬──────────────┐ +│ Отдел │ 2025 │ Q4 │ Бюджет │ +├────────────────┼──────┼─────────┼──────────────┤ +│ IT │ 2025 │ 4 │ 1,000,000₽ │ +│ HR │ 2025 │ 4 │ 300,000₽ │ +│ Финансы │ 2025 │ 4 │ 500,000₽ │ +│ Маркетинг │ 2025 │ 4 │ 250,000₽ │ +└────────────────┴──────┴─────────┴──────────────┘ +``` + +## Как работает проверка бюджета? + +Когда пользователь отправляет заявку на согласование: + +```python +# 1. Система получает текущий квартал +now = timezone.now() +quarter = (now.month - 1) // 3 + 1 + +# 2. Ищет бюджет отдела на этот период +budget = Budget.objects.get( + department=procurement_request.department, + year=now.year, + quarter=quarter +) + +# 3. Проверяет остаток +remaining = budget.remaining_amount # allocated - spent +if remaining >= procurement_request.estimated_cost: + # ✅ Можно отправить на согласование +else: + # ❌ Недостаточно бюджета + return error +``` + +## Ключевые свойства Budget + +```python +# Остаток бюджета +remaining_amount = allocated_amount - spent_amount + +# Зарезервировано (pending заявки) +reserved_amount = sum(pending_requests.estimated_cost) + +# Доступно с учётом резерва +available_amount = remaining_amount - reserved_amount + +# Процент использования +utilization_percentage = (spent_amount / allocated_amount) * 100 + +# Можно ли потратить X? +can_spend(amount: Decimal) → bool +``` + +## Пример в коде + +```python +# Проверка в views.py при отправке заявки +available, remaining = procurement_request.check_budget_available() + +if not available: + return Response({ + 'error': 'Недостаточно бюджета', + 'required': float(procurement_request.estimated_cost), + 'remaining': float(remaining), + }, status=400) +``` + +## Алерты при низком бюджете + +Система автоматически отправляет уведомления руководителю отдела: + +```python +# budget_low (остаток < 20%) +"Бюджет отдела снижается. Остаток: 45,000₽ (18%)" + +# budget_critical (остаток < 10%) +"⚠️ Критически низкий бюджет! Остаток: 8,000₽ (3%)" +``` + +**Когда:** При одобрении заявки если бюджет падает ниже порогов + +## Как менять бюджет? + +### ✅ Правильно (сумма выделенного): +``` +Отдел IT на Q4 2025: 1,000,000₽ +Потом изменили: 1,500,000₽ (увеличили бюджет) +``` + +### ❌ Неправильно (not spent_amount): +``` +❌ Не меняйте spent_amount вручную - это меняется автоматически +``` + +## Права доступа + +| Action | Кто может | +|--------|-----------| +| Создать бюджет | `CanManageBudget` (админ) | +| Редактировать | `CanManageBudget` (админ) | +| Удалить | `CanManageBudget` (админ) | +| Просмотреть свой | Руководитель отдела | +| Просмотреть все | Суперпользователь/Админ | + +## Типичный цикл + +``` +Q1 2025: Выделили IT-отделу 1,000,000₽ + ↓ +Заявка #1: 50,000₽ → одобрена +Заявка #2: 100,000₽ → одобрена +Zaявка #3: 30,000₽ → одобрена + ↓ +Потрачено: 180,000₽ +Остаток: 820,000₽ +Использовано: 18% + ↓ +Q2 2025: Новый квартал, новый бюджет +``` + +## SQL для статистики + +```sql +-- Сколько потрачено каждый отдел на Q4 2025? +SELECT + d.name, + b.allocated_amount, + b.spent_amount, + (b.allocated_amount - b.spent_amount) as remaining +FROM procurement_budget b +JOIN employees_department d ON b.department_id = d.id +WHERE b.year = 2025 AND b.quarter = 4; + +-- Самый загруженный отдел по бюджету +SELECT + d.name, + b.allocated_amount, + (b.spent_amount::float / b.allocated_amount * 100) as utilization +FROM procurement_budget b +JOIN employees_department d ON b.department_id = d.id +WHERE b.year = 2025 +ORDER BY utilization DESC; +``` + +## Интеграция с заявками + +``` +ProcurementRequest (заявка) + ├─ department_id + ├─ estimated_cost + └─ check_budget_available() + ↓ ищет + Budget (бюджет) + ├─ department + ├─ allocated_amount + └─ spent_amount +``` + +## Автоматические обновления spent_amount + +```python +# На данный момент spent_amount меняется вручную в: +# - Django Admin +# - API PATCH запрос + +# В будущем можно автоматизировать при: +# - Завершении заявки (status=COMPLETED) +# - Поступлении счёта (через другую модель Invoice) +``` diff --git a/PROCUREMENT_APPROVERS.md b/PROCUREMENT_APPROVERS.md new file mode 100644 index 00000000..cb03d27b --- /dev/null +++ b/PROCUREMENT_APPROVERS.md @@ -0,0 +1,160 @@ +# Механизм назначения согласующих в заявках на закупку + +## Как это работает + +### 1. Определение требуемых ролей (модель `ProcurementRequest`) + +Когда пользователь отправляет заявку на согласование (`submit` action), система определяет какие роли нужны: + +```python +# в procurement/models.py - метод get_required_approvals() + +cost = estimated_cost # Сумма заявки + +if cost < 10,000₽: # НИЗКАЯ + → [DEPARTMENT_HEAD] # Только руководитель отдела + +elif cost < 100,000₽: # СРЕДНЯЯ + → [DEPARTMENT_HEAD, # Руководитель отдела + + FINANCE_MANAGER] # Финансовый менеджер + +else: # ВЫСОКАЯ (> 100K) + → [DEPARTMENT_HEAD, # Руководитель отдела + + FINANCE_MANAGER, # Финансовый менеджер + + DIRECTOR] # Генеральный директор +``` + +### 2. Поиск конкретных людей (вьюсет `ProcurementRequestViewSet.submit()`) + +После определения ролей система ищет людей, занимающих эти должности: + +```python +for role in required_approvals: + approver = None + + if role == DEPARTMENT_HEAD: + # ✅ Берёт руководителя из department.head + approver = procurement_request.department.head + + elif role == FINANCE_MANAGER: + # ✅ Ищет пользователя с правом change_budget + approver = Employee.objects.filter( + is_active=True, + groups__permissions__codename='change_budget' + ).first() + + elif role == DIRECTOR: + # ✅ Берёт первого суперпользователя + approver = Employee.objects.filter( + is_superuser=True + ).first() + + # Создаём запись согласования + Approval.objects.create( + request=procurement_request, + approver=approver, + role=role, + status=ApprovalStatus.PENDING + ) +``` + +## Пример + +### Заявка на 50,000₽ от отдела IT + +``` +Сумма: 50,000₽ +Отдел: IT (руководитель = Петр Петров) + +┌─────────────────────────────────────────┐ +│ Определение ролей (get_required_approvals) +│ 10,000 < 50,000 < 100,000 +│ → [DEPARTMENT_HEAD, FINANCE_MANAGER] +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Поиск согласующих (submit action) │ +├─────────────────────────────────────────┤ +│ DEPARTMENT_HEAD │ +│ → IT.head = Петр Петров ✓ │ +│ │ +│ FINANCE_MANAGER │ +│ → Permission(change_budget) │ +│ → Мария Сидорова ✓ │ +└─────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────┐ +│ Создание записей согласования │ +├─────────────────────────────────────────┤ +│ Approval #1: Петр Петров (PENDING) │ +│ Approval #2: Мария Сидорова (PENDING) │ +└─────────────────────────────────────────┘ + ↓ + Уведомления + ↙ ↖ + Петр Мария +``` + +## Где это конфигурируется? + +| Компонент | Где конфигурируется | Как | +|-----------|-------------------|-----| +| **Пороги сумм** | `procurement/constants.py` | APPROVAL_THRESHOLD_LOW, APPROVAL_THRESHOLD_HIGH | +| **Руководитель отдела** | Django Admin → Departments | Выбрать `head` в модели Department | +| **Финансовый менеджер** | Django Admin → Permissions | Дать permission `change_budget` пользователю или группе | +| **Директор** | Django Admin → Users | Сделать пользователя суперпользователем (`is_superuser=True`) | + +## Потенциальные проблемы и решения + +### 1. "Финансовый менеджер не найдена" +```python +# Причина: нет пользователя с правом change_budget +# Решение: +Django Admin → Users → выбрать пользователя → добавить permission change_budget +``` + +### 2. "Директор не найден" +```python +# Причина: нет суперпользователей в системе +# Решение: +Django Admin → Users → выбрать пользователя → поставить галочку is_superuser +``` + +### 3. Разные согласующие для разных отделов +```python +# Текущая реализация: один финменеджер и один директор на всю компанию +# Чтобы сделать разных: +# 1. Создать модель ApprovalChain или DepartmentApprovalPolicy +# 2. Связать со своими финменеджерами и директорами +# 3. Изменить логику поиска в submit action +``` + +## API для отладки + +### Проверить какие роли требуются для заявки +```bash +GET /api/procurement/requests/{id}/ +# Ответ содержит поле "required_approvals" если его добавить в сериализатор +``` + +### Узнать кто назначен согласующим +```bash +GET /api/procurement/requests/{id}/ +# approvals: [ +# { id, approver, role, status, comment } +# ] +``` + +## Параметры для настройки + +```python +# backend/procurement/constants.py + +APPROVAL_THRESHOLD_LOW = 10_000 # ← Можно менять +APPROVAL_THRESHOLD_HIGH = 50_000 # ← Можно менять + +# Порог | Рол требующиеся +# < 10K | Руководитель отдела +# 10K - 50K | Руководитель + Финансы +# > 50K | Руководитель + Финансы + Директор +``` diff --git a/backend/procurement/admin.py b/backend/procurement/admin.py index c72bd33e..9151c775 100644 --- a/backend/procurement/admin.py +++ b/backend/procurement/admin.py @@ -335,7 +335,7 @@ class BudgetAdmin(admin.ModelAdmin): "quarter", "allocated_amount", "spent_amount", - "remaining_amount", + "get_remaining_amount", "utilization_badge", ] list_filter = ["year", "quarter", "department"] @@ -343,10 +343,20 @@ class BudgetAdmin(admin.ModelAdmin): readonly_fields = [ "created_at", "updated_at", - "remaining_amount", - "utilization_percentage", + "get_remaining_amount", + "get_utilization_percentage", ] + def get_remaining_amount(self, obj): + """Остаток бюджета (для отображения в админке).""" + return obj.remaining_amount + get_remaining_amount.short_description = "Остаток" + + def get_utilization_percentage(self, obj): + """Процент использования (для отображения в админке).""" + return f"{obj.utilization_percentage}%" + get_utilization_percentage.short_description = "Использовано" + def utilization_badge(self, obj): """Цветной бадж использования бюджета.""" percentage = obj.utilization_percentage diff --git a/backend/procurement/models.py b/backend/procurement/models.py index f6fdbf04..88f30f68 100644 --- a/backend/procurement/models.py +++ b/backend/procurement/models.py @@ -618,7 +618,8 @@ class Budget(models.Model): @property def remaining_amount(self): """Остаток бюджета.""" - return self.allocated_amount - self.spent_amount + allocated = self.allocated_amount or Decimal('0.00') + return allocated - self.spent_amount @property def reserved_amount(self): @@ -648,10 +649,11 @@ class Budget(models.Model): @property def utilization_percentage(self): """Процент использования бюджета.""" - if self.allocated_amount == 0: + allocated = self.allocated_amount or Decimal('0.00') + if allocated == 0: return Decimal('0.00') return ( - self.spent_amount / self.allocated_amount * 100 + self.spent_amount / allocated * 100 ).quantize(Decimal('0.01')) def can_spend(self, amount): diff --git a/backend/procurement/views_frontend.py b/backend/procurement/views_frontend.py index 25834f11..84fa6a18 100644 --- a/backend/procurement/views_frontend.py +++ b/backend/procurement/views_frontend.py @@ -180,7 +180,7 @@ def request_detail(request, pk): ProcurementRequest.objects.select_related( 'department', 'requestor' ).prefetch_related( - 'items__supplier', + 'items', 'approvals__approver' ), pk=pk