Feature: экономика заказа клиента — связи с поставщиком и расходами, отчёт по марже и рентабельности
Made-with: Cursor
This commit is contained in:
15
HISTORY.md
15
HISTORY.md
@@ -1,5 +1,20 @@
|
||||
# История изменений ERP WaterSurf
|
||||
|
||||
## 2025-02-26 00:15 UTC – Экономика заказа клиента: связи и отчёт
|
||||
|
||||
**Задача**: Цепочка «Заказ клиента → Поступления денежных средств (несколько) → Заказ поставщику по заказу → Расход на оплату поставщику; плюс прочие расходы по заказу (логистика)». По заказу клиента видеть: сколько поступило, сколько потрачено, маржа, маржинальность, рентабельность.
|
||||
|
||||
**Решение**:
|
||||
- В модель **SupplierOrder** добавлено поле **customer_order** (FK на CustomerOrder, null/blank) — заказ поставщику оформляется по заказу клиента.
|
||||
- В модель **CashExpense** добавлено поле **customer_order** (FK на CustomerOrder, null/blank) — расходы по заказу клиента без привязки к заказу поставщику (логистика и т.п.).
|
||||
- В формах заказа поставщику и расхода денежных средств добавлены поля выбора заказа клиента; в шаблоне заказа поставщику выведено поле «Заказ клиента».
|
||||
- В модель **CustomerOrder** добавлен метод **get_economics()**: сумма поступлений (cash_inflows), расходы через заказы поставщику (CashExpense по supplier_order.customer_order), прямые расходы по заказу (cash_expenses), итого расходов, маржа, маржинальность % (маржа/поступления), рентабельность % (маржа/расходы).
|
||||
- Добавлена страница **Просмотр заказа клиента** (CustomerOrderDetail): данные заказа, блок «Экономика заказа» (таблица показателей), списки поступлений, заказов поставщику и прочих расходов по заказу с ссылками на создание/редактирование. В списке заказов клиента номер ведёт на эту страницу.
|
||||
|
||||
**Изменения**: documents/models.py (customer_order в SupplierOrder и CashExpense, get_economics), миграция 0002_add_customer_order_links, documents/forms.py (поля customer_order), supplier_order_form.html (поле заказ клиента), documents/views.py (CustomerOrderDetail), documents/urls.py, шаблон customer_order_detail.html, customer_order_list.html (ссылка с номера на просмотр).
|
||||
|
||||
---
|
||||
|
||||
## 2025-02-25 23:55 UTC – Дата при открытии формы, без лишней пустой строки при редактировании
|
||||
|
||||
**Проблема**: При открытии формы редактирования заказа поле «Дата» не подставлялось из БД; в табличной части вместе с существующими строками отображалась лишняя пустая строка.
|
||||
|
||||
@@ -82,7 +82,7 @@ class CustomerOrderForm(forms.ModelForm):
|
||||
class SupplierOrderForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = SupplierOrder
|
||||
fields = ("date", "number", "organization", "supplier", "currency", "rate", "author")
|
||||
fields = ("date", "number", "organization", "supplier", "customer_order", "currency", "rate", "author")
|
||||
widgets = {
|
||||
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"number": forms.TextInput(attrs={"size": 15, "maxlength": 15}),
|
||||
@@ -112,7 +112,7 @@ class CashTransferForm(forms.ModelForm):
|
||||
class CashExpenseForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = CashExpense
|
||||
fields = ("date", "number", "sender", "amount", "supplier_order", "comment", "author")
|
||||
fields = ("date", "number", "sender", "amount", "supplier_order", "customer_order", "comment", "author")
|
||||
widgets = {
|
||||
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"number": forms.TextInput(attrs={"size": 15, "maxlength": 15}),
|
||||
|
||||
24
app/documents/migrations/0002_add_customer_order_links.py
Normal file
24
app/documents/migrations/0002_add_customer_order_links.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('documents', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='supplierorder',
|
||||
name='customer_order',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='supplier_orders', to='documents.customerorder', verbose_name='Заказ клиента'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cashexpense',
|
||||
name='customer_order',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cash_expenses', to='documents.customerorder', verbose_name='Заказ клиента'),
|
||||
),
|
||||
]
|
||||
@@ -2,6 +2,7 @@
|
||||
"""Документы ERP WaterSurf."""
|
||||
from decimal import Decimal
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
from references.models import Currency, OrderKind, Client, Organization, Supplier, Employee, CashAccount, Product
|
||||
|
||||
class CustomerOrder(models.Model):
|
||||
@@ -18,6 +19,25 @@ class CustomerOrder(models.Model):
|
||||
def __str__(self): return f"{self.number} от {self.date}"
|
||||
def recalc_total(self): self.total_amount = sum((i.price * i.quantity for i in self.items.all()), Decimal("0"))
|
||||
|
||||
def get_economics(self):
|
||||
"""Сводка по поступлениям и расходам по заказу: поступления, расходы (через заказы поставщику + прямые), маржа, маржинальность, рентабельность."""
|
||||
total_inflows = self.cash_inflows.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
expenses_via_supplier = CashExpense.objects.filter(supplier_order__customer_order=self).aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
expenses_direct = self.cash_expenses.aggregate(s=Sum("amount"))["s"] or Decimal("0")
|
||||
total_expenses = expenses_via_supplier + expenses_direct
|
||||
margin = total_inflows - total_expenses
|
||||
margin_pct = (margin / total_inflows * 100) if total_inflows else None
|
||||
profitability_pct = (margin / total_expenses * 100) if total_expenses else None
|
||||
return {
|
||||
"total_inflows": total_inflows,
|
||||
"expenses_via_supplier": expenses_via_supplier,
|
||||
"expenses_direct": expenses_direct,
|
||||
"total_expenses": total_expenses,
|
||||
"margin": margin,
|
||||
"margin_pct": margin_pct,
|
||||
"profitability_pct": profitability_pct,
|
||||
}
|
||||
|
||||
class CustomerOrderItem(models.Model):
|
||||
document = models.ForeignKey(CustomerOrder, on_delete=models.CASCADE, related_name="items", verbose_name="Заказ")
|
||||
product = models.ForeignKey(Product, on_delete=models.PROTECT, verbose_name="Товар")
|
||||
@@ -38,6 +58,7 @@ class SupplierOrder(models.Model):
|
||||
number = models.CharField("Номер", max_length=50)
|
||||
organization = models.ForeignKey(Organization, on_delete=models.PROTECT, verbose_name="Организация")
|
||||
supplier = models.ForeignKey(Supplier, on_delete=models.PROTECT, verbose_name="Поставщик")
|
||||
customer_order = models.ForeignKey(CustomerOrder, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Заказ клиента", related_name="supplier_orders")
|
||||
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, verbose_name="Валюта")
|
||||
rate = models.DecimalField("Курс валюты", max_digits=18, decimal_places=6, default=Decimal("1"))
|
||||
total_in_currency = models.DecimalField("Стоимость в валюте", max_digits=18, decimal_places=2, default=Decimal("0"), editable=False)
|
||||
@@ -97,6 +118,7 @@ class CashExpense(models.Model):
|
||||
sender = models.ForeignKey(CashAccount, on_delete=models.PROTECT, verbose_name="Отправитель", related_name="expenses")
|
||||
amount = models.DecimalField("Сумма", max_digits=18, decimal_places=2, default=Decimal("0"))
|
||||
supplier_order = models.ForeignKey(SupplierOrder, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Заказ поставщику", related_name="cash_expenses")
|
||||
customer_order = models.ForeignKey(CustomerOrder, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Заказ клиента", related_name="cash_expenses")
|
||||
comment = models.CharField("Комментарий", max_length=500, blank=True)
|
||||
author = models.ForeignKey(Employee, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Автор", related_name="cash_expenses")
|
||||
class Meta:
|
||||
|
||||
@@ -8,6 +8,7 @@ urlpatterns = [
|
||||
path("customer-orders/create/", views.CustomerOrderCreate.as_view(), name="customer_order_create"),
|
||||
path("customer-orders/<int:pk>/edit/", views.CustomerOrderUpdate.as_view(), name="customer_order_edit"),
|
||||
path("customer-orders/<int:pk>/delete/", views.CustomerOrderDelete.as_view(), name="customer_order_delete"),
|
||||
path("customer-orders/<int:pk>/", views.CustomerOrderDetail.as_view(), name="customer_order_detail"),
|
||||
path("supplier-orders/", views.SupplierOrderList.as_view(), name="supplier_order_list"),
|
||||
path("supplier-orders/create/", views.SupplierOrderCreate.as_view(), name="supplier_order_create"),
|
||||
path("supplier-orders/<int:pk>/edit/", views.SupplierOrderUpdate.as_view(), name="supplier_order_edit"),
|
||||
|
||||
@@ -107,6 +107,17 @@ class CustomerOrderDelete(LoginRequiredMixin, DeleteView):
|
||||
context_object_name = "object"
|
||||
|
||||
|
||||
class CustomerOrderDetail(LoginRequiredMixin, DetailView):
|
||||
model = CustomerOrder
|
||||
template_name = "documents/customer_order_detail.html"
|
||||
context_object_name = "object"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["economics"] = self.object.get_economics()
|
||||
return ctx
|
||||
|
||||
|
||||
# --- Заказы поставщику ---
|
||||
class SupplierOrderList(LoginRequiredMixin, ListView):
|
||||
model = SupplierOrder
|
||||
|
||||
157
app/templates/documents/customer_order_detail.html
Normal file
157
app/templates/documents/customer_order_detail.html
Normal file
@@ -0,0 +1,157 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Заказ {{ object.number }} — ERP WaterSurf{% endblock %}
|
||||
{% block content %}
|
||||
<div class="ws-card">
|
||||
<div class="ws-card-header">
|
||||
<h2 class="ws-page-title mb-0">Заказ клиента {{ object.number }} от {{ object.date }}</h2>
|
||||
<div class="ws-btn-group">
|
||||
<a href="{% url 'documents:customer_order_edit' object.pk %}" class="btn btn-ws-secondary">Изменить</a>
|
||||
<a href="{% url 'documents:customer_order_delete' object.pk %}" class="btn btn-ws-secondary ws-link-danger">Удалить</a>
|
||||
<a href="{% url 'documents:customer_order_list' %}" class="btn btn-ws-secondary">К списку</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-form-section" style="margin-top: 1rem;">
|
||||
<h3 class="ws-form-section-title">Данные заказа</h3>
|
||||
<p class="mb-1"><strong>Организация:</strong> {{ object.organization }}</p>
|
||||
<p class="mb-1"><strong>Клиент:</strong> {{ object.client }}</p>
|
||||
<p class="mb-1"><strong>Вид заказа:</strong> {{ object.order_kind }}</p>
|
||||
<p class="mb-1"><strong>Стоимость заказа:</strong> {{ object.total_amount|floatformat:2 }}</p>
|
||||
</div>
|
||||
|
||||
<div class="ws-form-section" style="margin-top: 1.5rem;">
|
||||
<h3 class="ws-form-section-title">Экономика заказа</h3>
|
||||
<table class="ws-table" style="max-width: 32rem;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Поступило от клиента (всего поступлений по заказу)</td>
|
||||
<td class="ws-num">{{ economics.total_inflows|floatformat:2 }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Расходы: оплата поставщикам (по заказам поставщику по этому заказу)</td>
|
||||
<td class="ws-num">−{{ economics.expenses_via_supplier|floatformat:2 }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Расходы: прочие по заказу (логистика и т.п.)</td>
|
||||
<td class="ws-num">−{{ economics.expenses_direct|floatformat:2 }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Всего расходов</strong></td>
|
||||
<td class="ws-num"><strong>−{{ economics.total_expenses|floatformat:2 }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Маржа (поступления − расходы)</strong></td>
|
||||
<td class="ws-num"><strong>{{ economics.margin|floatformat:2 }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Маржинальность (маржа / поступления)</td>
|
||||
<td class="ws-num">{% if economics.margin_pct is not None %}{{ economics.margin_pct|floatformat:1 }}%{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Рентабельность (маржа / расходы)</td>
|
||||
<td class="ws-num">{% if economics.profitability_pct is not None %}{{ economics.profitability_pct|floatformat:1 }}%{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="ws-form-section" style="margin-top: 1.5rem;">
|
||||
<h3 class="ws-form-section-title">Поступления по заказу</h3>
|
||||
{% if object.cash_inflows.all %}
|
||||
<div class="ws-table-wrap">
|
||||
<table class="ws-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Номер</th>
|
||||
<th>Получатель</th>
|
||||
<th class="ws-num">Сумма</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inv in object.cash_inflows.all %}
|
||||
<tr>
|
||||
<td>{{ inv.date }}</td>
|
||||
<td>{{ inv.number }}</td>
|
||||
<td>{{ inv.recipient }}</td>
|
||||
<td class="ws-num">{{ inv.amount|floatformat:2 }}</td>
|
||||
<td><a href="{% url 'documents:cash_inflow_edit' inv.pk %}" class="ws-link">Изменить</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="ws-text-muted">Нет поступлений, привязанных к этому заказу.</p>
|
||||
{% endif %}
|
||||
<p style="margin-top: 0.5rem;"><a href="{% url 'documents:cash_inflow_create' %}" class="ws-link">+ Оформить поступление</a></p>
|
||||
</div>
|
||||
|
||||
<div class="ws-form-section" style="margin-top: 1.5rem;">
|
||||
<h3 class="ws-form-section-title">Заказы поставщику по этому заказу</h3>
|
||||
{% if object.supplier_orders.all %}
|
||||
<div class="ws-table-wrap">
|
||||
<table class="ws-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Номер</th>
|
||||
<th>Поставщик</th>
|
||||
<th class="ws-num">Стоимость</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for so in object.supplier_orders.all %}
|
||||
<tr>
|
||||
<td>{{ so.date }}</td>
|
||||
<td>{{ so.number }}</td>
|
||||
<td>{{ so.supplier }}</td>
|
||||
<td class="ws-num">{{ so.total_amount|floatformat:2 }}</td>
|
||||
<td><a href="{% url 'documents:supplier_order_edit' so.pk %}" class="ws-link">Изменить</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="ws-text-muted">Нет заказов поставщику по этому заказу.</p>
|
||||
{% endif %}
|
||||
<p style="margin-top: 0.5rem;"><a href="{% url 'documents:supplier_order_create' %}" class="ws-link">+ Создать заказ поставщику</a></p>
|
||||
</div>
|
||||
|
||||
<div class="ws-form-section" style="margin-top: 1.5rem;">
|
||||
<h3 class="ws-form-section-title">Прочие расходы по заказу (логистика и т.п.)</h3>
|
||||
{% if object.cash_expenses.all %}
|
||||
<div class="ws-table-wrap">
|
||||
<table class="ws-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Номер</th>
|
||||
<th>Отправитель</th>
|
||||
<th class="ws-num">Сумма</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ex in object.cash_expenses.all %}
|
||||
<tr>
|
||||
<td>{{ ex.date }}</td>
|
||||
<td>{{ ex.number }}</td>
|
||||
<td>{{ ex.sender }}</td>
|
||||
<td class="ws-num">{{ ex.amount|floatformat:2 }}</td>
|
||||
<td><a href="{% url 'documents:cash_expense_edit' ex.pk %}" class="ws-link">Изменить</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="ws-text-muted">Нет прочих расходов, привязанных к этому заказу.</p>
|
||||
{% endif %}
|
||||
<p style="margin-top: 0.5rem;"><a href="{% url 'documents:cash_expense_create' %}" class="ws-link">+ Оформить расход</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -23,7 +23,7 @@
|
||||
{% for obj in object_list %}
|
||||
<tr>
|
||||
<td>{{ obj.date }}</td>
|
||||
<td>{{ obj.number }}</td>
|
||||
<td><a href="{% url 'documents:customer_order_detail' obj.pk %}" class="ws-link">{{ obj.number }}</a></td>
|
||||
<td>{{ obj.order_kind }}</td>
|
||||
<td>{{ obj.organization }}</td>
|
||||
<td>{{ obj.client }}</td>
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
{% if form.supplier.errors %}<small class="ws-text-danger">{{ form.supplier.errors.0 }}</small>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-form-row ws-form-row-2">
|
||||
<div class="ws-form-group">
|
||||
<label for="{{ form.customer_order.id_for_label }}">{{ form.customer_order.label }}</label>
|
||||
{{ form.customer_order }}
|
||||
{% if form.customer_order.errors %}<small class="ws-text-danger">{{ form.customer_order.errors.0 }}</small>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-form-row ws-form-row-2">
|
||||
<div class="ws-form-group">
|
||||
<label for="{{ form.currency.id_for_label }}">{{ form.currency.label }}</label>
|
||||
|
||||
Reference in New Issue
Block a user