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:
parent
ccbc8c5452
commit
46cb319903
246
BUDGET_SYSTEM.md
Normal file
246
BUDGET_SYSTEM.md
Normal 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
160
PROCUREMENT_APPROVERS.md
Normal 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 | Руководитель + Финансы + Директор
|
||||
```
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -180,7 +180,7 @@ def request_detail(request, pk):
|
||||
ProcurementRequest.objects.select_related(
|
||||
'department', 'requestor'
|
||||
).prefetch_related(
|
||||
'items__supplier',
|
||||
'items',
|
||||
'approvals__approver'
|
||||
),
|
||||
pk=pk
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user