From c6dfbc90ddeac3c2ad3b460bd919d9f01554197f Mon Sep 17 00:00:00 2001 From: Artem Darius Weber Date: Wed, 8 Jan 2025 18:32:15 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=9C=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20?= =?UTF-8?q?"=D0=9F=D1=80=D0=B8=D0=B5=D0=BC=D0=BA=D0=B0=20=D1=82=D0=BE?= =?UTF-8?q?=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.