From 1669c1218290bfbcecd8ff7691e4252402c2b3c1 Mon Sep 17 00:00:00 2001 From: cursor-agent Date: Wed, 25 Feb 2026 14:59:46 +0000 Subject: [PATCH] =?UTF-8?q?Docs:=20=D0=BD=D0=B0=D1=87=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 16 + .gitignore | 11 + Dockerfile | 22 ++ HISTORY.md | 24 ++ README.md | 81 ++++ app/__init__.py | 0 app/config/__init__.py | 0 app/config/settings.py | 117 ++++++ app/config/urls.py | 12 + app/config/wsgi.py | 8 + app/documents/__init__.py | 0 app/documents/admin.py | 47 +++ app/documents/apps.py | 7 + app/documents/forms.py | 58 +++ app/documents/migrations/0001_initial.py | 136 +++++++ app/documents/migrations/__init__.py | 0 app/documents/models.py | 105 ++++++ app/documents/services.py | 12 + app/documents/urls.py | 27 ++ app/documents/views.py | 348 ++++++++++++++++++ app/references/admin.py | 20 + app/references/apps.py | 7 + app/references/migrations/0001_initial.py | 103 ++++++ app/references/migrations/__init__.py | 0 app/references/models.py | 101 +++++ app/references/urls.py | 39 ++ app/references/views.py | 131 +++++++ app/templates/base.html | 71 ++++ app/templates/documents/cash_doc_form.html | 11 + .../documents/cash_expense_list.html | 35 ++ app/templates/documents/cash_inflow_list.html | 35 ++ .../documents/cash_transfer_list.html | 35 ++ app/templates/documents/confirm_delete.html | 11 + .../documents/customer_order_list.html | 37 ++ app/templates/documents/order_form.html | 48 +++ .../documents/supplier_order_form.html | 46 +++ .../documents/supplier_order_list.html | 37 ++ app/templates/home.html | 6 + .../references/reference_confirm_delete.html | 11 + app/templates/references/reference_form.html | 11 + app/templates/references/reference_list.html | 30 ++ app/templates/registration/login.html | 14 + app/users/__init__.py | 0 app/users/admin.py | 7 + app/users/apps.py | 7 + app/users/migrations/0001_initial.py | 30 ++ app/users/migrations/__init__.py | 0 app/users/models.py | 31 ++ app/users/urls.py | 10 + app/users/utils.py | 10 + app/users/views.py | 35 ++ docker-compose.yml | 54 +++ logs/.gitkeep | 0 manage.py | 16 + manage.sh | 11 + requirements.txt | 4 + 56 files changed, 2085 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 HISTORY.md create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config/__init__.py create mode 100644 app/config/settings.py create mode 100644 app/config/urls.py create mode 100644 app/config/wsgi.py create mode 100644 app/documents/__init__.py create mode 100644 app/documents/admin.py create mode 100644 app/documents/apps.py create mode 100644 app/documents/forms.py create mode 100644 app/documents/migrations/0001_initial.py create mode 100644 app/documents/migrations/__init__.py create mode 100644 app/documents/models.py create mode 100644 app/documents/services.py create mode 100644 app/documents/urls.py create mode 100644 app/documents/views.py create mode 100644 app/references/admin.py create mode 100644 app/references/apps.py create mode 100644 app/references/migrations/0001_initial.py create mode 100644 app/references/migrations/__init__.py create mode 100644 app/references/models.py create mode 100644 app/references/urls.py create mode 100644 app/references/views.py create mode 100644 app/templates/base.html create mode 100644 app/templates/documents/cash_doc_form.html create mode 100644 app/templates/documents/cash_expense_list.html create mode 100644 app/templates/documents/cash_inflow_list.html create mode 100644 app/templates/documents/cash_transfer_list.html create mode 100644 app/templates/documents/confirm_delete.html create mode 100644 app/templates/documents/customer_order_list.html create mode 100644 app/templates/documents/order_form.html create mode 100644 app/templates/documents/supplier_order_form.html create mode 100644 app/templates/documents/supplier_order_list.html create mode 100644 app/templates/home.html create mode 100644 app/templates/references/reference_confirm_delete.html create mode 100644 app/templates/references/reference_form.html create mode 100644 app/templates/references/reference_list.html create mode 100644 app/templates/registration/login.html create mode 100644 app/users/__init__.py create mode 100644 app/users/admin.py create mode 100644 app/users/apps.py create mode 100644 app/users/migrations/0001_initial.py create mode 100644 app/users/migrations/__init__.py create mode 100644 app/users/models.py create mode 100644 app/users/urls.py create mode 100644 app/users/utils.py create mode 100644 app/users/views.py create mode 100644 docker-compose.yml create mode 100644 logs/.gitkeep create mode 100644 manage.py create mode 100644 manage.sh create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e581ca6 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a2b649 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +*.pyc +__pycache__/ +*.sqlite3 +.git/ +logs/*.log +app/logs/*.log +.venv/ +venv/ +*.egg-info/ +.eggs/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c7da1ed --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 0000000..9aa79c8 --- /dev/null +++ b/HISTORY.md @@ -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) +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb02337 --- /dev/null +++ b/README.md @@ -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]` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config/__init__.py b/app/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config/settings.py b/app/config/settings.py new file mode 100644 index 0000000..29f3514 --- /dev/null +++ b/app/config/settings.py @@ -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") diff --git a/app/config/urls.py b/app/config/urls.py new file mode 100644 index 0000000..72771b6 --- /dev/null +++ b/app/config/urls.py @@ -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")), +] diff --git a/app/config/wsgi.py b/app/config/wsgi.py new file mode 100644 index 0000000..979fec3 --- /dev/null +++ b/app/config/wsgi.py @@ -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() diff --git a/app/documents/__init__.py b/app/documents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/documents/admin.py b/app/documents/admin.py new file mode 100644 index 0000000..9082153 --- /dev/null +++ b/app/documents/admin.py @@ -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") diff --git a/app/documents/apps.py b/app/documents/apps.py new file mode 100644 index 0000000..87a33a9 --- /dev/null +++ b/app/documents/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class DocumentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "documents" + verbose_name = "Документы" diff --git a/app/documents/forms.py b/app/documents/forms.py new file mode 100644 index 0000000..086b59f --- /dev/null +++ b/app/documents/forms.py @@ -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") diff --git a/app/documents/migrations/0001_initial.py b/app/documents/migrations/0001_initial.py new file mode 100644 index 0000000..91f63ca --- /dev/null +++ b/app/documents/migrations/0001_initial.py @@ -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': 'Строки заказа поставщику', + }, + ), + ] diff --git a/app/documents/migrations/__init__.py b/app/documents/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/documents/models.py b/app/documents/models.py new file mode 100644 index 0000000..d02be9f --- /dev/null +++ b/app/documents/models.py @@ -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}" diff --git a/app/documents/services.py b/app/documents/services.py new file mode 100644 index 0000000..b06ed69 --- /dev/null +++ b/app/documents/services.py @@ -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" diff --git a/app/documents/urls.py b/app/documents/urls.py new file mode 100644 index 0000000..e5a7820 --- /dev/null +++ b/app/documents/urls.py @@ -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//edit/", views.CustomerOrderUpdate.as_view(), name="customer_order_edit"), + path("customer-orders//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//edit/", views.SupplierOrderUpdate.as_view(), name="supplier_order_edit"), + path("supplier-orders//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//edit/", views.CashInflowUpdate.as_view(), name="cash_inflow_edit"), + path("cash-inflows//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//edit/", views.CashTransferUpdate.as_view(), name="cash_transfer_edit"), + path("cash-transfers//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//edit/", views.CashExpenseUpdate.as_view(), name="cash_expense_edit"), + path("cash-expenses//delete/", views.CashExpenseDelete.as_view(), name="cash_expense_delete"), +] diff --git a/app/documents/views.py b/app/documents/views.py new file mode 100644 index 0000000..7e4dbb4 --- /dev/null +++ b/app/documents/views.py @@ -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" diff --git a/app/references/admin.py b/app/references/admin.py new file mode 100644 index 0000000..8e9ed2a --- /dev/null +++ b/app/references/admin.py @@ -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) diff --git a/app/references/apps.py b/app/references/apps.py new file mode 100644 index 0000000..868de90 --- /dev/null +++ b/app/references/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ReferencesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "references" + verbose_name = "Справочники" diff --git a/app/references/migrations/0001_initial.py b/app/references/migrations/0001_initial.py new file mode 100644 index 0000000..ffe1209 --- /dev/null +++ b/app/references/migrations/0001_initial.py @@ -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': 'Поставщики', + }, + ), + ] diff --git a/app/references/migrations/__init__.py b/app/references/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/references/models.py b/app/references/models.py new file mode 100644 index 0000000..c139790 --- /dev/null +++ b/app/references/models.py @@ -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 diff --git a/app/references/urls.py b/app/references/urls.py new file mode 100644 index 0000000..0f886fb --- /dev/null +++ b/app/references/urls.py @@ -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//edit/", views.CurrencyUpdate.as_view(), name="currency_update"), + path("currencies//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//edit/", views.OrderKindUpdate.as_view(), name="orderkind_update"), + path("order-kinds//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//edit/", views.ClientUpdate.as_view(), name="client_update"), + path("clients//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//edit/", views.OrganizationUpdate.as_view(), name="organization_update"), + path("organizations//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//edit/", views.SupplierUpdate.as_view(), name="supplier_update"), + path("suppliers//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//edit/", views.EmployeeUpdate.as_view(), name="employee_update"), + path("employees//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//edit/", views.CashAccountUpdate.as_view(), name="cashaccount_update"), + path("cash-accounts//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//edit/", views.ProductUpdate.as_view(), name="product_update"), + path("products//delete/", views.ProductDelete.as_view(), name="product_delete"), +] diff --git a/app/references/views.py b/app/references/views.py new file mode 100644 index 0000000..386b4eb --- /dev/null +++ b/app/references/views.py @@ -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", + "Товары", "Товар", +) diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..b18521f --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,71 @@ +{% load static %} + + + + + + {% block title %}ERP WaterSurf{% endblock %} + + {% block extra_css %}{% endblock %} + + + +
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% block content %}{% endblock %} +
+ + {% block extra_js %}{% endblock %} + + diff --git a/app/templates/documents/cash_doc_form.html b/app/templates/documents/cash_doc_form.html new file mode 100644 index 0000000..2884513 --- /dev/null +++ b/app/templates/documents/cash_doc_form.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}{{ title }} — ERP WaterSurf{% endblock %} +{% block content %} +

{% if object %}Редактировать{% else %}Создать{% endif %} {{ title }}

+
+ {% csrf_token %} + {{ form.as_p }} + + Отмена +
+{% endblock %} diff --git a/app/templates/documents/cash_expense_list.html b/app/templates/documents/cash_expense_list.html new file mode 100644 index 0000000..09c2bca --- /dev/null +++ b/app/templates/documents/cash_expense_list.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Расходы денежных средств — ERP WaterSurf{% endblock %} +{% block content %} +

Расходы денежных средств

+

Создать

+ + + + + + + + + + + + + {% for obj in object_list %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаНомерОтправительСуммаЗаказ поставщику
{{ obj.date }}{{ obj.number }}{{ obj.sender }}{{ obj.amount }}{{ obj.supplier_order|default:"—" }} + Изменить + | Удалить +
Нет расходов.
+{% endblock %} diff --git a/app/templates/documents/cash_inflow_list.html b/app/templates/documents/cash_inflow_list.html new file mode 100644 index 0000000..93a6a3b --- /dev/null +++ b/app/templates/documents/cash_inflow_list.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Поступления денежных средств — ERP WaterSurf{% endblock %} +{% block content %} +

Поступления денежных средств

+

Создать

+ + + + + + + + + + + + + {% for obj in object_list %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаНомерПолучательСуммаЗаказ клиента
{{ obj.date }}{{ obj.number }}{{ obj.recipient }}{{ obj.amount }}{{ obj.customer_order|default:"—" }} + Изменить + | Удалить +
Нет поступлений.
+{% endblock %} diff --git a/app/templates/documents/cash_transfer_list.html b/app/templates/documents/cash_transfer_list.html new file mode 100644 index 0000000..62ef2c3 --- /dev/null +++ b/app/templates/documents/cash_transfer_list.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Перемещения денежных средств — ERP WaterSurf{% endblock %} +{% block content %} +

Перемещения денежных средств

+

Создать

+ + + + + + + + + + + + + {% for obj in object_list %} + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаНомерОтправительПолучательСумма
{{ obj.date }}{{ obj.number }}{{ obj.sender }}{{ obj.recipient }}{{ obj.amount }} + Изменить + | Удалить +
Нет перемещений.
+{% endblock %} diff --git a/app/templates/documents/confirm_delete.html b/app/templates/documents/confirm_delete.html new file mode 100644 index 0000000..990ec3c --- /dev/null +++ b/app/templates/documents/confirm_delete.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Удалить — ERP WaterSurf{% endblock %} +{% block content %} +

Удалить запись?

+

{{ object }}

+
+ {% csrf_token %} + + Отмена +
+{% endblock %} diff --git a/app/templates/documents/customer_order_list.html b/app/templates/documents/customer_order_list.html new file mode 100644 index 0000000..2a2f089 --- /dev/null +++ b/app/templates/documents/customer_order_list.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Заказы клиентов — ERP WaterSurf{% endblock %} +{% block content %} +

Заказы клиентов

+

Создать заказ

+ + + + + + + + + + + + + + {% for obj in object_list %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаНомерВид заказаОрганизацияКлиентСтоимость заказа
{{ obj.date }}{{ obj.number }}{{ obj.order_kind }}{{ obj.organization }}{{ obj.client }}{{ obj.total_amount }} + Изменить + | Удалить +
Нет заказов.
+{% endblock %} diff --git a/app/templates/documents/order_form.html b/app/templates/documents/order_form.html new file mode 100644 index 0000000..cdf96bd --- /dev/null +++ b/app/templates/documents/order_form.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}{{ title }} — ERP WaterSurf{% endblock %} +{% block content %} +

{% if object %}Редактировать{% else %}Создать{% endif %} {{ title }}

+
+ {% csrf_token %} + {{ form.as_p }} +

Товары

+ {{ formset.management_form }} + + + + + + + + + + + + + {% for f in formset %} + + + + + + + + + {% endfor %} + +
ТоварЦенаВалютаКоличествоСтоимостьУдалить
{{ f.id }}{{ f.product }}{{ f.price }}{{ f.currency }}{{ f.quantity }}{% if f.DELETE %}{{ f.DELETE }}{% endif %}
+ + Отмена +
+{% block extra_js %} + +{% endblock %} +{% endblock %} diff --git a/app/templates/documents/supplier_order_form.html b/app/templates/documents/supplier_order_form.html new file mode 100644 index 0000000..44e9679 --- /dev/null +++ b/app/templates/documents/supplier_order_form.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}{{ title }} — ERP WaterSurf{% endblock %} +{% block content %} +

{% if object %}Редактировать{% else %}Создать{% endif %} {{ title }}

+
+ {% csrf_token %} + {{ form.as_p }} +

Товары

+ {{ formset.management_form }} + + + + + + + + + + + + + {% for f in formset %} + + + + + + + + + {% endfor %} + +
ТоварЦенаВалютаКоличествоСтоимостьУдалить
{{ f.id }}{{ f.product }}{{ f.price }}{{ f.currency }}{{ f.quantity }}{% if f.DELETE %}{{ f.DELETE }}{% endif %}
+ + Отмена +
+ +{% endblock %} diff --git a/app/templates/documents/supplier_order_list.html b/app/templates/documents/supplier_order_list.html new file mode 100644 index 0000000..4ec8406 --- /dev/null +++ b/app/templates/documents/supplier_order_list.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Заказы поставщику — ERP WaterSurf{% endblock %} +{% block content %} +

Заказы поставщику

+

Создать заказ

+ + + + + + + + + + + + + + {% for obj in object_list %} + + + + + + + + + + {% empty %} + + {% endfor %} + +
ДатаНомерОрганизацияПоставщикСтоимость в валютеСтоимость заказа
{{ obj.date }}{{ obj.number }}{{ obj.organization }}{{ obj.supplier }}{{ obj.total_in_currency }}{{ obj.total_amount }} + Изменить + | Удалить +
Нет заказов.
+{% endblock %} diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..1fcdc2f --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% block title %}Главная — ERP WaterSurf{% endblock %} +{% block content %} +

WaterSurf ERP

+

Выберите раздел в меню: Справочники или Документы.

+{% endblock %} diff --git a/app/templates/references/reference_confirm_delete.html b/app/templates/references/reference_confirm_delete.html new file mode 100644 index 0000000..bffd0f7 --- /dev/null +++ b/app/templates/references/reference_confirm_delete.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Удалить {{ object }} — ERP WaterSurf{% endblock %} +{% block content %} +

Удалить запись?

+

{{ object }}

+
+ {% csrf_token %} + + Отмена +
+{% endblock %} diff --git a/app/templates/references/reference_form.html b/app/templates/references/reference_form.html new file mode 100644 index 0000000..fc0edcd --- /dev/null +++ b/app/templates/references/reference_form.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}{{ title }} — ERP WaterSurf{% endblock %} +{% block content %} +

{% if object %}Редактировать{% else %}Создать{% endif %} {{ title }}

+
+ {% csrf_token %} + {{ form.as_p }} + + Отмена +
+{% endblock %} diff --git a/app/templates/references/reference_list.html b/app/templates/references/reference_list.html new file mode 100644 index 0000000..05890be --- /dev/null +++ b/app/templates/references/reference_list.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}{{ title }} — ERP WaterSurf{% endblock %} +{% block content %} +

{{ title }}

+

Добавить

+ + + + + {% block table_headers %}{% endblock %} + + + + + {% for item in items %} + + + {% block table_cells %}{% endblock %} + + + {% empty %} + + {% endfor %} + +
#Название
{{ item.pk }}{{ item }} + Изменить + | + Удалить +
Нет записей.
+{% endblock %} diff --git a/app/templates/registration/login.html b/app/templates/registration/login.html new file mode 100644 index 0000000..a939aa5 --- /dev/null +++ b/app/templates/registration/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Вход — ERP WaterSurf{% endblock %} +{% block content %} +
+
+

Вход

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+
+{% endblock %} diff --git a/app/users/__init__.py b/app/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/users/admin.py b/app/users/admin.py new file mode 100644 index 0000000..63600e3 --- /dev/null +++ b/app/users/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from .models import UserProfile + + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ("user", "employee") diff --git a/app/users/apps.py b/app/users/apps.py new file mode 100644 index 0000000..5deab92 --- /dev/null +++ b/app/users/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "users" + verbose_name = "Пользователи" diff --git a/app/users/migrations/0001_initial.py b/app/users/migrations/0001_initial.py new file mode 100644 index 0000000..992eb6b --- /dev/null +++ b/app/users/migrations/0001_initial.py @@ -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': 'Профили пользователей', + }, + ), + ] diff --git a/app/users/migrations/__init__.py b/app/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/users/models.py b/app/users/models.py new file mode 100644 index 0000000..a49d46c --- /dev/null +++ b/app/users/models.py @@ -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 '—'}" diff --git a/app/users/urls.py b/app/users/urls.py new file mode 100644 index 0000000..ed0fd7b --- /dev/null +++ b/app/users/urls.py @@ -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"), +] diff --git a/app/users/utils.py b/app/users/utils.py new file mode 100644 index 0000000..5d32fa3 --- /dev/null +++ b/app/users/utils.py @@ -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 diff --git a/app/users/views.py b/app/users/views.py new file mode 100644 index 0000000..5a5bef9 --- /dev/null +++ b/app/users/views.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..410c4d3 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..f643e70 --- /dev/null +++ b/manage.py @@ -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) diff --git a/manage.sh b/manage.sh new file mode 100644 index 0000000..559aa4f --- /dev/null +++ b/manage.sh @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3abe0c7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Django>=5.0,<6 +psycopg2-binary>=2.9 +gunicorn>=21.0 +whitenoise>=6.6