fix: исправить ошибки в админке Budget

- Убрать remaining_amount и utilization_percentage из list_display (это @property)
- Добавить методы get_remaining_amount и get_utilization_percentage
- Защитить от None в allocated_amount
- Исправить prefetch_related в views_frontend
This commit is contained in:
admin i 2025-12-10 07:16:00 +03:00
parent ccbc8c5452
commit 46cb319903
5 changed files with 425 additions and 7 deletions

246
BUDGET_SYSTEM.md Normal file
View File

@ -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)
```

160
PROCUREMENT_APPROVERS.md Normal file
View File

@ -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 | Руководитель + Финансы + Директор
```

View File

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

View File

@ -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):

View File

@ -180,7 +180,7 @@ def request_detail(request, pk):
ProcurementRequest.objects.select_related(
'department', 'requestor'
).prefetch_related(
'items__supplier',
'items',
'approvals__approver'
),
pk=pk