From 04581f5ebb0dfcb86fe091d823181f3026c6eeff Mon Sep 17 00:00:00 2001 From: Artem Darius Weber Date: Wed, 8 Jan 2025 13:57:57 +0300 Subject: [PATCH 1/4] exported installed packages into requrements.txt --- requirements.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ec863bf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +asgiref==3.8.1 +Django==5.1.4 +djangorestframework==3.15.2 +drf-yasg==1.21.8 +inflection==0.5.1 +packaging==24.2 +psycopg2-binary==2.9.10 +pytz==2024.2 +PyYAML==6.0.2 +sqlparse==0.5.3 +typing_extensions==4.12.2 +uritemplate==4.1.1 From 87dae7b461a2640e3b1cae3e0650f023be047a9e Mon Sep 17 00:00:00 2001 From: Artem Darius Weber Date: Wed, 8 Jan 2025 17:29:25 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=D0=9C=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=20"=D0=A1=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Создать структуру базы данных для товаров: Поля: наименование, штрих-код, срок годности, размеры, единицы измерения, производитель, категория товара, температурные условия хранения, акции (тип "1+1", "2+1"). Реализовать функционал: Добавление, редактирование и удаление карточек товаров. Проверка на дублирование данных (штрих-код). Валидация данных (например, срок годности). --- inventory/models.py | 28 +++++++------ logs.log | 2 + product_directory.log | 4 ++ product_directory/__init__.py | 0 product_directory/admin.py | 8 ++++ product_directory/api.py | 7 ++++ product_directory/apps.py | 6 +++ product_directory/forms.py | 15 +++++++ product_directory/migrations/0001_initial.py | 29 +++++++++++++ product_directory/migrations/__init__.py | 0 product_directory/models.py | 26 ++++++++++++ product_directory/serializers.py | 7 ++++ .../product_directory/product_list.html | 13 ++++++ product_directory/tests.py | 18 ++++++++ product_directory/views.py | 42 +++++++++++++++++++ settings/base.py | 25 +++++++++++ 16 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 logs.log create mode 100644 product_directory.log create mode 100644 product_directory/__init__.py create mode 100644 product_directory/admin.py create mode 100644 product_directory/api.py create mode 100644 product_directory/apps.py create mode 100644 product_directory/forms.py create mode 100644 product_directory/migrations/0001_initial.py create mode 100644 product_directory/migrations/__init__.py create mode 100644 product_directory/models.py create mode 100644 product_directory/serializers.py create mode 100644 product_directory/templates/product_directory/product_list.html create mode 100644 product_directory/tests.py create mode 100644 product_directory/views.py diff --git a/inventory/models.py b/inventory/models.py index b6928e0..31563e0 100644 --- a/inventory/models.py +++ b/inventory/models.py @@ -1,19 +1,23 @@ from django.db import models from django.contrib.auth.models import AbstractUser, Permission, Group -# Справочник товаров -class Product(models.Model): - name = models.CharField(max_length=255, verbose_name="Наименование товара") - manufacturer_name = models.CharField(max_length=255, verbose_name="Производитель") - manufacturer_country = models.CharField(max_length=255, verbose_name="Страна производителя") - manufacturer_code = models.CharField(max_length=50, verbose_name="Код производителя", blank=True, null=True) - dimensions = models.CharField(max_length=255, verbose_name="Размеры", blank=True, null=True) - unit_of_measure = models.CharField(max_length=50, verbose_name="Единица измерения") - shelf_life_days = models.IntegerField(verbose_name="Срок годности (дни)") - barcode = models.CharField(max_length=50, unique=True, verbose_name="Штрихкод") +from product_directory.models import Product - def __str__(self): - return self.name +# todo: нужно перенести уникальные поля в новую модель + +# Справочник товаров +# class Product(models.Model): +# name = models.CharField(max_length=255, verbose_name="Наименование товара") +# manufacturer_name = models.CharField(max_length=255, verbose_name="Производитель") +# manufacturer_country = models.CharField(max_length=255, verbose_name="Страна производителя") +# manufacturer_code = models.CharField(max_length=50, verbose_name="Код производителя", blank=True, null=True) +# dimensions = models.CharField(max_length=255, verbose_name="Размеры", blank=True, null=True) +# unit_of_measure = models.CharField(max_length=50, verbose_name="Единица измерения") +# shelf_life_days = models.IntegerField(verbose_name="Срок годности (дни)") +# barcode = models.CharField(max_length=50, unique=True, verbose_name="Штрихкод") +# +# def __str__(self): +# return self.name # Справочник должностей class Position(models.Model): diff --git a/logs.log b/logs.log new file mode 100644 index 0000000..7cec389 --- /dev/null +++ b/logs.log @@ -0,0 +1,2 @@ +Watching for file changes with StatReloader +Watching for file changes with StatReloader diff --git a/product_directory.log b/product_directory.log new file mode 100644 index 0000000..5e7d650 --- /dev/null +++ b/product_directory.log @@ -0,0 +1,4 @@ +Watching for file changes with StatReloader +/Users/darius/Documents/franchise_store/settings/base.py changed, reloading. +Watching for file changes with StatReloader +/Users/darius/Documents/franchise_store/settings/base.py changed, reloading. diff --git a/product_directory/__init__.py b/product_directory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/product_directory/admin.py b/product_directory/admin.py new file mode 100644 index 0000000..137462c --- /dev/null +++ b/product_directory/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin + +from .models import Product + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ('name', 'barcode', 'category', 'shelf_life_days', 'promotion') + search_fields = ('name', 'barcode') diff --git a/product_directory/api.py b/product_directory/api.py new file mode 100644 index 0000000..e92f206 --- /dev/null +++ b/product_directory/api.py @@ -0,0 +1,7 @@ +from rest_framework.viewsets import ModelViewSet +from .models import Product +from .serializers import ProductSerializer + +class ProductViewSet(ModelViewSet): + queryset = Product.objects.all() + serializer_class = ProductSerializer \ No newline at end of file diff --git a/product_directory/apps.py b/product_directory/apps.py new file mode 100644 index 0000000..6c478b2 --- /dev/null +++ b/product_directory/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProductDirectoryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'product_directory' diff --git a/product_directory/forms.py b/product_directory/forms.py new file mode 100644 index 0000000..1242276 --- /dev/null +++ b/product_directory/forms.py @@ -0,0 +1,15 @@ +from django import forms +from .models import Product + +class ProductForm(forms.ModelForm): + class Meta: + model = Product + fields = '__all__' + + def clean_shelf_life_days(self): + shelf_life_days = self.cleaned_data.get('shelf_life_days') + if shelf_life_days <= 0: + raise forms.ValidationError("Срок годности должен быть положительным числом.") + if shelf_life_days > 3650: # 10 лет + raise forms.ValidationError("Срок годности превышает допустимый предел.") + return shelf_life_days \ No newline at end of file diff --git a/product_directory/migrations/0001_initial.py b/product_directory/migrations/0001_initial.py new file mode 100644 index 0000000..7cb2c54 --- /dev/null +++ b/product_directory/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.4 on 2025-01-08 14:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Product', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Наименование товара')), + ('barcode', models.CharField(max_length=50, unique=True, verbose_name='Штрих-код')), + ('shelf_life_days', models.PositiveIntegerField(verbose_name='Срок годности (дни)')), + ('dimensions', models.CharField(max_length=255, verbose_name='Размеры')), + ('unit_of_measure', models.CharField(max_length=50, verbose_name='Единицы измерения')), + ('manufacturer', models.CharField(max_length=255, verbose_name='Производитель')), + ('category', models.CharField(choices=[('Food', 'Продукты'), ('Electronics', 'Электроника'), ('Clothing', 'Одежда'), ('Other', 'Другое')], max_length=50, verbose_name='Категория')), + ('storage_temperature', models.CharField(max_length=50, verbose_name='Температурные условия хранения')), + ('promotion', models.CharField(blank=True, choices=[('1+1', '1+1'), ('2+1', '2+1')], max_length=10, null=True, verbose_name='Акция')), + ], + ), + ] diff --git a/product_directory/migrations/__init__.py b/product_directory/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/product_directory/models.py b/product_directory/models.py new file mode 100644 index 0000000..4882d40 --- /dev/null +++ b/product_directory/models.py @@ -0,0 +1,26 @@ +from django.db import models + + +class Product(models.Model): + CATEGORY_CHOICES = [ + ('Food', 'Продукты'), + ('Electronics', 'Электроника'), + ('Clothing', 'Одежда'), + ('Other', 'Другое'), + ] + PROMOTION_CHOICES = [ + ('1+1', '1+1'), + ('2+1', '2+1'), + ] + name = models.CharField(max_length=255, verbose_name="Наименование товара") + barcode = models.CharField(max_length=50, unique=True, verbose_name="Штрих-код") + shelf_life_days = models.PositiveIntegerField(verbose_name="Срок годности (дни)") + dimensions = models.CharField(max_length=255, verbose_name="Размеры") + unit_of_measure = models.CharField(max_length=50, verbose_name="Единицы измерения") + manufacturer = models.CharField(max_length=255, verbose_name="Производитель") + category = models.CharField(max_length=50, choices=CATEGORY_CHOICES, verbose_name="Категория") + storage_temperature = models.CharField(max_length=50, verbose_name="Температурные условия хранения") + promotion = models.CharField(max_length=10, choices=PROMOTION_CHOICES, verbose_name="Акция", blank=True, null=True) + + def __str__(self): + return self.name \ No newline at end of file diff --git a/product_directory/serializers.py b/product_directory/serializers.py new file mode 100644 index 0000000..c4c09f1 --- /dev/null +++ b/product_directory/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from .models import Product + +class ProductSerializer(serializers.ModelSerializer): + class Meta: + model = Product + fields = '__all__' \ No newline at end of file diff --git a/product_directory/templates/product_directory/product_list.html b/product_directory/templates/product_directory/product_list.html new file mode 100644 index 0000000..12b8a38 --- /dev/null +++ b/product_directory/templates/product_directory/product_list.html @@ -0,0 +1,13 @@ +{% for product in products %} + + {{ product.name }} + {{ product.barcode }} + {{ product.category }} + {{ product.shelf_life_days }} + {{ product.promotion }} + + Изменить + Удалить + + +{% endfor %} \ No newline at end of file diff --git a/product_directory/tests.py b/product_directory/tests.py new file mode 100644 index 0000000..28d3acb --- /dev/null +++ b/product_directory/tests.py @@ -0,0 +1,18 @@ +from django.test import TestCase +from .models import Product + +class ProductTestCase(TestCase): + def test_product_creation(self): + product = Product.objects.create( + name="Тестовый товар", + barcode="1234567890123", + shelf_life_days=365, + dimensions="10x10x10", + unit_of_measure="шт.", + manufacturer="Производитель", + category="Food", + storage_temperature="+4C", + promotion="1+1" + ) + self.assertEqual(Product.objects.count(), 1) + self.assertEqual(product.name, "Тестовый товар") \ No newline at end of file diff --git a/product_directory/views.py b/product_directory/views.py new file mode 100644 index 0000000..828d22c --- /dev/null +++ b/product_directory/views.py @@ -0,0 +1,42 @@ +import logging # Импортируем библиотеку для логирования +from django.shortcuts import render, get_object_or_404, redirect +from .models import Product +from .forms import ProductForm + +# Настраиваем логгер +logger = logging.getLogger(__name__) + +def product_list(request): + products = Product.objects.all() + return render(request, 'product_directory/product_list.html', {'products': products}) + +def product_create(request): + if request.method == 'POST': + form = ProductForm(request.POST) + if form.is_valid(): + product = form.save() + logger.info(f"Пользователь {request.user} создал товар '{product.name}'") + return redirect('product_list') + else: + form = ProductForm() + return render(request, 'product_directory/product_form.html', {'form': form}) + +def product_update(request, pk): + product = get_object_or_404(Product, pk=pk) + if request.method == 'POST': + form = ProductForm(request.POST, instance=product) + if form.is_valid(): + product = form.save() + logger.info(f"Пользователь {request.user} обновил товар '{product.name}'") + return redirect('product_list') + else: + form = ProductForm(instance=product) + return render(request, 'product_directory/product_form.html', {'form': form}) + +def product_delete(request, pk): + product = get_object_or_404(Product, pk=pk) + if request.method == 'POST': + logger.warning(f"Пользователь {request.user} удалил товар '{product.name}'") + product.delete() + return redirect('product_list') + return render(request, 'product_directory/product_confirm_delete.html', {'product': product}) diff --git a/settings/base.py b/settings/base.py index fe83006..f9e4780 100644 --- a/settings/base.py +++ b/settings/base.py @@ -56,6 +56,31 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'rest_framework', 'inventory', + 'product_directory', ] ROOT_URLCONF = 'urls' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': 'logs.log', # путь к файлу для логов + }, + }, + 'loggers': { + 'django': { + 'handlers': ['file'], + 'level': 'INFO', + 'propagate': True, + }, + 'product_directory': { + 'handlers': ['file'], + 'level': 'INFO', + 'propagate': True, + }, + }, +} From c6dfbc90ddeac3c2ad3b460bd919d9f01554197f Mon Sep 17 00:00:00 2001 From: Artem Darius Weber Date: Wed, 8 Jan 2025 18:32:15 +0300 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=D0=9C=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=20"=D0=9F=D1=80=D0=B8=D0=B5=D0=BC=D0=BA=D0=B0=20=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Задачи: 1. Реализовать функционал для приемки товара: - Проверка товара по количеству, качеству и сроку годности. - Автоматическое добавление товара на склад, если все параметры соответствуют. - Создание акта приемки с возможностью указания причин отклонений. 2. Реализовать логику учета состояния товаров (повреждения, не соответствие). 3. Реестор складов хранения товаров. --- goods_reception/__init__.py | 0 goods_reception/admin.py | 8 ++ goods_reception/api_views.py | 23 +++++ goods_reception/apps.py | 6 ++ goods_reception/forms.py | 13 +++ goods_reception/migrations/0001_initial.py | 28 ++++++ goods_reception/migrations/__init__.py | 0 goods_reception/models.py | 13 +++ goods_reception/serializers.py | 12 +++ goods_reception/signals.py | 24 +++++ .../goods_reception/reception_report.html | 19 ++++ goods_reception/tests.py | 18 ++++ goods_reception/tests/test_api.py | 36 +++++++ goods_reception/urls.py | 10 ++ goods_reception/views.py | 17 ++++ logs.log | 99 +++++++++++++++++++ settings/base.py | 2 + urls.py | 2 + warehouse/__init__.py | 0 warehouse/admin.py | 21 ++++ warehouse/api_views.py | 11 +++ warehouse/apps.py | 9 ++ warehouse/migrations/0001_initial.py | 30 ++++++ ..._warehouse_alter_stock_options_and_more.py | 67 +++++++++++++ warehouse/migrations/__init__.py | 0 warehouse/models.py | 64 ++++++++++++ warehouse/serializers.py | 7 ++ warehouse/services.py | 53 ++++++++++ warehouse/tests.py | 57 +++++++++++ warehouse/urls.py | 10 ++ warehouse/views.py | 3 + 31 files changed, 662 insertions(+) create mode 100644 goods_reception/__init__.py create mode 100644 goods_reception/admin.py create mode 100644 goods_reception/api_views.py create mode 100644 goods_reception/apps.py create mode 100644 goods_reception/forms.py create mode 100644 goods_reception/migrations/0001_initial.py create mode 100644 goods_reception/migrations/__init__.py create mode 100644 goods_reception/models.py create mode 100644 goods_reception/serializers.py create mode 100644 goods_reception/signals.py create mode 100644 goods_reception/templates/goods_reception/reception_report.html create mode 100644 goods_reception/tests.py create mode 100644 goods_reception/tests/test_api.py create mode 100644 goods_reception/urls.py create mode 100644 goods_reception/views.py create mode 100644 warehouse/__init__.py create mode 100644 warehouse/admin.py create mode 100644 warehouse/api_views.py create mode 100644 warehouse/apps.py create mode 100644 warehouse/migrations/0001_initial.py create mode 100644 warehouse/migrations/0002_warehouse_alter_stock_options_and_more.py create mode 100644 warehouse/migrations/__init__.py create mode 100644 warehouse/models.py create mode 100644 warehouse/serializers.py create mode 100644 warehouse/services.py create mode 100644 warehouse/tests.py create mode 100644 warehouse/urls.py create mode 100644 warehouse/views.py diff --git a/goods_reception/__init__.py b/goods_reception/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/goods_reception/admin.py b/goods_reception/admin.py new file mode 100644 index 0000000..ec33eac --- /dev/null +++ b/goods_reception/admin.py @@ -0,0 +1,8 @@ +from django.contrib import admin +from .models import GoodsReception + +@admin.register(GoodsReception) +class GoodsReceptionAdmin(admin.ModelAdmin): + list_display = ('product', 'quantity', 'quality_check', 'shelf_life_valid', 'is_accepted', 'rejection_reason') + search_fields = ('product__name',) + list_filter = ('is_accepted', 'quality_check', 'shelf_life_valid') diff --git a/goods_reception/api_views.py b/goods_reception/api_views.py new file mode 100644 index 0000000..8192e8c --- /dev/null +++ b/goods_reception/api_views.py @@ -0,0 +1,23 @@ +from rest_framework.viewsets import ModelViewSet +from .models import GoodsReception +from .serializers import GoodsReceptionSerializer +from warehouse.models import Stock + +class GoodsReceptionViewSet(ModelViewSet): + """ + API для работы с приемкой товаров. + """ + queryset = GoodsReception.objects.all() + serializer_class = GoodsReceptionSerializer + + def perform_create(self, serializer): + reception = serializer.save() + if reception.is_accepted: + # Добавление на склад + stock, created = Stock.objects.get_or_create( + product=reception.product, + location="Основной склад", + defaults={'quantity': 0}, + ) + stock.quantity += reception.quantity + stock.save() diff --git a/goods_reception/apps.py b/goods_reception/apps.py new file mode 100644 index 0000000..5e7dce9 --- /dev/null +++ b/goods_reception/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GoodsReceptionConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'goods_reception' diff --git a/goods_reception/forms.py b/goods_reception/forms.py new file mode 100644 index 0000000..468f714 --- /dev/null +++ b/goods_reception/forms.py @@ -0,0 +1,13 @@ +from django import forms +from .models import GoodsReception + +class GoodsReceptionForm(forms.ModelForm): + class Meta: + model = GoodsReception + fields = '__all__' + + def clean(self): + cleaned_data = super().clean() + if not cleaned_data.get('quality_check') or not cleaned_data.get('shelf_life_valid'): + cleaned_data['is_accepted'] = False + return cleaned_data \ No newline at end of file diff --git a/goods_reception/migrations/0001_initial.py b/goods_reception/migrations/0001_initial.py new file mode 100644 index 0000000..9ee65e7 --- /dev/null +++ b/goods_reception/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.4 on 2025-01-08 14:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('product_directory', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='GoodsReception', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(verbose_name='Количество')), + ('quality_check', models.BooleanField(verbose_name='Проверка качества')), + ('shelf_life_valid', models.BooleanField(verbose_name='Срок годности соответствует')), + ('is_accepted', models.BooleanField(default=False, verbose_name='Принят')), + ('rejection_reason', models.TextField(blank=True, null=True, verbose_name='Причина отклонения')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='product_directory.product', verbose_name='Товар')), + ], + ), + ] diff --git a/goods_reception/migrations/__init__.py b/goods_reception/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/goods_reception/models.py b/goods_reception/models.py new file mode 100644 index 0000000..039602f --- /dev/null +++ b/goods_reception/models.py @@ -0,0 +1,13 @@ +from django.db import models +from product_directory.models import Product + +class GoodsReception(models.Model): + product = models.ForeignKey(Product, on_delete=models.CASCADE, verbose_name="Товар") + quantity = models.PositiveIntegerField(verbose_name="Количество") + quality_check = models.BooleanField(verbose_name="Проверка качества") + shelf_life_valid = models.BooleanField(verbose_name="Срок годности соответствует") + is_accepted = models.BooleanField(verbose_name="Принят", default=False) + rejection_reason = models.TextField(verbose_name="Причина отклонения", blank=True, null=True) + + def __str__(self): + return f"Приемка: {self.product.name}" \ No newline at end of file diff --git a/goods_reception/serializers.py b/goods_reception/serializers.py new file mode 100644 index 0000000..9ba8954 --- /dev/null +++ b/goods_reception/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from .models import GoodsReception + +class GoodsReceptionSerializer(serializers.ModelSerializer): + class Meta: + model = GoodsReception + fields = '__all__' + + def validate(self, data): + if not data['quality_check'] or not data['shelf_life_valid']: + raise serializers.ValidationError("Товар не прошёл проверку качества или срок годности истёк.") + return data diff --git a/goods_reception/signals.py b/goods_reception/signals.py new file mode 100644 index 0000000..9642962 --- /dev/null +++ b/goods_reception/signals.py @@ -0,0 +1,24 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from goods_reception.models import GoodsReception +from warehouse.models import StockOperation, Warehouse, StorageLocation + +@receiver(post_save, sender=GoodsReception) +def handle_goods_reception(sender, instance, created, **kwargs): + if created and instance.is_accepted: + # операция прихода + operation = StockOperation.objects.create( + product=instance.product, + warehouse=Warehouse.objects.get(name="Основной склад"), + storage_location=StorageLocation.objects.filter(warehouse__name="Основной склад").first(), + operation_type='Incoming', + quantity=instance.quantity, + expiration_date=None # Укажите срок годности, если известно + ) + + # логика распределения по местам хранения + if instance.product.storage_temperature == "+4C": + cold_storage = StorageLocation.objects.filter(warehouse=operation.warehouse, temperature_control=True).first() + if cold_storage: + operation.storage_location = cold_storage + operation.save() diff --git a/goods_reception/templates/goods_reception/reception_report.html b/goods_reception/templates/goods_reception/reception_report.html new file mode 100644 index 0000000..6f1c249 --- /dev/null +++ b/goods_reception/templates/goods_reception/reception_report.html @@ -0,0 +1,19 @@ +

Акт приемки

+ + + + + + + + + {% for reception in receptions %} + + + + + + + + {% endfor %} +
ТоварКоличествоКачествоСрок годностиПричина отклонения
{{ reception.product.name }}{{ reception.quantity }}{{ reception.quality_check }}{{ reception.shelf_life_valid }}{{ reception.rejection_reason }}
\ No newline at end of file diff --git a/goods_reception/tests.py b/goods_reception/tests.py new file mode 100644 index 0000000..28d3acb --- /dev/null +++ b/goods_reception/tests.py @@ -0,0 +1,18 @@ +from django.test import TestCase +from .models import Product + +class ProductTestCase(TestCase): + def test_product_creation(self): + product = Product.objects.create( + name="Тестовый товар", + barcode="1234567890123", + shelf_life_days=365, + dimensions="10x10x10", + unit_of_measure="шт.", + manufacturer="Производитель", + category="Food", + storage_temperature="+4C", + promotion="1+1" + ) + self.assertEqual(Product.objects.count(), 1) + self.assertEqual(product.name, "Тестовый товар") \ No newline at end of file diff --git a/goods_reception/tests/test_api.py b/goods_reception/tests/test_api.py new file mode 100644 index 0000000..1493812 --- /dev/null +++ b/goods_reception/tests/test_api.py @@ -0,0 +1,36 @@ +from rest_framework.test import APITestCase +from rest_framework import status +from .models import GoodsReception +from product_directory.models import Product + +class GoodsReceptionAPITestCase(APITestCase): + def setUp(self): + self.product = Product.objects.create( + name="Тестовый товар", + barcode="1234567890123", + shelf_life_days=365, + dimensions="10x10x10", + unit_of_measure="шт.", + manufacturer="Производитель", + category="Food", + storage_temperature="+4C", + promotion="1+1" + ) + self.goods_reception_data = { + "product": self.product.id, + "quantity": 10, + "quality_check": True, + "shelf_life_valid": True, + "is_accepted": True, + "rejection_reason": None + } + + def test_create_goods_reception(self): + response = self.client.post('/goods-reception/api/goods-receptions/', self.goods_reception_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_list_goods_receptions(self): + GoodsReception.objects.create(**self.goods_reception_data) + response = self.client.get('/goods-reception/api/goods-receptions/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) diff --git a/goods_reception/urls.py b/goods_reception/urls.py new file mode 100644 index 0000000..8138dd5 --- /dev/null +++ b/goods_reception/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import GoodsReceptionViewSet + +router = DefaultRouter() +router.register(r'goods-receptions', GoodsReceptionViewSet, basename='goods-reception') + +urlpatterns = [ + path('api/', include(router.urls)), +] \ No newline at end of file diff --git a/goods_reception/views.py b/goods_reception/views.py new file mode 100644 index 0000000..168f4c1 --- /dev/null +++ b/goods_reception/views.py @@ -0,0 +1,17 @@ +from django.shortcuts import render, redirect +from .forms import GoodsReceptionForm +from .models import GoodsReception + +def goods_reception_list(request): + receptions = GoodsReception.objects.all() + return render(request, 'goods_reception/goods_reception_list.html', {'receptions': receptions}) + +def goods_reception_create(request): + if request.method == 'POST': + form = GoodsReceptionForm(request.POST) + if form.is_valid(): + form.save() + return redirect('goods_reception_list') + else: + form = GoodsReceptionForm() + return render(request, 'goods_reception/goods_reception_form.html', {'form': form}) \ No newline at end of file diff --git a/logs.log b/logs.log index 7cec389..e35bd10 100644 --- a/logs.log +++ b/logs.log @@ -1,2 +1,101 @@ Watching for file changes with StatReloader Watching for file changes with StatReloader +Watching for file changes with StatReloader +Not Found: / +"GET / HTTP/1.1" 404 2456 +Not Found: /goods-reception +"GET /goods-reception HTTP/1.1" 404 2519 +"GET /goods-reception/api HTTP/1.1" 301 0 +"GET /goods-reception/api/ HTTP/1.1" 200 5454 +"GET /static/rest_framework/css/prettify.css HTTP/1.1" 200 817 +"GET /static/rest_framework/css/bootstrap-tweaks.css HTTP/1.1" 200 3426 +"GET /static/rest_framework/css/default.css HTTP/1.1" 200 1152 +"GET /static/rest_framework/js/ajax-form.js HTTP/1.1" 200 3796 +"GET /static/rest_framework/js/prettify-min.js HTTP/1.1" 200 13632 +"GET /static/rest_framework/css/bootstrap.min.css HTTP/1.1" 200 121457 +"GET /static/rest_framework/js/default.js HTTP/1.1" 200 1268 +"GET /static/rest_framework/js/csrf.js HTTP/1.1" 200 1793 +"GET /static/rest_framework/js/jquery-3.7.1.min.js HTTP/1.1" 200 87533 +"GET /static/rest_framework/js/load-ajax-form.js HTTP/1.1" 200 59 +"GET /static/rest_framework/js/bootstrap.min.js HTTP/1.1" 200 39680 +"GET /static/rest_framework/img/grid.png HTTP/1.1" 200 1458 +"GET /goods-reception/api/ HTTP/1.1" 200 5454 +"OPTIONS /goods-reception/api/ HTTP/1.1" 200 5662 +"GET /goods-reception/api/ HTTP/1.1" 200 5454 +Not Found: /goods-reception/swagger +"GET /goods-reception/swagger HTTP/1.1" 404 2637 +"GET /api/swagger/ HTTP/1.1" 200 2456 +"GET /api/swagger/?format=openapi HTTP/1.1" 200 21094 +"GET /goods-reception/api/goods-receptions/ HTTP/1.1" 200 2 +"GET /admin/ HTTP/1.1" 200 7189 +/Users/darius/Documents/franchise_store/goods_reception/admin.py changed, reloading. +Watching for file changes with StatReloader +"GET /admin/ HTTP/1.1" 200 8161 +"GET /admin/goods_reception/goodsreception/ HTTP/1.1" 200 10520 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /static/admin/img/icon-viewlink.svg HTTP/1.1" 200 581 +"GET /admin/ HTTP/1.1" 200 8161 +"GET /admin/product_directory/product/ HTTP/1.1" 200 11170 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/ HTTP/1.1" 200 8161 +"GET /admin/goods_reception/goodsreception/ HTTP/1.1" 200 10520 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/goods_reception/goodsreception/add/ HTTP/1.1" 200 13670 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"POST /admin/goods_reception/goodsreception/add/ HTTP/1.1" 302 0 +"GET /admin/goods_reception/goodsreception/ HTTP/1.1" 200 13308 +"GET /static/admin/img/icon-no.svg HTTP/1.1" 200 560 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/goods_reception/goodsreception/1/change/ HTTP/1.1" 200 13985 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/goods_reception/goodsreception/ HTTP/1.1" 200 13062 +Watching for file changes with StatReloader +"GET /admin/ HTTP/1.1" 200 9417 +"GET /admin/ HTTP/1.1" 200 9417 +"GET /admin/warehouse/stock/ HTTP/1.1" 200 10410 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/ HTTP/1.1" 200 9417 +/Users/darius/Documents/franchise_store/warehouse/models.py changed, reloading. +Watching for file changes with StatReloader +/Users/darius/Documents/franchise_store/warehouse/models.py changed, reloading. +Watching for file changes with StatReloader +/Users/darius/Documents/franchise_store/warehouse/models.py changed, reloading. +Watching for file changes with StatReloader +Watching for file changes with StatReloader +"GET /admin/ HTTP/1.1" 200 10808 +"GET /admin/warehouse/storagelocation/ HTTP/1.1" 200 11604 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/storagelocation/add/ HTTP/1.1" 200 13587 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/storagelocation/ HTTP/1.1" 200 11604 +"GET /admin/ HTTP/1.1" 200 10808 +"GET /admin/warehouse/warehouse/ HTTP/1.1" 200 10710 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/warehouse/add/ HTTP/1.1" 200 12376 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"POST /admin/warehouse/warehouse/add/ HTTP/1.1" 302 0 +"GET /admin/warehouse/warehouse/ HTTP/1.1" 200 12695 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/warehouse/1/change/ HTTP/1.1" 200 12651 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/storagelocation/ HTTP/1.1" 200 11604 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/storagelocation/add/ HTTP/1.1" 200 13632 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"POST /admin/warehouse/storagelocation/add/ HTTP/1.1" 302 0 +"GET /admin/warehouse/storagelocation/ HTTP/1.1" 200 13712 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 12593 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/stockoperation/add/ HTTP/1.1" 200 19071 +"GET /static/admin/js/calendar.js HTTP/1.1" 200 9141 +"GET /static/admin/js/admin/DateTimeShortcuts.js HTTP/1.1" 200 19319 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /static/admin/img/icon-calendar.svg HTTP/1.1" 200 1086 +"GET /static/admin/img/calendar-icons.svg HTTP/1.1" 200 2455 +"POST /admin/warehouse/stockoperation/add/ HTTP/1.1" 302 0 +"GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 15070 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/stockoperation/1/change/ HTTP/1.1" 200 19394 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 14799 diff --git a/settings/base.py b/settings/base.py index f9e4780..dd0f478 100644 --- a/settings/base.py +++ b/settings/base.py @@ -56,7 +56,9 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'rest_framework', 'inventory', + 'warehouse', 'product_directory', + 'goods_reception', ] ROOT_URLCONF = 'urls' diff --git a/urls.py b/urls.py index 4c7bb56..f7a79b1 100644 --- a/urls.py +++ b/urls.py @@ -4,4 +4,6 @@ from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), path('api/', include('inventory.urls')), + path('goods-reception/', include('goods_reception.urls')), + path('warehouse/', include('warehouse.urls')), ] \ No newline at end of file diff --git a/warehouse/__init__.py b/warehouse/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/warehouse/admin.py b/warehouse/admin.py new file mode 100644 index 0000000..2914f5a --- /dev/null +++ b/warehouse/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin +from .models import Warehouse, StorageLocation, StockOperation + +@admin.register(Warehouse) +class WarehouseAdmin(admin.ModelAdmin): + list_display = ('name', 'location', 'is_active') + search_fields = ('name', 'location') + + +@admin.register(StorageLocation) +class StorageLocationAdmin(admin.ModelAdmin): + list_display = ('name', 'warehouse', 'temperature_control') + search_fields = ('name', 'warehouse__name') + list_filter = ('temperature_control',) + + +@admin.register(StockOperation) +class StockOperationAdmin(admin.ModelAdmin): + list_display = ('product', 'warehouse', 'operation_type', 'quantity', 'operation_date') + search_fields = ('product__name', 'warehouse__name', 'operation_type') + list_filter = ('operation_type', 'operation_date') diff --git a/warehouse/api_views.py b/warehouse/api_views.py new file mode 100644 index 0000000..c437fcd --- /dev/null +++ b/warehouse/api_views.py @@ -0,0 +1,11 @@ +from rest_framework.viewsets import ModelViewSet +from .models import StockOperation +from .serializers import StockOperationSerializer + +class StockOperationViewSet(ModelViewSet): + queryset = StockOperation.objects.all() + serializer_class = StockOperationSerializer + +def handle_goods_reception(sender, instance, created, **kwargs): + if created and instance.is_accepted: + from warehouse.models import Stock \ No newline at end of file diff --git a/warehouse/apps.py b/warehouse/apps.py new file mode 100644 index 0000000..40fc6fa --- /dev/null +++ b/warehouse/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class WarehouseConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'warehouse' + + def ready(self): + import goods_reception.signals diff --git a/warehouse/migrations/0001_initial.py b/warehouse/migrations/0001_initial.py new file mode 100644 index 0000000..3df60a9 --- /dev/null +++ b/warehouse/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.4 on 2025-01-08 15:13 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('product_directory', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Stock', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=0, verbose_name='Количество')), + ('location', models.CharField(max_length=255, verbose_name='Место хранения')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='product_directory.product', verbose_name='Товар')), + ], + options={ + 'verbose_name': 'Складская запись', + 'verbose_name_plural': 'Складские записи', + 'unique_together': {('product', 'location')}, + }, + ), + ] diff --git a/warehouse/migrations/0002_warehouse_alter_stock_options_and_more.py b/warehouse/migrations/0002_warehouse_alter_stock_options_and_more.py new file mode 100644 index 0000000..f28308e --- /dev/null +++ b/warehouse/migrations/0002_warehouse_alter_stock_options_and_more.py @@ -0,0 +1,67 @@ +# Generated by Django 5.1.4 on 2025-01-08 15:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product_directory', '0001_initial'), + ('warehouse', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Warehouse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Название склада')), + ('location', models.CharField(max_length=255, verbose_name='Расположение склада')), + ('is_active', models.BooleanField(default=True, verbose_name='Активен')), + ], + options={ + 'verbose_name': 'Склад', + 'verbose_name_plural': 'Склады', + }, + ), + migrations.AlterModelOptions( + name='stock', + options={}, + ), + migrations.AlterUniqueTogether( + name='stock', + unique_together=set(), + ), + migrations.CreateModel( + name='StorageLocation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Место хранения')), + ('temperature_control', models.BooleanField(default=False, verbose_name='Требуется контроль температуры')), + ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='storage_locations', to='warehouse.warehouse', verbose_name='Склад')), + ], + options={ + 'verbose_name': 'Место хранения', + 'verbose_name_plural': 'Места хранения', + }, + ), + migrations.CreateModel( + name='StockOperation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('operation_type', models.CharField(choices=[('Incoming', 'Приход'), ('Outgoing', 'Расход'), ('Transfer', 'Перемещение'), ('WriteOff', 'Списание')], max_length=50, verbose_name='Тип операции')), + ('quantity', models.PositiveIntegerField(verbose_name='Количество')), + ('operation_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата операции')), + ('expiration_date', models.DateField(blank=True, null=True, verbose_name='Срок годности')), + ('reason', models.TextField(blank=True, null=True, verbose_name='Причина (для списания)')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='warehouse_stock_operations', to='product_directory.product', verbose_name='Товар')), + ('storage_location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='warehouse.storagelocation', verbose_name='Место хранения')), + ('warehouse', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='warehouse.warehouse', verbose_name='Склад')), + ], + options={ + 'verbose_name': 'Операция с товаром', + 'verbose_name_plural': 'Операции с товарами', + }, + ), + ] diff --git a/warehouse/migrations/__init__.py b/warehouse/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/warehouse/models.py b/warehouse/models.py new file mode 100644 index 0000000..03d8ae5 --- /dev/null +++ b/warehouse/models.py @@ -0,0 +1,64 @@ +from django.db import models +from product_directory.models import Product + +class Warehouse(models.Model): + name = models.CharField(max_length=255, verbose_name="Название склада") + location = models.CharField(max_length=255, verbose_name="Расположение склада") + is_active = models.BooleanField(default=True, verbose_name="Активен") + + class Meta: + verbose_name = "Склад" + verbose_name_plural = "Склады" + + def __str__(self): + return self.name + + +class StorageLocation(models.Model): + warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, related_name="storage_locations", verbose_name="Склад") + name = models.CharField(max_length=255, verbose_name="Место хранения") + temperature_control = models.BooleanField(default=False, verbose_name="Требуется контроль температуры") + + class Meta: + verbose_name = "Место хранения" + verbose_name_plural = "Места хранения" + + def __str__(self): + return f"{self.name} ({self.warehouse.name})" + +class Stock(models.Model): + product = models.ForeignKey(Product, on_delete=models.CASCADE, verbose_name="Товар") + quantity = models.PositiveIntegerField(default=0, verbose_name="Количество") + location = models.CharField(max_length=255, verbose_name="Место хранения") + + def __str__(self): + return f"{self.product.name} - {self.quantity} шт." + +class StockOperation(models.Model): + OPERATION_TYPE_CHOICES = [ + ('Incoming', 'Приход'), + ('Outgoing', 'Расход'), + ('Transfer', 'Перемещение'), + ('WriteOff', 'Списание'), + ] + + product = models.ForeignKey( + Product, + on_delete=models.CASCADE, + verbose_name="Товар", + related_name="warehouse_stock_operations" + ) + warehouse = models.ForeignKey(Warehouse, on_delete=models.CASCADE, verbose_name="Склад") + storage_location = models.ForeignKey(StorageLocation, on_delete=models.CASCADE, verbose_name="Место хранения") + operation_type = models.CharField(max_length=50, choices=OPERATION_TYPE_CHOICES, verbose_name="Тип операции") + quantity = models.PositiveIntegerField(verbose_name="Количество") + operation_date = models.DateTimeField(auto_now_add=True, verbose_name="Дата операции") + expiration_date = models.DateField(blank=True, null=True, verbose_name="Срок годности") + reason = models.TextField(blank=True, null=True, verbose_name="Причина (для списания)") + + class Meta: + verbose_name = "Операция с товаром" + verbose_name_plural = "Операции с товарами" + + def __str__(self): + return f"{self.operation_type} - {self.product.name} ({self.warehouse.name})" diff --git a/warehouse/serializers.py b/warehouse/serializers.py new file mode 100644 index 0000000..0a34141 --- /dev/null +++ b/warehouse/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from .models import StockOperation + +class StockOperationSerializer(serializers.ModelSerializer): + class Meta: + model = StockOperation + fields = '__all__' diff --git a/warehouse/services.py b/warehouse/services.py new file mode 100644 index 0000000..13efd27 --- /dev/null +++ b/warehouse/services.py @@ -0,0 +1,53 @@ +from . import models +from .models import StockOperation + +def register_incoming_stock(product, quantity, warehouse, expiration_date=None): + operation = StockOperation.objects.create( + product=product, + quantity=quantity, + warehouse=warehouse, + operation_type='Incoming', + expiration_date=expiration_date + ) + + # Распределение по местам хранения + if product.storage_temperature: + storage_location = "Холодильник" if product.storage_temperature == "+4C" else "Полка" + operation.storage_location = storage_location + operation.save() + return operation + +def transfer_stock(product, quantity, from_warehouse, to_location): + total_stock = StockOperation.objects.filter( + product=product, warehouse=from_warehouse, operation_type='Incoming' + ).aggregate(total=models.Sum('quantity'))['total'] or 0 + + if total_stock < quantity: + raise ValueError("Недостаточно товара на складе для перемещения.") + + transfer_operation = StockOperation.objects.create( + product=product, + quantity=quantity, + warehouse=from_warehouse, + storage_location=to_location, + operation_type='Transfer' + ) + return transfer_operation + + +def write_off_stock(product, quantity, warehouse, reason): + total_stock = StockOperation.objects.filter( + product=product, warehouse=warehouse, operation_type='Incoming' + ).aggregate(total=models.Sum('quantity'))['total'] or 0 + + if total_stock < quantity: + raise ValueError("Недостаточно товара на складе для списания.") + + write_off_operation = StockOperation.objects.create( + product=product, + quantity=quantity, + warehouse=warehouse, + operation_type='WriteOff', + reason=reason + ) + return write_off_operation diff --git a/warehouse/tests.py b/warehouse/tests.py new file mode 100644 index 0000000..06c7455 --- /dev/null +++ b/warehouse/tests.py @@ -0,0 +1,57 @@ +from django.test import TestCase +from product_directory.models import Product +from .models import Stock, Warehouse, StorageLocation, StockOperation + + +class StockTestCase(TestCase): + def setUp(self): + self.product = Product.objects.create( + name="Тестовый товар", + barcode="1234567890123", + shelf_life_days=365, + dimensions="10x10x10", + unit_of_measure="шт.", + manufacturer="Производитель", + category="Food", + storage_temperature="+4C", + promotion="1+1" + ) + self.stock = Stock.objects.create( + product=self.product, + quantity=100, + location="Основной склад" + ) + + def test_stock_creation(self): + self.assertEqual(self.stock.quantity, 100) + + def test_stock_update(self): + self.stock.quantity += 50 + self.stock.save() + self.assertEqual(self.stock.quantity, 150) + +class StockOperationTestCase(TestCase): + def setUp(self): + self.warehouse = Warehouse.objects.create(name="Основной склад", location="Москва") + self.storage = StorageLocation.objects.create(warehouse=self.warehouse, name="Холодильник", temperature_control=True) + self.product = Product.objects.create( + name="Тестовый товар", + barcode="1234567890123", + shelf_life_days=365, + dimensions="10x10x10", + unit_of_measure="шт.", + manufacturer="Производитель", + category="Food", + storage_temperature="+4C", + promotion="1+1" + ) + + def test_incoming_operation(self): + operation = StockOperation.objects.create( + product=self.product, + warehouse=self.warehouse, + storage_location=self.storage, + operation_type="Incoming", + quantity=100 + ) + self.assertEqual(operation.quantity, 100) \ No newline at end of file diff --git a/warehouse/urls.py b/warehouse/urls.py new file mode 100644 index 0000000..fc68b30 --- /dev/null +++ b/warehouse/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import StockOperationViewSet + +router = DefaultRouter() +router.register(r'stock-operations', StockOperationViewSet, basename='stock-operation') + +urlpatterns = [ + path('api/', include(router.urls)), +] diff --git a/warehouse/views.py b/warehouse/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/warehouse/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From d48ae7e3a17c50ab9092877e203ce0395ad675b4 Mon Sep 17 00:00:00 2001 From: Artem Darius Weber Date: Wed, 8 Jan 2025 18:40:36 +0300 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=20=D1=81=20=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- logs.log | 34 +++++++++++++ warehouse/admin.py | 48 +++++++++++++++++++ .../admin/warehouse/stock_statistics.html | 7 +++ .../warehouse/stockoperation/change_list.html | 6 +++ 4 files changed, 95 insertions(+) create mode 100644 warehouse/templates/admin/warehouse/stock_statistics.html create mode 100644 warehouse/templates/admin/warehouse/stockoperation/change_list.html diff --git a/logs.log b/logs.log index e35bd10..92ab0b8 100644 --- a/logs.log +++ b/logs.log @@ -99,3 +99,37 @@ Watching for file changes with StatReloader "GET /admin/warehouse/stockoperation/1/change/ HTTP/1.1" 200 19394 "GET /admin/jsi18n/ HTTP/1.1" 200 3342 "GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 14799 +Watching for file changes with StatReloader +"GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 14886 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +Watching for file changes with StatReloader +"GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 14914 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +/Users/darius/Documents/franchise_store/warehouse/admin.py changed, reloading. +Watching for file changes with StatReloader +/Users/darius/Documents/franchise_store/warehouse/admin.py changed, reloading. +Watching for file changes with StatReloader +/Users/darius/Documents/franchise_store/warehouse/admin.py changed, reloading. +Watching for file changes with StatReloader +Watching for file changes with StatReloader +"GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 14886 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/stockoperation/statistics/ HTTP/1.1" 200 24699 +"GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 14886 +"GET /admin/warehouse/stockoperation/add/ HTTP/1.1" 200 19071 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/product_directory/product/1/change/?_to_field=id&_popup=1 HTTP/1.1" 200 10158 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/product_directory/product/1/change/?_to_field=id HTTP/1.1" 200 17244 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/stockoperation/add/ HTTP/1.1" 200 19071 +"GET /admin/product_directory/product/add/?_to_field=id&_popup=1 HTTP/1.1" 200 10066 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"POST /admin/warehouse/stockoperation/add/ HTTP/1.1" 302 0 +"GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 15687 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/stockoperation/statistics/ HTTP/1.1" 200 28103 +"GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 15416 +"GET /admin/jsi18n/ HTTP/1.1" 200 3342 +"GET /admin/warehouse/stockoperation/statistics/ HTTP/1.1" 200 28103 +"GET /admin/warehouse/stockoperation/ HTTP/1.1" 200 15416 diff --git a/warehouse/admin.py b/warehouse/admin.py index 2914f5a..260a64a 100644 --- a/warehouse/admin.py +++ b/warehouse/admin.py @@ -1,5 +1,13 @@ from django.contrib import admin from .models import Warehouse, StorageLocation, StockOperation +from django.urls import path +from django.shortcuts import render +import matplotlib.pyplot as plt +from io import BytesIO +import base64 + +import matplotlib +matplotlib.use('Agg') @admin.register(Warehouse) class WarehouseAdmin(admin.ModelAdmin): @@ -19,3 +27,43 @@ class StockOperationAdmin(admin.ModelAdmin): list_display = ('product', 'warehouse', 'operation_type', 'quantity', 'operation_date') search_fields = ('product__name', 'warehouse__name', 'operation_type') list_filter = ('operation_type', 'operation_date') + + def changelist_view(self, request, extra_context=None): + extra_context = extra_context or {} + extra_context['custom_button'] = { + 'url': 'statistics/', + 'label': 'Просмотреть статистику' + } + return super().changelist_view(request, extra_context) + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('statistics/', self.admin_site.admin_view(self.statistics_view), name='stock_operation_statistics'), + ] + return custom_urls + urls + + def statistics_view(self, request): + operations = StockOperation.objects.all() + incoming_count = operations.filter(operation_type='Incoming').count() + outgoing_count = operations.filter(operation_type='Outgoing').count() + transfer_count = operations.filter(operation_type='Transfer').count() + writeoff_count = operations.filter(operation_type='WriteOff').count() + + labels = ['Incoming', 'Outgoing', 'Transfer', 'WriteOff'] + data = [incoming_count, outgoing_count, transfer_count, writeoff_count] + + plt.figure(figsize=(6, 6)) + plt.pie(data, labels=labels, autopct='%1.1f%%', startangle=90) + plt.axis('equal') + + buffer = BytesIO() + plt.savefig(buffer, format='png') + buffer.seek(0) + image_png = buffer.getvalue() + buffer.close() + + chart = base64.b64encode(image_png).decode('utf-8') + + context = {'chart': chart} + return render(request, 'admin/warehouse/stock_statistics.html', context) \ No newline at end of file diff --git a/warehouse/templates/admin/warehouse/stock_statistics.html b/warehouse/templates/admin/warehouse/stock_statistics.html new file mode 100644 index 0000000..1a7f2c4 --- /dev/null +++ b/warehouse/templates/admin/warehouse/stock_statistics.html @@ -0,0 +1,7 @@ +{% extends "admin/base_site.html" %} + +{% block content %} +

Статистика операций на складе

+График статистики +Назад к списку операций +{% endblock %} diff --git a/warehouse/templates/admin/warehouse/stockoperation/change_list.html b/warehouse/templates/admin/warehouse/stockoperation/change_list.html new file mode 100644 index 0000000..abf58f4 --- /dev/null +++ b/warehouse/templates/admin/warehouse/stockoperation/change_list.html @@ -0,0 +1,6 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools %} +{{ block.super }} +{{ custom_button.label }} +{% endblock %}