Feature: экономика заказа клиента — связи с поставщиком и расходами, отчёт по марже и рентабельности

Made-with: Cursor
This commit is contained in:
2026-02-26 14:52:08 +00:00
parent 8145db86e3
commit 8fcecb558d
9 changed files with 240 additions and 3 deletions

View File

@@ -1,5 +1,20 @@
# История изменений ERP WaterSurf # История изменений 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 Дата при открытии формы, без лишней пустой строки при редактировании ## 2025-02-25 23:55 UTC Дата при открытии формы, без лишней пустой строки при редактировании
**Проблема**: При открытии формы редактирования заказа поле «Дата» не подставлялось из БД; в табличной части вместе с существующими строками отображалась лишняя пустая строка. **Проблема**: При открытии формы редактирования заказа поле «Дата» не подставлялось из БД; в табличной части вместе с существующими строками отображалась лишняя пустая строка.

View File

@@ -82,7 +82,7 @@ class CustomerOrderForm(forms.ModelForm):
class SupplierOrderForm(forms.ModelForm): class SupplierOrderForm(forms.ModelForm):
class Meta: class Meta:
model = SupplierOrder model = SupplierOrder
fields = ("date", "number", "organization", "supplier", "currency", "rate", "author") fields = ("date", "number", "organization", "supplier", "customer_order", "currency", "rate", "author")
widgets = { widgets = {
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), "date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"number": forms.TextInput(attrs={"size": 15, "maxlength": 15}), "number": forms.TextInput(attrs={"size": 15, "maxlength": 15}),
@@ -112,7 +112,7 @@ class CashTransferForm(forms.ModelForm):
class CashExpenseForm(forms.ModelForm): class CashExpenseForm(forms.ModelForm):
class Meta: class Meta:
model = CashExpense model = CashExpense
fields = ("date", "number", "sender", "amount", "supplier_order", "comment", "author") fields = ("date", "number", "sender", "amount", "supplier_order", "customer_order", "comment", "author")
widgets = { widgets = {
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), "date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
"number": forms.TextInput(attrs={"size": 15, "maxlength": 15}), "number": forms.TextInput(attrs={"size": 15, "maxlength": 15}),

View 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='Заказ клиента'),
),
]

View File

@@ -2,6 +2,7 @@
"""Документы ERP WaterSurf.""" """Документы ERP WaterSurf."""
from decimal import Decimal from decimal import Decimal
from django.db import models from django.db import models
from django.db.models import Sum
from references.models import Currency, OrderKind, Client, Organization, Supplier, Employee, CashAccount, Product from references.models import Currency, OrderKind, Client, Organization, Supplier, Employee, CashAccount, Product
class CustomerOrder(models.Model): class CustomerOrder(models.Model):
@@ -18,6 +19,25 @@ class CustomerOrder(models.Model):
def __str__(self): return f"{self.number} от {self.date}" 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 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): class CustomerOrderItem(models.Model):
document = models.ForeignKey(CustomerOrder, on_delete=models.CASCADE, related_name="items", verbose_name="Заказ") document = models.ForeignKey(CustomerOrder, on_delete=models.CASCADE, related_name="items", verbose_name="Заказ")
product = models.ForeignKey(Product, on_delete=models.PROTECT, 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) number = models.CharField("Номер", max_length=50)
organization = models.ForeignKey(Organization, on_delete=models.PROTECT, verbose_name="Организация") organization = models.ForeignKey(Organization, on_delete=models.PROTECT, verbose_name="Организация")
supplier = models.ForeignKey(Supplier, 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="Валюта") currency = models.ForeignKey(Currency, on_delete=models.PROTECT, verbose_name="Валюта")
rate = models.DecimalField("Курс валюты", max_digits=18, decimal_places=6, default=Decimal("1")) 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) 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") 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")) 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") 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) 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") author = models.ForeignKey(Employee, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Автор", related_name="cash_expenses")
class Meta: class Meta:

View File

@@ -8,6 +8,7 @@ urlpatterns = [
path("customer-orders/create/", views.CustomerOrderCreate.as_view(), name="customer_order_create"), 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>/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>/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/", views.SupplierOrderList.as_view(), name="supplier_order_list"),
path("supplier-orders/create/", views.SupplierOrderCreate.as_view(), name="supplier_order_create"), 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"), path("supplier-orders/<int:pk>/edit/", views.SupplierOrderUpdate.as_view(), name="supplier_order_edit"),

View File

@@ -107,6 +107,17 @@ class CustomerOrderDelete(LoginRequiredMixin, DeleteView):
context_object_name = "object" 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): class SupplierOrderList(LoginRequiredMixin, ListView):
model = SupplierOrder model = SupplierOrder

View 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 %}

View File

@@ -23,7 +23,7 @@
{% for obj in object_list %} {% for obj in object_list %}
<tr> <tr>
<td>{{ obj.date }}</td> <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.order_kind }}</td>
<td>{{ obj.organization }}</td> <td>{{ obj.organization }}</td>
<td>{{ obj.client }}</td> <td>{{ obj.client }}</td>

View File

@@ -29,6 +29,13 @@
{% if form.supplier.errors %}<small class="ws-text-danger">{{ form.supplier.errors.0 }}</small>{% endif %} {% if form.supplier.errors %}<small class="ws-text-danger">{{ form.supplier.errors.0 }}</small>{% endif %}
</div> </div>
</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-row ws-form-row-2">
<div class="ws-form-group"> <div class="ws-form-group">
<label for="{{ form.currency.id_for_label }}">{{ form.currency.label }}</label> <label for="{{ form.currency.id_for_label }}">{{ form.currency.label }}</label>