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
## 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 Дата при открытии формы, без лишней пустой строки при редактировании
**Проблема**: При открытии формы редактирования заказа поле «Дата» не подставлялось из БД; в табличной части вместе с существующими строками отображалась лишняя пустая строка.

View File

@@ -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}),

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."""
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:

View File

@@ -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"),

View File

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

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

View File

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