Docs: начальная структура проекта

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-25 14:59:46 +00:00
commit 1669c12182
56 changed files with 2085 additions and 0 deletions

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# База данных (для docker-compose)
POSTGRES_USER=watersurf_erp
POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
POSTGRES_DB=watersurf_erp
# Django
SECRET_KEY=CHANGE_ME_SECRET_KEY_MIN_50_CHARS
DEBUG=false
ALLOWED_HOSTS=erp.gen7x.ru,localhost,127.0.0.1,app
# Подключение к БД (приложение)
DB_HOST=db
DB_PORT=5432
DB_NAME=watersurf_erp
DB_USER=watersurf_erp
DB_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.env
*.pyc
__pycache__/
*.sqlite3
.git/
logs/*.log
app/logs/*.log
.venv/
venv/
*.egg-info/
.eggs/

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
ENV PYTHONPATH=/app/app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p logs app/logs
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "config.wsgi:application"]

24
HISTORY.md Normal file
View File

@@ -0,0 +1,24 @@
# История изменений ERP WaterSurf
## 2025-02-25 15:00 UTC Начальная структура проекта (MVP)
**Проблема**: необходимо развернуть систему класса ERP для WaterSurf с веб-доступом, авторизацией и хранением данных в БД.
**Решение**:
- Создан проект в `/opt/watersurf-erp`: Django 5, PostgreSQL 16, Docker Compose.
- Реализованы справочники: Валюты, Виды заказов, Клиенты, Организации, Поставщики, Сотрудники, Счета денежных средств, Товары (CRUD через веб и админку).
- Реализованы документы: Заказ клиента, Заказ поставщику (с табличными частями товаров), Поступление/Перемещение/Расход денежных средств (автогенерация номера).
- Связь пользователь → сотрудник (профиль) для автоматической подстановки автора в документах.
- Настроены Nginx (erp.gen7x.ru), добавлен volume в список бэкапов платформы.
**Изменения**:
- Структура приложения: config, references, documents, users; шаблоны, формы, представления; миграции.
- Файлы: README.md, HISTORY.md, .env.example, docker-compose.yml, Dockerfile, manage.sh, requirements.txt.
**Проверка**:
```bash
cd /opt/watersurf-erp
docker compose up -d
docker compose logs -f app
# Открыть https://erp.gen7x.ru/ (после перезагрузки Nginx)
```

81
README.md Normal file
View File

@@ -0,0 +1,81 @@
# ERP WaterSurf
Веб-приложение класса ERP для компании WaterSurf: справочники, документы (заказы клиентов и поставщикам, поступления/перемещения/расход денежных средств), авторизация пользователей.
## Требования
- Docker и Docker Compose
- Доступ к порту 8010 на localhost (для reverse proxy)
## Установка и настройка
```bash
cd /opt/watersurf-erp
cp .env.example .env
# Отредактировать .env: пароли, SECRET_KEY, ALLOWED_HOSTS
docker compose up -d db
docker compose run --rm app python manage.py migrate --noinput
docker compose run --rm app python manage.py createsuperuser # первый пользователь
docker compose up -d app
```
## Конфигурация
Переменные в `.env` (см. `.env.example`):
- `POSTGRES_*` — учётные данные PostgreSQL для контейнера db
- `DB_*` — подключение приложения к БД
- `SECRET_KEY` — секрет Django (не менее 50 символов)
- `ALLOWED_HOSTS` — через запятую (например `erp.gen7x.ru,localhost`)
- `DEBUG` — true/false
## Использование
- Веб-интерфейс: после настройки Nginx — https://erp.gen7x.ru/
- Вход по логину/паролю (пользователи Django).
- Меню: Справочники (валюты, виды заказов, клиенты, организации, поставщики, сотрудники, счета, товары), Документы (заказы клиентов, заказы поставщику, поступления, перемещения, расходы).
- Поле «Автор» в документах подставляется из профиля пользователя (связь User → Сотрудник в разделе «Пользователи» / админка).
## Структура проекта
- `app/config/` — настройки Django
- `app/references/` — справочники (модели, админка, CRUD)
- `app/documents/` — документы (модели, формы, представления, автогенерация номера)
- `app/users/` — профиль пользователя (связь с сотрудником)
- `app/templates/` — шаблоны
- `manage.py` — точка входа для команд Django
## Docker Compose
- Сервис `db`: PostgreSQL 16, volume `watersurf_erp_data`
- Сервис `app`: Django + Gunicorn, порт 8010:8000
Запуск: `./manage.sh start` или `docker compose up -d`
Остановка: `./manage.sh stop`
Логи: `./manage.sh logs app`
## Volumes
- `watersurf_erp_data` — данные PostgreSQL. Добавлен в `/opt/gen7x/backup/include-volumes.txt` для бэкапа.
## Логирование
- Логи приложения: stdout контейнера; при наличии каталога `app/logs` — также в `app/logs/app.log`.
- Просмотр: `docker compose logs -f app`
## Резервное копирование и восстановление
- Volume БД включён в список бэкапов платформы Gen7x. Восстановление — по инструкции платформы (restore.sh и т.д.).
## Troubleshooting
- Ошибка подключения к БД: проверить, что контейнер `db` запущен и переменные `DB_*` в `.env` совпадают с `POSTGRES_*`.
- 502 от Nginx: проверить, что приложение слушает на 8010 (`docker compose ps`, `docker compose logs app`).
- После добавления полей в модели: `docker compose run --rm app python manage.py makemigrations && docker compose run --rm app python manage.py migrate`.
## Команды управления
- Запуск: `./manage.sh start`
- Остановка: `./manage.sh stop`
- Перезапуск: `./manage.sh restart`
- Логи: `./manage.sh logs [service]`

0
app/__init__.py Normal file
View File

0
app/config/__init__.py Normal file
View File

117
app/config/settings.py Normal file
View File

@@ -0,0 +1,117 @@
"""
Настройки Django для ERP WaterSurf.
"""
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-change-me")
DEBUG = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"references",
"documents",
"users",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django.middleware.locale.LocaleMiddleware",
]
ROOT_URLCONF = "config.urls"
WSGI_APPLICATION = "config.wsgi.application"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("DB_NAME", "watersurf_erp"),
"USER": os.environ.get("DB_USER", "watersurf_erp"),
"PASSWORD": os.environ.get("DB_PASSWORD", ""),
"HOST": os.environ.get("DB_HOST", "localhost"),
"PORT": os.environ.get("DB_PORT", "5432"),
}
}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "ru-ru"
TIME_ZONE = "Europe/Moscow"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STATICFILES_DIRS = [BASE_DIR / "static"] if (BASE_DIR / "static").exists() else []
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGIN_URL = "users:login"
LOGIN_REDIRECT_URL = "users:home"
LOGOUT_REDIRECT_URL = "users:login"
# Логирование
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "{levelname} {asctime} {module} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
"file": {
"class": "logging.FileHandler",
"filename": BASE_DIR / "logs" / "app.log",
"formatter": "verbose",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}
_log_file = BASE_DIR / "logs" / "app.log"
if _log_file.parent.exists():
LOGGING["root"]["handlers"].append("file")

12
app/config/urls.py Normal file
View File

@@ -0,0 +1,12 @@
"""
Главный маршрутизатор URL.
"""
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("users.urls")),
path("references/", include("references.urls")),
path("documents/", include("documents.urls")),
]

8
app/config/wsgi.py Normal file
View File

@@ -0,0 +1,8 @@
"""
WSGI-конфигурация для ERP WaterSurf.
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()

View File

47
app/documents/admin.py Normal file
View File

@@ -0,0 +1,47 @@
from django.contrib import admin
from .models import (
CustomerOrder,
CustomerOrderItem,
SupplierOrder,
SupplierOrderItem,
CashInflow,
CashTransfer,
CashExpense,
)
class CustomerOrderItemInline(admin.TabularInline):
model = CustomerOrderItem
extra = 0
@admin.register(CustomerOrder)
class CustomerOrderAdmin(admin.ModelAdmin):
list_display = ("number", "date", "organization", "client", "total_amount")
inlines = [CustomerOrderItemInline]
class SupplierOrderItemInline(admin.TabularInline):
model = SupplierOrderItem
extra = 0
@admin.register(SupplierOrder)
class SupplierOrderAdmin(admin.ModelAdmin):
list_display = ("number", "date", "organization", "supplier", "total_in_currency", "total_amount")
inlines = [SupplierOrderItemInline]
@admin.register(CashInflow)
class CashInflowAdmin(admin.ModelAdmin):
list_display = ("number", "date", "recipient", "amount", "customer_order")
@admin.register(CashTransfer)
class CashTransferAdmin(admin.ModelAdmin):
list_display = ("number", "date", "sender", "recipient", "amount")
@admin.register(CashExpense)
class CashExpenseAdmin(admin.ModelAdmin):
list_display = ("number", "date", "sender", "amount", "supplier_order")

7
app/documents/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class DocumentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "documents"
verbose_name = "Документы"

58
app/documents/forms.py Normal file
View File

@@ -0,0 +1,58 @@
"""Формы документов."""
from django import forms
from .models import (
CustomerOrder,
CustomerOrderItem,
SupplierOrder,
SupplierOrderItem,
CashInflow,
CashTransfer,
CashExpense,
)
from django.forms import inlineformset_factory
CustomerOrderItemFormSet = inlineformset_factory(
CustomerOrder,
CustomerOrderItem,
fields=("product", "price", "currency", "quantity"),
extra=1,
can_delete=True,
)
SupplierOrderItemFormSet = inlineformset_factory(
SupplierOrder,
SupplierOrderItem,
fields=("product", "price", "currency", "quantity"),
extra=1,
can_delete=True,
)
class CustomerOrderForm(forms.ModelForm):
class Meta:
model = CustomerOrder
fields = ("date", "number", "order_kind", "organization", "client", "author")
class SupplierOrderForm(forms.ModelForm):
class Meta:
model = SupplierOrder
fields = ("date", "number", "organization", "supplier", "currency", "rate", "author")
class CashInflowForm(forms.ModelForm):
class Meta:
model = CashInflow
fields = ("date", "number", "recipient", "amount", "customer_order", "comment", "author")
class CashTransferForm(forms.ModelForm):
class Meta:
model = CashTransfer
fields = ("date", "number", "sender", "recipient", "amount", "comment", "author")
class CashExpenseForm(forms.ModelForm):
class Meta:
model = CashExpense
fields = ("date", "number", "sender", "amount", "supplier_order", "comment", "author")

View File

@@ -0,0 +1,136 @@
# Generated by Django 5.2.11 on 2026-02-25 14:58
import django.db.models.deletion
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('references', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='CashTransfer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(verbose_name='Дата')),
('number', models.CharField(max_length=50, verbose_name='Номер')),
('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=18, verbose_name='Сумма')),
('comment', models.CharField(blank=True, max_length=500, verbose_name='Комментарий')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cash_transfers', to='references.employee', verbose_name='Автор')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transfers_received', to='references.cashaccount', verbose_name='Получатель')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transfers_sent', to='references.cashaccount', verbose_name='Отправитель')),
],
options={
'verbose_name': 'Перемещение денежных средств',
'verbose_name_plural': 'Перемещения денежных средств',
},
),
migrations.CreateModel(
name='CustomerOrder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(verbose_name='Дата')),
('number', models.CharField(max_length=50, verbose_name='Номер')),
('total_amount', models.DecimalField(decimal_places=2, default=Decimal('0'), editable=False, max_digits=18, verbose_name='Стоимость заказа')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='customer_orders', to='references.employee', verbose_name='Автор')),
('client', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='references.client', verbose_name='Клиент')),
('order_kind', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='references.orderkind', verbose_name='Вид заказа')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='references.organization', verbose_name='Организация')),
],
options={
'verbose_name': 'Заказ клиента',
'verbose_name_plural': 'Заказы клиентов',
},
),
migrations.CreateModel(
name='CashInflow',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(verbose_name='Дата')),
('number', models.CharField(max_length=50, verbose_name='Номер')),
('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=18, verbose_name='Сумма')),
('comment', models.CharField(blank=True, max_length=500, verbose_name='Комментарий')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cash_inflows', to='references.employee', verbose_name='Автор')),
('recipient', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='inflows', to='references.cashaccount', verbose_name='Получатель')),
('customer_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cash_inflows', to='documents.customerorder', verbose_name='Заказ клиента')),
],
options={
'verbose_name': 'Поступление денежных средств',
'verbose_name_plural': 'Поступления денежных средств',
},
),
migrations.CreateModel(
name='CustomerOrderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=18, verbose_name='Цена')),
('quantity', models.DecimalField(decimal_places=4, default=Decimal('1'), max_digits=18, verbose_name='Количество')),
('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), editable=False, max_digits=18, verbose_name='Стоимость')),
('currency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='references.currency', verbose_name='Валюта')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='documents.customerorder', verbose_name='Заказ')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='references.product', verbose_name='Товар')),
],
options={
'verbose_name': 'Строка заказа клиента',
'verbose_name_plural': 'Строки заказа клиента',
},
),
migrations.CreateModel(
name='SupplierOrder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(verbose_name='Дата')),
('number', models.CharField(max_length=50, verbose_name='Номер')),
('rate', models.DecimalField(decimal_places=6, default=Decimal('1'), max_digits=18, verbose_name='Курс валюты')),
('total_in_currency', models.DecimalField(decimal_places=2, default=Decimal('0'), editable=False, max_digits=18, verbose_name='Стоимость в валюте')),
('total_amount', models.DecimalField(decimal_places=2, default=Decimal('0'), editable=False, max_digits=18, verbose_name='Стоимость заказа')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='supplier_orders', to='references.employee', verbose_name='Автор')),
('currency', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='references.currency', verbose_name='Валюта')),
('organization', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='references.organization', verbose_name='Организация')),
('supplier', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='references.supplier', verbose_name='Поставщик')),
],
options={
'verbose_name': 'Заказ поставщику',
'verbose_name_plural': 'Заказы поставщику',
},
),
migrations.CreateModel(
name='CashExpense',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(verbose_name='Дата')),
('number', models.CharField(max_length=50, verbose_name='Номер')),
('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=18, verbose_name='Сумма')),
('comment', models.CharField(blank=True, max_length=500, verbose_name='Комментарий')),
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cash_expenses', to='references.employee', verbose_name='Автор')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='expenses', to='references.cashaccount', verbose_name='Отправитель')),
('supplier_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cash_expenses', to='documents.supplierorder', verbose_name='Заказ поставщику')),
],
options={
'verbose_name': 'Расход денежных средств',
'verbose_name_plural': 'Расходы денежных средств',
},
),
migrations.CreateModel(
name='SupplierOrderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('price', models.DecimalField(decimal_places=2, default=Decimal('0'), max_digits=18, verbose_name='Цена')),
('quantity', models.DecimalField(decimal_places=4, default=Decimal('1'), max_digits=18, verbose_name='Количество')),
('amount', models.DecimalField(decimal_places=2, default=Decimal('0'), editable=False, max_digits=18, verbose_name='Стоимость')),
('currency', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='references.currency', verbose_name='Валюта')),
('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='documents.supplierorder', verbose_name='Заказ поставщику')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='references.product', verbose_name='Товар')),
],
options={
'verbose_name': 'Строка заказа поставщику',
'verbose_name_plural': 'Строки заказа поставщику',
},
),
]

View File

105
app/documents/models.py Normal file
View File

@@ -0,0 +1,105 @@
"""Документы ERP WaterSurf."""
from decimal import Decimal
from django.db import models
from references.models import Currency, OrderKind, Client, Organization, Supplier, Employee, CashAccount, Product
class CustomerOrder(models.Model):
date = models.DateField("Дата")
number = models.CharField("Номер", max_length=50)
order_kind = models.ForeignKey(OrderKind, on_delete=models.PROTECT, verbose_name="Вид заказа")
organization = models.ForeignKey(Organization, on_delete=models.PROTECT, verbose_name="Организация")
client = models.ForeignKey(Client, on_delete=models.PROTECT, verbose_name="Клиент")
total_amount = models.DecimalField("Стоимость заказа", max_digits=18, decimal_places=2, default=Decimal("0"), editable=False)
author = models.ForeignKey(Employee, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Автор", related_name="customer_orders")
class Meta:
verbose_name = "Заказ клиента"
verbose_name_plural = "Заказы клиентов"
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"))
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="Товар")
price = models.DecimalField("Цена", max_digits=18, decimal_places=2, default=Decimal("0"))
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, verbose_name="Валюта", null=True, blank=True)
quantity = models.DecimalField("Количество", max_digits=18, decimal_places=4, default=Decimal("1"))
amount = models.DecimalField("Стоимость", max_digits=18, decimal_places=2, default=Decimal("0"), editable=False)
class Meta:
verbose_name = "Строка заказа клиента"
verbose_name_plural = "Строки заказа клиента"
def save(self, *args, **kwargs):
self.amount = self.price * self.quantity
super().save(*args, **kwargs)
class SupplierOrder(models.Model):
date = models.DateField("Дата")
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="Поставщик")
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)
total_amount = models.DecimalField("Стоимость заказа", max_digits=18, decimal_places=2, default=Decimal("0"), editable=False)
author = models.ForeignKey(Employee, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Автор", related_name="supplier_orders")
class Meta:
verbose_name = "Заказ поставщику"
verbose_name_plural = "Заказы поставщику"
def __str__(self): return f"{self.number} от {self.date}"
def recalc_totals(self):
self.total_in_currency = sum((i.price * i.quantity for i in self.items.all()), Decimal("0"))
self.total_amount = self.total_in_currency * self.rate
class SupplierOrderItem(models.Model):
document = models.ForeignKey(SupplierOrder, on_delete=models.CASCADE, related_name="items", verbose_name="Заказ поставщику")
product = models.ForeignKey(Product, on_delete=models.PROTECT, verbose_name="Товар")
price = models.DecimalField("Цена", max_digits=18, decimal_places=2, default=Decimal("0"))
currency = models.ForeignKey(Currency, on_delete=models.PROTECT, verbose_name="Валюта", null=True, blank=True)
quantity = models.DecimalField("Количество", max_digits=18, decimal_places=4, default=Decimal("1"))
amount = models.DecimalField("Стоимость", max_digits=18, decimal_places=2, default=Decimal("0"), editable=False)
class Meta:
verbose_name = "Строка заказа поставщику"
verbose_name_plural = "Строки заказа поставщику"
def save(self, *args, **kwargs):
self.amount = self.price * self.quantity
super().save(*args, **kwargs)
class CashInflow(models.Model):
date = models.DateField("Дата")
number = models.CharField("Номер", max_length=50)
recipient = models.ForeignKey(CashAccount, on_delete=models.PROTECT, verbose_name="Получатель", related_name="inflows")
amount = models.DecimalField("Сумма", max_digits=18, decimal_places=2, default=Decimal("0"))
customer_order = models.ForeignKey(CustomerOrder, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Заказ клиента", related_name="cash_inflows")
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_inflows")
class Meta:
verbose_name = "Поступление денежных средств"
verbose_name_plural = "Поступления денежных средств"
def __str__(self): return f"{self.number} от {self.date}"
class CashTransfer(models.Model):
date = models.DateField("Дата")
number = models.CharField("Номер", max_length=50)
sender = models.ForeignKey(CashAccount, on_delete=models.PROTECT, verbose_name="Отправитель", related_name="transfers_sent")
recipient = models.ForeignKey(CashAccount, on_delete=models.PROTECT, verbose_name="Получатель", related_name="transfers_received")
amount = models.DecimalField("Сумма", max_digits=18, decimal_places=2, default=Decimal("0"))
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_transfers")
class Meta:
verbose_name = "Перемещение денежных средств"
verbose_name_plural = "Перемещения денежных средств"
def __str__(self): return f"{self.number} от {self.date}"
class CashExpense(models.Model):
date = models.DateField("Дата")
number = models.CharField("Номер", max_length=50)
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")
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:
verbose_name = "Расход денежных средств"
verbose_name_plural = "Расходы денежных средств"
def __str__(self): return f"{self.number} от {self.date}"

12
app/documents/services.py Normal file
View File

@@ -0,0 +1,12 @@
from django.db.models import Max
def next_number(model_class, field_name="number"):
"""Следующий номер по порядку (максимум + 1). Для числоподобных строк."""
agg = model_class.objects.aggregate(m=Max(field_name))
current = agg["m"]
if current is None:
return "1"
try:
return str(int(current) + 1)
except (ValueError, TypeError):
return "1"

27
app/documents/urls.py Normal file
View File

@@ -0,0 +1,27 @@
from django.urls import path
from . import views
app_name = "documents"
urlpatterns = [
path("customer-orders/", views.CustomerOrderList.as_view(), name="customer_order_list"),
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("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"),
path("supplier-orders/<int:pk>/delete/", views.SupplierOrderDelete.as_view(), name="supplier_order_delete"),
path("cash-inflows/", views.CashInflowList.as_view(), name="cash_inflow_list"),
path("cash-inflows/create/", views.CashInflowCreate.as_view(), name="cash_inflow_create"),
path("cash-inflows/<int:pk>/edit/", views.CashInflowUpdate.as_view(), name="cash_inflow_edit"),
path("cash-inflows/<int:pk>/delete/", views.CashInflowDelete.as_view(), name="cash_inflow_delete"),
path("cash-transfers/", views.CashTransferList.as_view(), name="cash_transfer_list"),
path("cash-transfers/create/", views.CashTransferCreate.as_view(), name="cash_transfer_create"),
path("cash-transfers/<int:pk>/edit/", views.CashTransferUpdate.as_view(), name="cash_transfer_edit"),
path("cash-transfers/<int:pk>/delete/", views.CashTransferDelete.as_view(), name="cash_transfer_delete"),
path("cash-expenses/", views.CashExpenseList.as_view(), name="cash_expense_list"),
path("cash-expenses/create/", views.CashExpenseCreate.as_view(), name="cash_expense_create"),
path("cash-expenses/<int:pk>/edit/", views.CashExpenseUpdate.as_view(), name="cash_expense_edit"),
path("cash-expenses/<int:pk>/delete/", views.CashExpenseDelete.as_view(), name="cash_expense_delete"),
]

348
app/documents/views.py Normal file
View File

@@ -0,0 +1,348 @@
"""Представления документов: списки и формы создания/редактирования."""
import logging
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.shortcuts import redirect, get_object_or_404
from django.contrib import messages
from django.http import HttpResponseRedirect
from users.utils import get_author_employee
from .models import (
CustomerOrder,
SupplierOrder,
CashInflow,
CashTransfer,
CashExpense,
)
from .forms import (
CustomerOrderForm,
CustomerOrderItemFormSet,
SupplierOrderForm,
SupplierOrderItemFormSet,
CashInflowForm,
CashTransferForm,
CashExpenseForm,
)
from .services import next_number
logger = logging.getLogger(__name__)
def set_author(form, request):
"""Подставить автора из профиля пользователя."""
author = get_author_employee(request.user)
if author and "author" in form.fields:
form.instance.author = author
# --- Заказы клиентов ---
class CustomerOrderList(LoginRequiredMixin, ListView):
model = CustomerOrder
template_name = "documents/customer_order_list.html"
context_object_name = "object_list"
class CustomerOrderCreate(LoginRequiredMixin, CreateView):
model = CustomerOrder
form_class = CustomerOrderForm
template_name = "documents/order_form.html"
success_url = reverse_lazy("documents:customer_order_list")
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["formset"] = CustomerOrderItemFormSet(instance=self.object) if self.object.pk else CustomerOrderItemFormSet()
ctx["title"] = "Заказ клиента"
return ctx
def form_valid(self, form):
set_author(form, self.request)
self.object = form.save()
formset = CustomerOrderItemFormSet(self.request.POST, instance=self.object)
if formset.is_valid():
formset.save()
self.object.recalc_total()
self.object.save()
logger.info("Создан заказ клиента %s", self.object)
messages.success(self.request, "Заказ создан.")
return redirect(self.success_url)
return self.render_to_response(self.get_context_data(form=form))
def get(self, request, *args, **kwargs):
self.object = None
return super().get(request, *args, **kwargs)
class CustomerOrderUpdate(LoginRequiredMixin, UpdateView):
model = CustomerOrder
form_class = CustomerOrderForm
template_name = "documents/order_form.html"
context_object_name = "object"
success_url = reverse_lazy("documents:customer_order_list")
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["formset"] = CustomerOrderItemFormSet(instance=self.object)
ctx["title"] = "Заказ клиента"
return ctx
def form_valid(self, form):
formset = CustomerOrderItemFormSet(self.request.POST, instance=self.object)
if formset.is_valid():
form.save()
formset.save()
self.object.recalc_total()
self.object.save()
logger.info("Обновлён заказ клиента %s", self.object)
messages.success(self.request, "Заказ сохранён.")
return redirect(self.success_url)
return self.render_to_response(self.get_context_data(form=form))
class CustomerOrderDelete(LoginRequiredMixin, DeleteView):
model = CustomerOrder
template_name = "documents/confirm_delete.html"
success_url = reverse_lazy("documents:customer_order_list")
context_object_name = "object"
# --- Заказы поставщику ---
class SupplierOrderList(LoginRequiredMixin, ListView):
model = SupplierOrder
template_name = "documents/supplier_order_list.html"
context_object_name = "object_list"
class SupplierOrderCreate(LoginRequiredMixin, CreateView):
model = SupplierOrder
form_class = SupplierOrderForm
template_name = "documents/supplier_order_form.html"
success_url = reverse_lazy("documents:supplier_order_list")
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["formset"] = SupplierOrderItemFormSet(instance=self.object) if self.object and self.object.pk else SupplierOrderItemFormSet()
ctx["title"] = "Заказ поставщику"
return ctx
def form_valid(self, form):
set_author(form, self.request)
self.object = form.save()
formset = SupplierOrderItemFormSet(self.request.POST, instance=self.object)
if formset.is_valid():
formset.save()
self.object.recalc_totals()
self.object.save()
logger.info("Создан заказ поставщику %s", self.object)
messages.success(self.request, "Заказ создан.")
return redirect(self.success_url)
return self.render_to_response(self.get_context_data(form=form))
def get(self, request, *args, **kwargs):
self.object = None
return super().get(request, *args, **kwargs)
class SupplierOrderUpdate(LoginRequiredMixin, UpdateView):
model = SupplierOrder
form_class = SupplierOrderForm
template_name = "documents/supplier_order_form.html"
context_object_name = "object"
success_url = reverse_lazy("documents:supplier_order_list")
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["formset"] = SupplierOrderItemFormSet(instance=self.object)
ctx["title"] = "Заказ поставщику"
return ctx
def form_valid(self, form):
formset = SupplierOrderItemFormSet(self.request.POST, instance=self.object)
if formset.is_valid():
form.save()
formset.save()
self.object.recalc_totals()
self.object.save()
logger.info("Обновлён заказ поставщику %s", self.object)
messages.success(self.request, "Заказ сохранён.")
return redirect(self.success_url)
return self.render_to_response(self.get_context_data(form=form))
class SupplierOrderDelete(LoginRequiredMixin, DeleteView):
model = SupplierOrder
template_name = "documents/confirm_delete.html"
success_url = reverse_lazy("documents:supplier_order_list")
context_object_name = "object"
# --- Поступление денежных средств ---
class CashInflowList(LoginRequiredMixin, ListView):
model = CashInflow
template_name = "documents/cash_inflow_list.html"
context_object_name = "object_list"
class CashInflowCreate(LoginRequiredMixin, CreateView):
model = CashInflow
form_class = CashInflowForm
template_name = "documents/cash_doc_form.html"
success_url = reverse_lazy("documents:cash_inflow_list")
def get_initial(self):
initial = super().get_initial()
initial["number"] = next_number(CashInflow)
return initial
def form_valid(self, form):
set_author(form, self.request)
form.save()
logger.info("Создано поступление %s", form.instance)
messages.success(self.request, "Поступление создано.")
return redirect(self.success_url)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["title"] = "Поступление денежных средств"
ctx["cancel_url"] = reverse_lazy("documents:cash_inflow_list")
return ctx
class CashInflowUpdate(LoginRequiredMixin, UpdateView):
model = CashInflow
form_class = CashInflowForm
template_name = "documents/cash_doc_form.html"
context_object_name = "object"
success_url = reverse_lazy("documents:cash_inflow_list")
def form_valid(self, form):
form.save()
logger.info("Обновлено поступление %s", self.object)
messages.success(self.request, "Поступление сохранено.")
return redirect(self.success_url)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["title"] = "Поступление денежных средств"
ctx["cancel_url"] = reverse_lazy("documents:cash_inflow_list")
return ctx
class CashInflowDelete(LoginRequiredMixin, DeleteView):
model = CashInflow
template_name = "documents/confirm_delete.html"
success_url = reverse_lazy("documents:cash_inflow_list")
context_object_name = "object"
# --- Перемещение денежных средств ---
class CashTransferList(LoginRequiredMixin, ListView):
model = CashTransfer
template_name = "documents/cash_transfer_list.html"
context_object_name = "object_list"
class CashTransferCreate(LoginRequiredMixin, CreateView):
model = CashTransfer
form_class = CashTransferForm
template_name = "documents/cash_doc_form.html"
success_url = reverse_lazy("documents:cash_transfer_list")
def get_initial(self):
initial = super().get_initial()
initial["number"] = next_number(CashTransfer)
return initial
def form_valid(self, form):
set_author(form, self.request)
form.save()
logger.info("Создано перемещение %s", form.instance)
messages.success(self.request, "Перемещение создано.")
return redirect(self.success_url)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["title"] = "Перемещение денежных средств"
ctx["cancel_url"] = reverse_lazy("documents:cash_transfer_list")
return ctx
class CashTransferUpdate(LoginRequiredMixin, UpdateView):
model = CashTransfer
form_class = CashTransferForm
template_name = "documents/cash_doc_form.html"
context_object_name = "object"
success_url = reverse_lazy("documents:cash_transfer_list")
def form_valid(self, form):
form.save()
logger.info("Обновлено перемещение %s", self.object)
messages.success(self.request, "Перемещение сохранено.")
return redirect(self.success_url)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["title"] = "Перемещение денежных средств"
ctx["cancel_url"] = reverse_lazy("documents:cash_transfer_list")
return ctx
class CashTransferDelete(LoginRequiredMixin, DeleteView):
model = CashTransfer
template_name = "documents/confirm_delete.html"
success_url = reverse_lazy("documents:cash_transfer_list")
context_object_name = "object"
# --- Расход денежных средств ---
class CashExpenseList(LoginRequiredMixin, ListView):
model = CashExpense
template_name = "documents/cash_expense_list.html"
context_object_name = "object_list"
class CashExpenseCreate(LoginRequiredMixin, CreateView):
model = CashExpense
form_class = CashExpenseForm
template_name = "documents/cash_doc_form.html"
success_url = reverse_lazy("documents:cash_expense_list")
def get_initial(self):
initial = super().get_initial()
initial["number"] = next_number(CashExpense)
return initial
def form_valid(self, form):
set_author(form, self.request)
form.save()
logger.info("Создан расход %s", form.instance)
messages.success(self.request, "Расход создан.")
return redirect(self.success_url)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["title"] = "Расход денежных средств"
ctx["cancel_url"] = reverse_lazy("documents:cash_expense_list")
return ctx
class CashExpenseUpdate(LoginRequiredMixin, UpdateView):
model = CashExpense
form_class = CashExpenseForm
template_name = "documents/cash_doc_form.html"
context_object_name = "object"
success_url = reverse_lazy("documents:cash_expense_list")
def form_valid(self, form):
form.save()
logger.info("Обновлён расход %s", self.object)
messages.success(self.request, "Расход сохранён.")
return redirect(self.success_url)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["title"] = "Расход денежных средств"
ctx["cancel_url"] = reverse_lazy("documents:cash_expense_list")
return ctx
class CashExpenseDelete(LoginRequiredMixin, DeleteView):
model = CashExpense
template_name = "documents/confirm_delete.html"
success_url = reverse_lazy("documents:cash_expense_list")
context_object_name = "object"

20
app/references/admin.py Normal file
View File

@@ -0,0 +1,20 @@
from django.contrib import admin
from .models import (
Currency,
OrderKind,
Client,
Organization,
Supplier,
Employee,
CashAccount,
Product,
)
admin.site.register(Currency)
admin.site.register(OrderKind)
admin.site.register(Client)
admin.site.register(Organization)
admin.site.register(Supplier)
admin.site.register(Employee)
admin.site.register(CashAccount)
admin.site.register(Product)

7
app/references/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ReferencesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "references"
verbose_name = "Справочники"

View File

@@ -0,0 +1,103 @@
# Generated by Django 5.2.11 on 2026-02-25 14:58
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='CashAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=300, verbose_name='Название')),
],
options={
'verbose_name': 'Счёт денежных средств',
'verbose_name_plural': 'Счета денежных средств',
},
),
migrations.CreateModel(
name='Client',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=300, verbose_name='Название')),
],
options={
'verbose_name': 'Клиент',
'verbose_name_plural': 'Клиенты',
},
),
migrations.CreateModel(
name='Currency',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, verbose_name='Название')),
('code', models.CharField(blank=True, max_length=10, verbose_name='Код')),
],
options={
'verbose_name': 'Валюта',
'verbose_name_plural': 'Валюты',
},
),
migrations.CreateModel(
name='Employee',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=300, verbose_name='ФИО')),
],
options={
'verbose_name': 'Сотрудник',
'verbose_name_plural': 'Сотрудники',
},
),
migrations.CreateModel(
name='OrderKind',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название')),
],
options={
'verbose_name': 'Вид заказа',
'verbose_name_plural': 'Виды заказов',
},
),
migrations.CreateModel(
name='Organization',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=300, verbose_name='Название')),
],
options={
'verbose_name': 'Организация',
'verbose_name_plural': 'Организации',
},
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=300, verbose_name='Название')),
],
options={
'verbose_name': 'Товар',
'verbose_name_plural': 'Товары',
},
),
migrations.CreateModel(
name='Supplier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=300, verbose_name='Название')),
],
options={
'verbose_name': 'Поставщик',
'verbose_name_plural': 'Поставщики',
},
),
]

View File

101
app/references/models.py Normal file
View File

@@ -0,0 +1,101 @@
"""
Справочники ERP WaterSurf.
"""
from django.db import models
class Currency(models.Model):
"""Валюты."""
name = models.CharField("Название", max_length=100)
code = models.CharField("Код", max_length=10, blank=True)
class Meta:
verbose_name = "Валюта"
verbose_name_plural = "Валюты"
def __str__(self):
return self.name or self.code or str(self.pk)
class OrderKind(models.Model):
"""Виды заказов."""
name = models.CharField("Название", max_length=200)
class Meta:
verbose_name = "Вид заказа"
verbose_name_plural = "Виды заказов"
def __str__(self):
return self.name
class Client(models.Model):
"""Клиенты."""
name = models.CharField("Название", max_length=300)
class Meta:
verbose_name = "Клиент"
verbose_name_plural = "Клиенты"
def __str__(self):
return self.name
class Organization(models.Model):
"""Организации."""
name = models.CharField("Название", max_length=300)
class Meta:
verbose_name = "Организация"
verbose_name_plural = "Организации"
def __str__(self):
return self.name
class Supplier(models.Model):
"""Поставщики."""
name = models.CharField("Название", max_length=300)
class Meta:
verbose_name = "Поставщик"
verbose_name_plural = "Поставщики"
def __str__(self):
return self.name
class Employee(models.Model):
"""Сотрудники (для поля Автор в документах)."""
name = models.CharField("ФИО", max_length=300)
class Meta:
verbose_name = "Сотрудник"
verbose_name_plural = "Сотрудники"
def __str__(self):
return self.name
class CashAccount(models.Model):
"""Счета денежных средств."""
name = models.CharField("Название", max_length=300)
class Meta:
verbose_name = "Счёт денежных средств"
verbose_name_plural = "Счета денежных средств"
def __str__(self):
return self.name
class Product(models.Model):
"""Товары."""
name = models.CharField("Название", max_length=300)
class Meta:
verbose_name = "Товар"
verbose_name_plural = "Товары"
def __str__(self):
return self.name

39
app/references/urls.py Normal file
View File

@@ -0,0 +1,39 @@
from django.urls import path
from . import views
app_name = "references"
urlpatterns = [
path("currencies/", views.CurrencyList.as_view(), name="currency_list"),
path("currencies/create/", views.CurrencyCreate.as_view(), name="currency_create"),
path("currencies/<int:pk>/edit/", views.CurrencyUpdate.as_view(), name="currency_update"),
path("currencies/<int:pk>/delete/", views.CurrencyDelete.as_view(), name="currency_delete"),
path("order-kinds/", views.OrderKindList.as_view(), name="orderkind_list"),
path("order-kinds/create/", views.OrderKindCreate.as_view(), name="orderkind_create"),
path("order-kinds/<int:pk>/edit/", views.OrderKindUpdate.as_view(), name="orderkind_update"),
path("order-kinds/<int:pk>/delete/", views.OrderKindDelete.as_view(), name="orderkind_delete"),
path("clients/", views.ClientList.as_view(), name="client_list"),
path("clients/create/", views.ClientCreate.as_view(), name="client_create"),
path("clients/<int:pk>/edit/", views.ClientUpdate.as_view(), name="client_update"),
path("clients/<int:pk>/delete/", views.ClientDelete.as_view(), name="client_delete"),
path("organizations/", views.OrganizationList.as_view(), name="organization_list"),
path("organizations/create/", views.OrganizationCreate.as_view(), name="organization_create"),
path("organizations/<int:pk>/edit/", views.OrganizationUpdate.as_view(), name="organization_update"),
path("organizations/<int:pk>/delete/", views.OrganizationDelete.as_view(), name="organization_delete"),
path("suppliers/", views.SupplierList.as_view(), name="supplier_list"),
path("suppliers/create/", views.SupplierCreate.as_view(), name="supplier_create"),
path("suppliers/<int:pk>/edit/", views.SupplierUpdate.as_view(), name="supplier_update"),
path("suppliers/<int:pk>/delete/", views.SupplierDelete.as_view(), name="supplier_delete"),
path("employees/", views.EmployeeList.as_view(), name="employee_list"),
path("employees/create/", views.EmployeeCreate.as_view(), name="employee_create"),
path("employees/<int:pk>/edit/", views.EmployeeUpdate.as_view(), name="employee_update"),
path("employees/<int:pk>/delete/", views.EmployeeDelete.as_view(), name="employee_delete"),
path("cash-accounts/", views.CashAccountList.as_view(), name="cashaccount_list"),
path("cash-accounts/create/", views.CashAccountCreate.as_view(), name="cashaccount_create"),
path("cash-accounts/<int:pk>/edit/", views.CashAccountUpdate.as_view(), name="cashaccount_update"),
path("cash-accounts/<int:pk>/delete/", views.CashAccountDelete.as_view(), name="cashaccount_delete"),
path("products/", views.ProductList.as_view(), name="product_list"),
path("products/create/", views.ProductCreate.as_view(), name="product_create"),
path("products/<int:pk>/edit/", views.ProductUpdate.as_view(), name="product_update"),
path("products/<int:pk>/delete/", views.ProductDelete.as_view(), name="product_delete"),
]

131
app/references/views.py Normal file
View File

@@ -0,0 +1,131 @@
"""
CRUD для справочников (списки и формы).
"""
import logging
from django.views.generic import ListView, CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.contrib import messages
from django.utils.translation import gettext_lazy as _
from .models import (
Currency,
OrderKind,
Client,
Organization,
Supplier,
Employee,
CashAccount,
Product,
)
logger = logging.getLogger(__name__)
def _ref_view(model_class, list_url_name, create_url_name, update_url_name, delete_url_name, list_title, form_title):
"""Фабрика представлений для одного справочника."""
class List(LoginRequiredMixin, ListView):
model = model_class
template_name = "references/reference_list.html"
context_object_name = "items"
extra_context = {
"title": list_title,
"create_url_name": create_url_name,
"update_url_name": update_url_name,
"delete_url_name": delete_url_name,
}
class Create(LoginRequiredMixin, CreateView):
model = model_class
fields = "__all__"
template_name = "references/reference_form.html"
success_url = reverse_lazy(list_url_name)
extra_context = {"title": form_title, "cancel_url": reverse_lazy(list_url_name)}
def form_valid(self, form):
messages.success(self.request, _("Запись создана."))
logger.info("Справочник %s: создана запись %s", model_class.__name__, form.instance)
return super().form_valid(form)
class Update(LoginRequiredMixin, UpdateView):
model = model_class
fields = "__all__"
template_name = "references/reference_form.html"
success_url = reverse_lazy(list_url_name)
context_object_name = "object"
extra_context = {"title": form_title, "cancel_url": reverse_lazy(list_url_name)}
def form_valid(self, form):
messages.success(self.request, _("Запись сохранена."))
logger.info("Справочник %s: обновлена запись %s", model_class.__name__, form.instance)
return super().form_valid(form)
class Delete(LoginRequiredMixin, DeleteView):
model = model_class
template_name = "references/reference_confirm_delete.html"
success_url = reverse_lazy(list_url_name)
context_object_name = "object"
extra_context = {"cancel_url": reverse_lazy(list_url_name)}
def form_valid(self, form):
messages.success(self.request, _("Запись удалена."))
logger.info("Справочник %s: удалена запись %s", model_class.__name__, self.object)
return super().form_valid(form)
return List, Create, Update, Delete
# Валюты
CurrencyList, CurrencyCreate, CurrencyUpdate, CurrencyDelete = _ref_view(
Currency, "references:currency_list", "references:currency_create",
"references:currency_update", "references:currency_delete",
"Валюты", "Валюта",
)
# Виды заказов
OrderKindList, OrderKindCreate, OrderKindUpdate, OrderKindDelete = _ref_view(
OrderKind, "references:orderkind_list", "references:orderkind_create",
"references:orderkind_update", "references:orderkind_delete",
"Виды заказов", "Вид заказа",
)
# Клиенты
ClientList, ClientCreate, ClientUpdate, ClientDelete = _ref_view(
Client, "references:client_list", "references:client_create",
"references:client_update", "references:client_delete",
"Клиенты", "Клиент",
)
# Организации
OrganizationList, OrganizationCreate, OrganizationUpdate, OrganizationDelete = _ref_view(
Organization, "references:organization_list", "references:organization_create",
"references:organization_update", "references:organization_delete",
"Организации", "Организация",
)
# Поставщики
SupplierList, SupplierCreate, SupplierUpdate, SupplierDelete = _ref_view(
Supplier, "references:supplier_list", "references:supplier_create",
"references:supplier_update", "references:supplier_delete",
"Поставщики", "Поставщик",
)
# Сотрудники
EmployeeList, EmployeeCreate, EmployeeUpdate, EmployeeDelete = _ref_view(
Employee, "references:employee_list", "references:employee_create",
"references:employee_update", "references:employee_delete",
"Сотрудники", "Сотрудник",
)
# Счета денежных средств
CashAccountList, CashAccountCreate, CashAccountUpdate, CashAccountDelete = _ref_view(
CashAccount, "references:cashaccount_list", "references:cashaccount_create",
"references:cashaccount_update", "references:cashaccount_delete",
"Счета денежных средств", "Счёт денежных средств",
)
# Товары
ProductList, ProductCreate, ProductUpdate, ProductDelete = _ref_view(
Product, "references:product_list", "references:product_create",
"references:product_update", "references:product_delete",
"Товары", "Товар",
)

71
app/templates/base.html Normal file
View File

@@ -0,0 +1,71 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}ERP WaterSurf{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
{% block extra_css %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{% url 'users:home' %}">ERP WaterSurf</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
{% if user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown">Справочники</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'references:currency_list' %}">Валюты</a></li>
<li><a class="dropdown-item" href="{% url 'references:orderkind_list' %}">Виды заказов</a></li>
<li><a class="dropdown-item" href="{% url 'references:client_list' %}">Клиенты</a></li>
<li><a class="dropdown-item" href="{% url 'references:organization_list' %}">Организации</a></li>
<li><a class="dropdown-item" href="{% url 'references:supplier_list' %}">Поставщики</a></li>
<li><a class="dropdown-item" href="{% url 'references:employee_list' %}">Сотрудники</a></li>
<li><a class="dropdown-item" href="{% url 'references:cashaccount_list' %}">Счета денежных средств</a></li>
<li><a class="dropdown-item" href="{% url 'references:product_list' %}">Товары</a></li>
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown">Документы</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'documents:customer_order_list' %}">Заказы клиентов</a></li>
<li><a class="dropdown-item" href="{% url 'documents:supplier_order_list' %}">Заказы поставщику</a></li>
<li><a class="dropdown-item" href="{% url 'documents:cash_inflow_list' %}">Поступление денежных средств</a></li>
<li><a class="dropdown-item" href="{% url 'documents:cash_transfer_list' %}">Перемещение денежных средств</a></li>
<li><a class="dropdown-item" href="{% url 'documents:cash_expense_list' %}">Расход денежных средств</a></li>
</ul>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
{% if user.is_authenticated %}
<li class="nav-item"><span class="navbar-text me-2">{{ user.username }}</span></li>
<li class="nav-item"><a class="nav-link" href="{% url 'users:logout' %}">Выход</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{% url 'users:login' %}">Вход</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
<main class="container my-4">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}{{ title }} — ERP WaterSurf{% endblock %}
{% block content %}
<h2>{% if object %}Редактировать{% else %}Создать{% endif %} {{ title }}</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="{{ cancel_url|default:'#' }}" class="btn btn-secondary">Отмена</a>
</form>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Расходы денежных средств — ERP WaterSurf{% endblock %}
{% block content %}
<h2>Расходы денежных средств</h2>
<p><a href="{% url 'documents:cash_expense_create' %}" class="btn btn-primary">Создать</a></p>
<table class="table table-striped">
<thead>
<tr>
<th>Дата</th>
<th>Номер</th>
<th>Отправитель</th>
<th>Сумма</th>
<th>Заказ поставщику</th>
<th></th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td>{{ obj.date }}</td>
<td>{{ obj.number }}</td>
<td>{{ obj.sender }}</td>
<td>{{ obj.amount }}</td>
<td>{{ obj.supplier_order|default:"—" }}</td>
<td>
<a href="{% url 'documents:cash_expense_edit' obj.pk %}">Изменить</a>
| <a href="{% url 'documents:cash_expense_delete' obj.pk %}" class="text-danger">Удалить</a>
</td>
</tr>
{% empty %}
<tr><td colspan="6">Нет расходов.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Поступления денежных средств — ERP WaterSurf{% endblock %}
{% block content %}
<h2>Поступления денежных средств</h2>
<p><a href="{% url 'documents:cash_inflow_create' %}" class="btn btn-primary">Создать</a></p>
<table class="table table-striped">
<thead>
<tr>
<th>Дата</th>
<th>Номер</th>
<th>Получатель</th>
<th>Сумма</th>
<th>Заказ клиента</th>
<th></th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td>{{ obj.date }}</td>
<td>{{ obj.number }}</td>
<td>{{ obj.recipient }}</td>
<td>{{ obj.amount }}</td>
<td>{{ obj.customer_order|default:"—" }}</td>
<td>
<a href="{% url 'documents:cash_inflow_edit' obj.pk %}">Изменить</a>
| <a href="{% url 'documents:cash_inflow_delete' obj.pk %}" class="text-danger">Удалить</a>
</td>
</tr>
{% empty %}
<tr><td colspan="6">Нет поступлений.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Перемещения денежных средств — ERP WaterSurf{% endblock %}
{% block content %}
<h2>Перемещения денежных средств</h2>
<p><a href="{% url 'documents:cash_transfer_create' %}" class="btn btn-primary">Создать</a></p>
<table class="table table-striped">
<thead>
<tr>
<th>Дата</th>
<th>Номер</th>
<th>Отправитель</th>
<th>Получатель</th>
<th>Сумма</th>
<th></th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td>{{ obj.date }}</td>
<td>{{ obj.number }}</td>
<td>{{ obj.sender }}</td>
<td>{{ obj.recipient }}</td>
<td>{{ obj.amount }}</td>
<td>
<a href="{% url 'documents:cash_transfer_edit' obj.pk %}">Изменить</a>
| <a href="{% url 'documents:cash_transfer_delete' obj.pk %}" class="text-danger">Удалить</a>
</td>
</tr>
{% empty %}
<tr><td colspan="6">Нет перемещений.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Удалить — ERP WaterSurf{% endblock %}
{% block content %}
<h2>Удалить запись?</h2>
<p>{{ object }}</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Удалить</button>
<a href="{{ request.META.HTTP_REFERER|default:'/' }}" class="btn btn-secondary">Отмена</a>
</form>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Заказы клиентов — ERP WaterSurf{% endblock %}
{% block content %}
<h2>Заказы клиентов</h2>
<p><a href="{% url 'documents:customer_order_create' %}" class="btn btn-primary">Создать заказ</a></p>
<table class="table table-striped">
<thead>
<tr>
<th>Дата</th>
<th>Номер</th>
<th>Вид заказа</th>
<th>Организация</th>
<th>Клиент</th>
<th>Стоимость заказа</th>
<th></th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td>{{ obj.date }}</td>
<td>{{ obj.number }}</td>
<td>{{ obj.order_kind }}</td>
<td>{{ obj.organization }}</td>
<td>{{ obj.client }}</td>
<td>{{ obj.total_amount }}</td>
<td>
<a href="{% url 'documents:customer_order_edit' obj.pk %}">Изменить</a>
| <a href="{% url 'documents:customer_order_delete' obj.pk %}" class="text-danger">Удалить</a>
</td>
</tr>
{% empty %}
<tr><td colspan="7">Нет заказов.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}{{ title }} — ERP WaterSurf{% endblock %}
{% block content %}
<h2>{% if object %}Редактировать{% else %}Создать{% endif %} {{ title }}</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<h3>Товары</h3>
{{ formset.management_form }}
<table class="table" id="order-items">
<thead>
<tr>
<th>Товар</th>
<th>Цена</th>
<th>Валюта</th>
<th>Количество</th>
<th>Стоимость</th>
<th>Удалить</th>
</tr>
</thead>
<tbody>
{% for f in formset %}
<tr class="item-row">
<td>{{ f.id }}{{ f.product }}</td>
<td>{{ f.price }}</td>
<td>{{ f.currency }}</td>
<td>{{ f.quantity }}</td>
<td class="row-amount"></td>
<td>{% if f.DELETE %}{{ f.DELETE }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="{% url 'documents:customer_order_list' %}" class="btn btn-secondary">Отмена</a>
</form>
{% block extra_js %}
<script>
document.querySelector('form').addEventListener('input', function() {
document.querySelectorAll('#order-items .item-row').forEach(function(row) {
var price = parseFloat(row.querySelector('input[name$="-price"]')?.value) || 0;
var qty = parseFloat(row.querySelector('input[name$="-quantity"]')?.value) || 0;
row.querySelector('.row-amount').textContent = (price * qty).toFixed(2);
});
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}{{ title }} — ERP WaterSurf{% endblock %}
{% block content %}
<h2>{% if object %}Редактировать{% else %}Создать{% endif %} {{ title }}</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<h3>Товары</h3>
{{ formset.management_form }}
<table class="table" id="supplier-order-items">
<thead>
<tr>
<th>Товар</th>
<th>Цена</th>
<th>Валюта</th>
<th>Количество</th>
<th>Стоимость</th>
<th>Удалить</th>
</tr>
</thead>
<tbody>
{% for f in formset %}
<tr class="item-row">
<td>{{ f.id }}{{ f.product }}</td>
<td>{{ f.price }}</td>
<td>{{ f.currency }}</td>
<td>{{ f.quantity }}</td>
<td class="row-amount"></td>
<td>{% if f.DELETE %}{{ f.DELETE }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="{% url 'documents:supplier_order_list' %}" class="btn btn-secondary">Отмена</a>
</form>
<script>
document.querySelector('form').addEventListener('input', function() {
document.querySelectorAll('#supplier-order-items .item-row').forEach(function(row) {
var price = parseFloat(row.querySelector('input[name$=\"-price\"]')?.value) || 0;
var qty = parseFloat(row.querySelector('input[name$=\"-quantity\"]')?.value) || 0;
row.querySelector('.row-amount').textContent = (price * qty).toFixed(2);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Заказы поставщику — ERP WaterSurf{% endblock %}
{% block content %}
<h2>Заказы поставщику</h2>
<p><a href="{% url 'documents:supplier_order_create' %}" class="btn btn-primary">Создать заказ</a></p>
<table class="table table-striped">
<thead>
<tr>
<th>Дата</th>
<th>Номер</th>
<th>Организация</th>
<th>Поставщик</th>
<th>Стоимость в валюте</th>
<th>Стоимость заказа</th>
<th></th>
</tr>
</thead>
<tbody>
{% for obj in object_list %}
<tr>
<td>{{ obj.date }}</td>
<td>{{ obj.number }}</td>
<td>{{ obj.organization }}</td>
<td>{{ obj.supplier }}</td>
<td>{{ obj.total_in_currency }}</td>
<td>{{ obj.total_amount }}</td>
<td>
<a href="{% url 'documents:supplier_order_edit' obj.pk %}">Изменить</a>
| <a href="{% url 'documents:supplier_order_delete' obj.pk %}" class="text-danger">Удалить</a>
</td>
</tr>
{% empty %}
<tr><td colspan="7">Нет заказов.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

6
app/templates/home.html Normal file
View File

@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}Главная — ERP WaterSurf{% endblock %}
{% block content %}
<h1>WaterSurf ERP</h1>
<p class="lead">Выберите раздел в меню: Справочники или Документы.</p>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Удалить {{ object }} — ERP WaterSurf{% endblock %}
{% block content %}
<h2>Удалить запись?</h2>
<p>{{ object }}</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Удалить</button>
<a href="{{ cancel_url }}" class="btn btn-secondary">Отмена</a>
</form>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}{{ title }} — ERP WaterSurf{% endblock %}
{% block content %}
<h2>{% if object %}Редактировать{% else %}Создать{% endif %} {{ title }}</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="{{ cancel_url }}" class="btn btn-secondary">Отмена</a>
</form>
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block title %}{{ title }} — ERP WaterSurf{% endblock %}
{% block content %}
<h2>{{ title }}</h2>
<p><a href="{% url create_url_name %}" class="btn btn-primary">Добавить</a></p>
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
{% block table_headers %}<th>Название</th>{% endblock %}
<th></th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.pk }}</td>
{% block table_cells %}<td>{{ item }}</td>{% endblock %}
<td>
<a href="{% block edit_url %}{% url update_url_name item.pk %}{% endblock %}">Изменить</a>
|
<a href="{% block delete_url %}{% url delete_url_name item.pk %}{% endblock %}" class="text-danger">Удалить</a>
</td>
</tr>
{% empty %}
<tr><td colspan="3">Нет записей.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Вход — ERP WaterSurf{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<h2>Вход</h2>
<form method="post" action="{% url 'users:login' %}">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Войти</button>
</form>
</div>
</div>
{% endblock %}

0
app/users/__init__.py Normal file
View File

7
app/users/admin.py Normal file
View File

@@ -0,0 +1,7 @@
from django.contrib import admin
from .models import UserProfile
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ("user", "employee")

7
app/users/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "users"
verbose_name = "Пользователи"

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.11 on 2026-02-25 14:58
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('references', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('employee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user_profiles', to='references.employee', verbose_name='Сотрудник (автор в документах)')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Профиль пользователя',
'verbose_name_plural': 'Профили пользователей',
},
),
]

View File

31
app/users/models.py Normal file
View File

@@ -0,0 +1,31 @@
"""
Профиль пользователя: связь User → Сотрудник для поля «Автор» в документах.
"""
from django.conf import settings
from django.db import models
from references.models import Employee
class UserProfile(models.Model):
"""Профиль пользователя: привязка к справочнику Сотрудники."""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="profile",
verbose_name="Пользователь",
)
employee = models.ForeignKey(
Employee,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="user_profiles",
verbose_name="Сотрудник (автор в документах)",
)
class Meta:
verbose_name = "Профиль пользователя"
verbose_name_plural = "Профили пользователей"
def __str__(self):
return f"{self.user.username}{self.employee or ''}"

10
app/users/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "users"
urlpatterns = [
path("", views.HomeView.as_view(), name="home"),
path("login/", views.ErpLoginView.as_view(), name="login"),
path("logout/", views.ErpLogoutView.as_view(), name="logout"),
]

10
app/users/utils.py Normal file
View File

@@ -0,0 +1,10 @@
"""Хелперы для пользователей."""
def get_author_employee(user):
"""Возвращает сотрудника (автора) для текущего пользователя, если привязан профиль."""
if not user or not user.is_authenticated:
return None
try:
profile = user.profile
return profile.employee if profile else None
except Exception:
return None

35
app/users/views.py Normal file
View File

@@ -0,0 +1,35 @@
"""
Вход, выход и главная страница.
"""
import logging
from django.contrib.auth.views import LoginView, LogoutView
from django.views.generic import TemplateView
from django.contrib.auth.mixins import LoginRequiredMixin
logger = logging.getLogger(__name__)
class HomeView(LoginRequiredMixin, TemplateView):
"""Главная страница после входа: меню справочников и документов."""
template_name = "home.html"
login_url = "users:login"
class ErpLoginView(LoginView):
"""Страница входа."""
template_name = "registration/login.html"
redirect_authenticated_user = True
def form_valid(self, form):
logger.info("Вход пользователя: %s", form.get_user().username)
return super().form_valid(form)
class ErpLogoutView(LogoutView):
"""Выход."""
next_page = "users:login"
def dispatch(self, request, *args, **kwargs):
if request.user.is_authenticated:
logger.info("Выход пользователя: %s", request.user.username)
return super().dispatch(request, *args, **kwargs)

54
docker-compose.yml Normal file
View File

@@ -0,0 +1,54 @@
services:
db:
image: postgres:16-alpine
container_name: watersurf_erp_db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- watersurf_erp_data:/var/lib/postgresql/data
networks:
- watersurf_erp_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
app:
build: .
container_name: watersurf_erp_app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${POSTGRES_DB}
DB_USER: ${POSTGRES_USER}
DB_PASSWORD: ${POSTGRES_PASSWORD}
SECRET_KEY: ${SECRET_KEY}
DEBUG: ${DEBUG:-false}
ALLOWED_HOSTS: ${ALLOWED_HOSTS}
volumes:
- ./logs:/app/logs
networks:
- watersurf_erp_network
ports:
- "127.0.0.1:8010:8000"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
watersurf_erp_data:
driver: local
networks:
watersurf_erp_network:
driver: bridge

0
logs/.gitkeep Normal file
View File

16
manage.py Normal file
View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python
"""Точка входа для управляющих команд Django."""
import os
import sys
if __name__ == "__main__":
base = os.path.dirname(os.path.abspath(__file__))
app_dir = os.path.join(base, "app")
if app_dir not in sys.path:
sys.path.insert(0, app_dir)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError("Не удалось импортировать Django.") from exc
execute_from_command_line(sys.argv)

11
manage.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Управление контейнерами ERP WaterSurf
set -e
cd "$(dirname "$0")"
case "${1:-}" in
start) docker compose up -d ;;
stop) docker compose stop ;;
restart) docker compose restart ;;
logs) docker compose logs -f "${2:-app}" ;;
*) echo "Использование: $0 start|stop|restart|logs [service]"; exit 1 ;;
esac

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
Django>=5.0,<6
psycopg2-binary>=2.9
gunicorn>=21.0
whitenoise>=6.6