diff --git a/Rasaddam_Backend/settings.py b/Rasaddam_Backend/settings.py index 33c511a..8d7c8be 100644 --- a/Rasaddam_Backend/settings.py +++ b/Rasaddam_Backend/settings.py @@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/ import os.path from datetime import timedelta from pathlib import Path +import sentry_sdk from django.conf import settings @@ -27,6 +28,22 @@ SECRET_KEY = 'django-insecure-@0apn-lk85pfw=z00x2ib$w9#rwz8%2v4i_n^^9jz-m9b+y55* # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True +sentry_sdk.init( + dsn="https://e8d8ff4f1bf729370af00b7775be441c@o4509597964697600.ingest.us.sentry.io/4509597966073856", + # Add data like request headers and IP for users, + # see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info + send_default_pii=True, + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for tracing. + traces_sample_rate=1.0, + # Set profile_session_sample_rate to 1.0 to profile 100% + # of profile sessions. + profile_session_sample_rate=1.0, + # Set profile_lifecycle to "trace" to automatically + # run the profiler on when there is an active transaction + profile_lifecycle="trace", +) + ALLOWED_HOSTS = [ 'localhost', '127.0.0.1', @@ -131,16 +148,18 @@ AUTH_USER_MODEL = 'authentication.User' SWAGGER_SETTINGS = { 'SECURITY_DEFINITIONS': { - 'Bearer': { - 'type': 'apiKey', - 'name': 'Authorization', - 'in': 'header' + "bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "description": 'JWT Authorization header using the Bearer scheme. Example: "Authorization: Bearer "', }, 'basic': { # <<-- is for djagno authentication 'type': 'basic' }, }, - 'USE_SESSION_AUTH': True, + 'USE_SESSION_AUTH': False, + "DEFAULT_AUTO_SCHEMA_CLASS": "drf_yasg.inspectors.SwaggerAutoSchema" } LOGIN_URL = 'rest_framework:login' @@ -159,7 +178,7 @@ REST_FRAMEWORK = { 'django_filters.rest_framework.DjangoFilterBackend', 'rest_framework.filters.SearchFilter', ], - 'EXCEPTION_HANDLER': 'apps.core.error_handler.custom_exception_handler', + # 'EXCEPTION_HANDLER': 'apps.core.error_handler.custom_exception_handler', "DEFAULT_PAGINATION_CLASS": 'apps.core.pagination.CustomPageNumberPagination', "PAGE_SIZE": 20, 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema' diff --git a/Rasaddam_Backend/urls.py b/Rasaddam_Backend/urls.py index f065616..3bca59d 100644 --- a/Rasaddam_Backend/urls.py +++ b/Rasaddam_Backend/urls.py @@ -38,5 +38,6 @@ urlpatterns = [ path('tag/', include('apps.tag.urls')), path('search/', include('apps.search.urls')), path('product/', include('apps.product.urls')), + path('warehouse/', include('apps.warehouse.urls')), path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), ] diff --git a/apps/authentication/api/v1/api.py b/apps/authentication/api/v1/api.py index 6cdae40..3d1f9da 100644 --- a/apps/authentication/api/v1/api.py +++ b/apps/authentication/api/v1/api.py @@ -98,9 +98,9 @@ class UserViewSet(ModelViewSet): Customizing update user & bank account info with permission levels """ - serializer = self.serializer_class(data=request.data) + serializer = self.serializer_class(data=request.data, instance=self.get_object(), partial=True) if serializer.is_valid(): - user = serializer.update(self.queryset.get(id=pk), validated_data=request.data) + user = serializer.save() if 'organization' in request.data.keys(): # noqa organization = CustomOperations().custom_update( # update organization for user diff --git a/apps/authentication/api/v1/serializers/serializer.py b/apps/authentication/api/v1/serializers/serializer.py index c1dcd72..9bd375e 100644 --- a/apps/authentication/api/v1/serializers/serializer.py +++ b/apps/authentication/api/v1/serializers/serializer.py @@ -138,8 +138,8 @@ class UserSerializer(serializers.ModelSerializer): instance.ownership = validated_data.get('ownership') instance.address = validated_data.get('address') instance.photo = validated_data.get('photo') - instance.province = Province.objects.get(id=validated_data.get('province')) - instance.city = City.objects.get(id=validated_data.get('city')) + instance.province = validated_data.get('province', instance.province) + instance.city = validated_data.get('province', instance.province) instance.otp_status = validated_data.get('otp_status') instance.save() @@ -213,14 +213,10 @@ class OrganizationSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): """ update user organization information """ # noqa instance.name = validated_data.get('name', instance.name) - if validated_data.get('type'): - instance.type = validated_data['type'] - if validated_data.get('province'): - instance.province = validated_data['province'] - if validated_data.get('city'): - instance.city = validated_data['city'] - if validated_data.get('parent_organization'): - instance.parent_organization = validated_data['parent_organization'] + instance.type = validated_data.get('type', instance.type) + instance.province = validated_data.get('province', instance.province) + instance.city = validated_data.get('city', instance.city) + instance.parent_organization = validated_data.get('parent_organization', instance.parent_organization) instance.national_unique_id = validated_data.get('national_unique_id', instance.national_unique_id) instance.save() return instance diff --git a/apps/authentication/migrations/0023_alter_organization_company_code_and_more.py b/apps/authentication/migrations/0023_alter_organization_company_code_and_more.py new file mode 100644 index 0000000..1dde4be --- /dev/null +++ b/apps/authentication/migrations/0023_alter_organization_company_code_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0 on 2025-06-28 11:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0022_alter_user_mobile_alter_user_national_code'), + ] + + operations = [ + migrations.AlterField( + model_name='organization', + name='company_code', + field=models.CharField(default='', max_length=30), + ), + migrations.AlterField( + model_name='organization', + name='field_of_activity', + field=models.CharField(choices=[('CO', 'Country'), ('PR', 'Province'), ('CI', 'City')], default='', max_length=2), + ), + ] diff --git a/apps/authentication/models.py b/apps/authentication/models.py index 47874c5..ee58c35 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -105,8 +105,8 @@ class Organization(BaseModel): ('PR', 'Province'), ('CI', 'City') ) - field_of_activity = models.CharField(max_length=2, choices=activity_fields, default='EM') - company_code = models.CharField(max_length=30, default="empty") + field_of_activity = models.CharField(max_length=2, choices=activity_fields, default="") + company_code = models.CharField(max_length=30, default="") province = models.ForeignKey( Province, on_delete=models.CASCADE, diff --git a/apps/authorization/api/v1/serializers.py b/apps/authorization/api/v1/serializers.py index 33fd777..b970ed4 100644 --- a/apps/authorization/api/v1/serializers.py +++ b/apps/authorization/api/v1/serializers.py @@ -146,10 +146,8 @@ class UserRelationSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): """ update user relation object """ - if validated_data.get('role'): - instance.role = Role.objects.get(id=validated_data.get("role")) - if validated_data.get('organization'): - instance.organization = Organization.objects.get(id=validated_data.get('organization')) + instance.role = validated_data.get('role', instance.role) + instance.organization = validated_data.get('organization', instance.organization) instance.save() instance.permissions.clear() instance.permissions.add(*(validated_data.get('permissions', instance.permissions))) diff --git a/apps/core/models.py b/apps/core/models.py index 99f188e..3b1c341 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -32,8 +32,8 @@ class BaseModel(models.Model): self.modified_by = user if not self.creator_info: self.created_by = user - self.creator_info = user.first_name + ' ' + user.last_name + '-' + user.national_code - self.modifier_info = user.first_name + ' ' + user.last_name + '-' + user.national_code + self.creator_info = user.first_name + ' ' + user.last_name + self.modifier_info = user.first_name + ' ' + user.last_name super(BaseModel, self).save(*args, **kwargs) diff --git a/apps/core/swagger.py b/apps/core/swagger.py index cc4ade6..2e3cc0c 100644 --- a/apps/core/swagger.py +++ b/apps/core/swagger.py @@ -12,5 +12,5 @@ schema_view = get_schema_view( license=openapi.License(name="BSD License"), ), public=True, - permission_classes=[permissions.BasePermission, permissions.IsAuthenticated] + # permission_classes=[permissions.BasePermission, permissions.IsAuthenticated] ) diff --git a/apps/product/exceptions.py b/apps/product/exceptions.py index 61e35ce..a2d6d49 100644 --- a/apps/product/exceptions.py +++ b/apps/product/exceptions.py @@ -6,5 +6,21 @@ class QuotaWeightException(APIException): """ if quota distributions weight is more """ status_code = status.HTTP_400_BAD_REQUEST - default_detail = "مقدار وارد شده باعث می‌شود مجموع سهمیه‌ها از مقدار کل سهمیه بیشتر شود." # noqa + default_detail = "مقدار وارد شده باعث می‌شود مجموع سهمیه‌ها از مقدار کل سهمیه بیشتر شود." # noqa + default_code = 'error' + + +class QuotaClosedException(APIException): + """ if quota is closed, operations can not be done """ + + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "این سهمیه بسته شده است و قابل توزیع نیست" # noqa + default_code = 'error' + + +class QuotaExpiredTimeException(APIException): + """if quota allowed time for distribute, sale, etc. is expired""" + + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "زمان مجوز این سهمیه به پایان رسیده است" # noqa default_code = 'error' diff --git a/apps/product/migrations/0029_remove_quota_assigned_organizations_and_more.py b/apps/product/migrations/0029_remove_quota_assigned_organizations_and_more.py new file mode 100644 index 0000000..f99e90f --- /dev/null +++ b/apps/product/migrations/0029_remove_quota_assigned_organizations_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0 on 2025-06-28 10:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0028_quota_remaining_weight'), + ] + + operations = [ + migrations.RemoveField( + model_name='quota', + name='assigned_organizations', + ), + migrations.RemoveField( + model_name='quota', + name='registerer_organization', + ), + migrations.RemoveField( + model_name='quotadistribution', + name='assigned_organization', + ), + migrations.RemoveField( + model_name='quotadistribution', + name='assigner_organization', + ), + ] diff --git a/apps/product/migrations/0030_remove_broker_organization_relations.py b/apps/product/migrations/0030_remove_broker_organization_relations.py new file mode 100644 index 0000000..4fe9190 --- /dev/null +++ b/apps/product/migrations/0030_remove_broker_organization_relations.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0 on 2025-06-28 11:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0029_remove_quota_assigned_organizations_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='broker', + name='organization_relations', + ), + ] diff --git a/apps/product/migrations/0031_broker_organization_quota_assigned_organizations_and_more.py b/apps/product/migrations/0031_broker_organization_quota_assigned_organizations_and_more.py new file mode 100644 index 0000000..167723d --- /dev/null +++ b/apps/product/migrations/0031_broker_organization_quota_assigned_organizations_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 5.0 on 2025-06-28 11:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0022_alter_user_mobile_alter_user_national_code'), + ('product', '0030_remove_broker_organization_relations'), + ] + + operations = [ + migrations.AddField( + model_name='broker', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='product_organization', to='authentication.organization'), + ), + migrations.AddField( + model_name='quota', + name='assigned_organizations', + field=models.ManyToManyField(blank=True, related_name='assigned_quotas', to='authentication.organization'), + ), + migrations.AddField( + model_name='quota', + name='registerer_organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quotas', to='authentication.organization'), + ), + migrations.AddField( + model_name='quotadistribution', + name='assigned_organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='distributions', to='authentication.organization'), + ), + migrations.AddField( + model_name='quotadistribution', + name='assigner_organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='distributions_assigner', to='authentication.organization'), + ), + ] diff --git a/apps/product/migrations/0032_quota_closed_at_quota_is_closed.py b/apps/product/migrations/0032_quota_closed_at_quota_is_closed.py new file mode 100644 index 0000000..f4fa5fa --- /dev/null +++ b/apps/product/migrations/0032_quota_closed_at_quota_is_closed.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0 on 2025-06-29 11:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0031_broker_organization_quota_assigned_organizations_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='quota', + name='closed_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='quota', + name='is_closed', + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/product/migrations/0033_alter_quotalivestockagelimitation_livestock_subtype_and_more.py b/apps/product/migrations/0033_alter_quotalivestockagelimitation_livestock_subtype_and_more.py new file mode 100644 index 0000000..879e1d6 --- /dev/null +++ b/apps/product/migrations/0033_alter_quotalivestockagelimitation_livestock_subtype_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.0 on 2025-06-30 11:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0032_quota_closed_at_quota_is_closed'), + ] + + operations = [ + migrations.AlterField( + model_name='quotalivestockagelimitation', + name='livestock_subtype', + field=models.CharField(choices=[('milking', 'شیری'), ('fattening', 'پرواری')], max_length=20, null=True), + ), + migrations.AlterField( + model_name='quotalivestockagelimitation', + name='livestock_type', + field=models.CharField(choices=[('light', 'سبک'), ('heavy', 'سنگین')], max_length=20, null=True), + ), + migrations.AlterField( + model_name='quotalivestockallocation', + name='livestock_group', + field=models.CharField(choices=[('rural', 'روستایی'), ('industrial', 'صنعتی'), ('nomadic', 'عشایری')], max_length=20, null=True), + ), + migrations.AlterField( + model_name='quotalivestockallocation', + name='livestock_subtype', + field=models.CharField(choices=[('milking', 'شیری'), ('fattening', 'پرواری')], max_length=20, null=True), + ), + migrations.AlterField( + model_name='quotalivestockallocation', + name='livestock_type', + field=models.CharField(choices=[('light', 'سبک'), ('heavy', 'سنگین')], max_length=20, null=True), + ), + migrations.AlterField( + model_name='quotalivestockallocation', + name='quantity_kg', + field=models.DecimalField(decimal_places=2, max_digits=12, null=True), + ), + ] diff --git a/apps/product/migrations/0034_alter_quotalivestockallocation_unique_together.py b/apps/product/migrations/0034_alter_quotalivestockallocation_unique_together.py new file mode 100644 index 0000000..d8f5cd1 --- /dev/null +++ b/apps/product/migrations/0034_alter_quotalivestockallocation_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0 on 2025-06-30 11:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0033_alter_quotalivestockagelimitation_livestock_subtype_and_more'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='quotalivestockallocation', + unique_together=set(), + ), + ] diff --git a/apps/product/migrations/0035_remove_quota_quota_balance.py b/apps/product/migrations/0035_remove_quota_quota_balance.py new file mode 100644 index 0000000..619bab0 --- /dev/null +++ b/apps/product/migrations/0035_remove_quota_quota_balance.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0 on 2025-07-01 05:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0034_alter_quotalivestockallocation_unique_together'), + ] + + operations = [ + migrations.RemoveField( + model_name='quota', + name='quota_balance', + ), + ] diff --git a/apps/product/migrations/0036_historicalquotadistribution.py b/apps/product/migrations/0036_historicalquotadistribution.py new file mode 100644 index 0000000..2976926 --- /dev/null +++ b/apps/product/migrations/0036_historicalquotadistribution.py @@ -0,0 +1,52 @@ +# Generated by Django 5.0 on 2025-07-02 05:18 + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0023_alter_organization_company_code_and_more'), + ('product', '0035_remove_quota_quota_balance'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalQuotaDistribution', + fields=[ + ('id', models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('create_date', models.DateTimeField(blank=True, editable=False)), + ('modify_date', models.DateTimeField(blank=True, editable=False)), + ('creator_info', models.CharField(max_length=100, null=True)), + ('modifier_info', models.CharField(max_length=100, null=True)), + ('trash', models.BooleanField(default=False)), + ('description', models.TextField(max_length=1000, null=True)), + ('distribution_id', models.CharField(max_length=20, null=True)), + ('weight', models.PositiveBigIntegerField(default=0)), + ('warehouse_entry', models.PositiveBigIntegerField(default=0)), + ('warehouse_balance', models.PositiveBigIntegerField(default=0)), + ('been_sold', models.PositiveBigIntegerField(default=0)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('assigned_organization', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='authentication.organization')), + ('assigner_organization', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='authentication.organization')), + ('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL)), + ('quota', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='product.quota')), + ], + options={ + 'verbose_name': 'historical quota distribution', + 'verbose_name_plural': 'historical quota distributions', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/apps/product/models.py b/apps/product/models.py index 260392e..a071929 100644 --- a/apps/product/models.py +++ b/apps/product/models.py @@ -1,7 +1,12 @@ +import datetime +from simple_history.models import HistoricalRecords from django.db import models from apps.core.models import BaseModel from apps.authorization.models import UserRelations +from apps.authentication.models import Organization from django.contrib.postgres.fields import ArrayField +from datetime import datetime +import jdatetime class LivestockGroup(models.TextChoices): @@ -74,6 +79,41 @@ class Product(BaseModel): next_code = 10 return next_code + def quota_information(self): + """ + quotas information of product + """ + + # number of quotas + quotas_count = self.quotas.filter(is_closed=False).count() + + # total weight of product that assigned in quota + total_quotas_weight = self.quotas.filter(is_closed=False).aggregate( + total=models.Sum('quota_weight') + )['total'] or 0 + + # total remaining weight of product quotas + total_remaining_quotas_weight = self.quotas.filter(is_closed=False).aggregate( + total=models.Sum('remaining_weight') + )['total'] or 0 + + total_distributed_weight = QuotaDistribution.objects.filter( + quota__product_id=self.id, + quota__is_closed=False + ).aggregate(total_weight=models.Sum('weight'))['total_weight'] or 0 + + total_sold = QuotaDistribution.objects.filter( + quota__product_id=self.id, + quota__is_closed=False + ).aggregate(total_sold=models.Sum('been_sold'))['total_sold'] or 0 + + total_warehouse_entry = QuotaDistribution.objects.filter( + quota__product_id=self.id, + quota__is_closed=False + ).aggregate(total_entry=models.Sum('warehouse_entry'))['total_entry'] or 0 + + return {'quotas_count': quotas_count, 'total_quotas_weight': total_quotas_weight} + def __str__(self): return f'name: {self.name} - type: {self.type}' @@ -159,8 +199,8 @@ class Broker(BaseModel): related_name='product_broker', null=True ) - organization_relations = models.ForeignKey( - UserRelations, + organization = models.ForeignKey( + Organization, on_delete=models.CASCADE, related_name='product_organization', null=True @@ -174,7 +214,7 @@ class Broker(BaseModel): required = models.BooleanField(default=False) def __str__(self): - return f'{self.organization_relations.organization.name} - {self.product.name}' + return f'{self.organization.name} - {self.product.name}' def save(self, *args, **kwargs): return super(Broker, self).save(*args, **kwargs) @@ -245,13 +285,13 @@ class Quota(BaseModel): """ quota for product with some conditions """ registerer_organization = models.ForeignKey( - UserRelations, + Organization, on_delete=models.CASCADE, related_name='quotas', null=True ) assigned_organizations = models.ManyToManyField( - UserRelations, + Organization, related_name='assigned_quotas', blank=True ) @@ -260,7 +300,6 @@ class Quota(BaseModel): quota_weight = models.PositiveIntegerField(default=0) remaining_weight = models.PositiveBigIntegerField(default=0) quota_distributed = models.PositiveIntegerField(default=0) - quota_balance = models.PositiveIntegerField(default=0) product = models.ForeignKey( Product, on_delete=models.CASCADE, @@ -279,6 +318,8 @@ class Quota(BaseModel): base_price_factory = models.DecimalField(max_digits=12, decimal_places=2) base_price_cooperative = models.DecimalField(max_digits=12, decimal_places=2) final_price = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True) + is_closed = models.BooleanField(default=False) + closed_at = models.DateTimeField(null=True, blank=True) def __str__(self): return f"Quota ({self.id}) for {self.product.name}" @@ -313,6 +354,13 @@ class Quota(BaseModel): distributed_weight = self.distributions_assigned.aggregate(total=models.Sum("weight"))["total"] or 0 return self.quota_weight - distributed_weight + def is_in_valid_time(self): + """ check if quota allowed time for distribute, sale, etc is expired""" + now = datetime.now() + persian_date = jdatetime.datetime.fromgregorian(datetime=now) + + return persian_date.month in self.month_choices + def save(self, calculate_final_price=None, *args, **kwargs): if not self.quota_id: self.quota_id = self.generate_quota_id() @@ -364,7 +412,7 @@ class QuotaBrokerValue(BaseModel): value = models.DecimalField(max_digits=12, decimal_places=2) def __str__(self): - return f"Quota ({self.quota.id}) for Broker({self.broker.organization_relations.organization.name})" + return f"Quota ({self.quota.id}) for Broker({self.broker.organization.name})" def save(self, *args, **kwargs): return super(QuotaBrokerValue, self).save(*args, **kwargs) @@ -379,13 +427,16 @@ class QuotaLivestockAllocation(BaseModel): related_name="livestock_allocations", null=True ) - livestock_group = models.CharField(max_length=20, choices=LivestockGroup.choices) - livestock_type = models.CharField(max_length=20, choices=LivestockType.choices) - livestock_subtype = models.CharField(max_length=20, choices=LivestockSubtype.choices) - quantity_kg = models.DecimalField(max_digits=12, decimal_places=2) + livestock_group = models.CharField(max_length=20, choices=LivestockGroup.choices, null=True) + livestock_type = models.CharField(max_length=20, choices=LivestockType.choices, null=True) + livestock_subtype = models.CharField(max_length=20, choices=LivestockSubtype.choices, null=True) + quantity_kg = models.DecimalField(max_digits=12, decimal_places=2, null=True) + """ + @using for set unique values between fields class Meta: unique_together = ('quota', 'livestock_group', 'livestock_type', 'livestock_subtype') + """ def __str__(self): return f"{self.livestock_group} - {self.livestock_type}/{self.livestock_subtype}: {self.quantity_kg}kg" @@ -401,8 +452,8 @@ class QuotaLiveStockAgeLimitation(BaseModel): related_name='livestock_age_limitations', null=True ) - livestock_type = models.CharField(max_length=20, choices=LivestockType.choices) - livestock_subtype = models.CharField(max_length=20, choices=LivestockSubtype.choices) + livestock_type = models.CharField(max_length=20, choices=LivestockType.choices, null=True) + livestock_subtype = models.CharField(max_length=20, choices=LivestockSubtype.choices, null=True) age_month = models.PositiveIntegerField(default=0) def __str__(self): @@ -414,11 +465,17 @@ class QuotaLiveStockAgeLimitation(BaseModel): class QuotaDistribution(BaseModel): assigner_organization = models.ForeignKey( - UserRelations, + Organization, on_delete=models.CASCADE, related_name='distributions_assigner', null=True ) + assigned_organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name='distributions', + null=True + ) description = models.TextField(max_length=1000, null=True) distribution_id = models.CharField(max_length=20, null=True) quota = models.ForeignKey( @@ -427,19 +484,32 @@ class QuotaDistribution(BaseModel): related_name='distributions_assigned', null=True ) - assigned_organization = models.ForeignKey( - UserRelations, - on_delete=models.CASCADE, - related_name='distributions', - null=True - ) weight = models.PositiveBigIntegerField(default=0) warehouse_entry = models.PositiveBigIntegerField(default=0) warehouse_balance = models.PositiveBigIntegerField(default=0) been_sold = models.PositiveBigIntegerField(default=0) + history = HistoricalRecords() + + def generate_distribution_id(self): + """ generate special id for quota distribution """ + + year = jdatetime.datetime.now().year + month = jdatetime.datetime.now().month + day = jdatetime.datetime.now().day + product_id = self.quota.product.product_id + quota_id = self.quota.quota_id + + base_code = f"{str(year)[3]}{month}{day}{product_id}{quota_id}" + + similar_codes = QuotaDistribution.objects.filter(distribution_id__startswith=base_code).count() + counter = str(similar_codes + 1).zfill(4) + + return f"{base_code}{counter}" def __str__(self): - return f"{self.distribution_id}-{self.assigned_organization.organization.name}" + return f"{self.distribution_id}-" def save(self, *args, **kwargs): + if not self.distribution_id: + self.distribution_id = self.generate_distribution_id() return super(QuotaDistribution, self).save(*args, **kwargs) diff --git a/apps/product/signals.py b/apps/product/signals.py index 4422567..1787287 100644 --- a/apps/product/signals.py +++ b/apps/product/signals.py @@ -10,7 +10,8 @@ def recalculate_remaining_amount(quota): )['total'] or 0 quota.remaining_weight = quota.quota_weight - total_distributed - quota.save(update_fields=["remaining_weight"]) + quota.quota_distributed = total_distributed + quota.save(update_fields=["remaining_weight", "quota_distributed"]) @receiver(post_save, sender=QuotaDistribution) diff --git a/apps/product/web/api/v1/product_api.py b/apps/product/web/api/v1/product_api.py index 6344f79..d599d06 100644 --- a/apps/product/web/api/v1/product_api.py +++ b/apps/product/web/api/v1/product_api.py @@ -1,6 +1,7 @@ import datetime from apps.product.web.api.v1 import product_serializers as product_serializers +from apps.product.exceptions import QuotaExpiredTimeException from rest_framework.exceptions import APIException from apps.product import models as product_models from rest_framework.response import Response @@ -10,6 +11,7 @@ from rest_framework import viewsets from rest_framework import status from django.db import transaction from django.db.models import Q +from datetime import datetime def trash(queryset, pk): # noqa @@ -65,6 +67,10 @@ class ProductViewSet(viewsets.ModelViewSet): queryset = product_models.Product.objects.all() serializer_class = product_serializers.ProductSerializer + def list(self, request, *args, **kwargs): + product = self.queryset.get(id=1) + return Response(product.quota_information(), status.HTTP_200_OK) + @action( methods=['put'], detail=True, @@ -346,24 +352,26 @@ class QuotaViewSet(viewsets.ModelViewSet): # noqa user_relation = request.user.user_relation.all().first() # add user relation to data - request.data['registerer_organization'] = user_relation.id + request.data['registerer_organization'] = user_relation.organization.id # create quota serializer = self.serializer_class(data=request.data) if serializer.is_valid(): quota = serializer.save() - quota.remaining_quota_weight = quota.quota_weight + quota.remaining_weight = quota.quota_weight # create incentive plan + plans_list = [] if 'incentive_plan_data' in request.data.keys(): - incentive_plan = CustomOperations().custom_create( - request=request, - view=QuotaIncentiveAssignmentViewSet(), - data_key='incentive_plan_data', - additional_data={'quota': quota.id} - ) - else: - incentive_plan = {} + for plan in request.data['incentive_plan_data']: + plan.update({'quota': quota.id}) + incentive_plan = CustomOperations().custom_create( + request=request, + view=QuotaIncentiveAssignmentViewSet(), + data_key='incentive_plan_data', + data=plan + ) + plans_list.append(incentive_plan) # create product price attributes for quota attributes_value_list = [] @@ -376,7 +384,6 @@ class QuotaViewSet(viewsets.ModelViewSet): # noqa data=attr ) attributes_value_list.append(attributes) - # create product broker values for quota broker_data_list = [] if 'broker_data' in request.data.keys(): @@ -400,7 +407,6 @@ class QuotaViewSet(viewsets.ModelViewSet): # noqa data=ls_alloc ) allocations_list.append(allocations) - # create livestock age limits for quota livestock_age_limits = [] if 'livestock_age_limitations' in request.data.keys(): @@ -415,19 +421,133 @@ class QuotaViewSet(viewsets.ModelViewSet): # noqa data = { 'quota': serializer.data, - 'incentive_plan': incentive_plan, + 'incentive_plan': plans_list, # noqa 'attribute_values': attributes_value_list, 'broker_values': broker_data_list, 'live_stock_allocations': allocations_list, 'livestock_age_limitations': livestock_age_limits } + # call save method to generate id & calculate quota final price quota.save(calculate_final_price=True) return Response(data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_403_FORBIDDEN) @transaction.atomic - def update(self, request, *args, **kwargs): - pass + def update(self, request, pk=None, *args, **kwargs): + # get user relations data like organization + user_relation = request.user.user_relation.all().first() + + # add user relation to data + request.data['registerer_organization'] = user_relation.organization.id + + # create quota + serializer = self.serializer_class(data=request.data, instance=self.get_object(), partial=True) + if serializer.is_valid(): + quota = serializer.save() + + # create incentive plan + plans_list = [] + if 'incentive_plan_data' in request.data.keys(): + for plan in request.data['incentive_plan_data']: + plan.update({'quota': quota.id}) + incentive_plan = CustomOperations().custom_update( + request=request, + view=QuotaIncentiveAssignmentViewSet(), + data_key='incentive_plan_data', + obj_id=plan['id'], + data=plan + ) + plans_list.append(incentive_plan) + + # create product price attributes for quota + attributes_value_list = [] # noqa + if 'price_attributes_data' in request.data.keys(): + for attr in request.data['price_attributes_data']: + attr.update({'quota': quota.id}) + attributes = CustomOperations().custom_update( + request=request, + view=AttributeValueViewSet(), + obj_id=attr['id'], + data=attr + ) + attributes_value_list.append(attributes) + # create product broker values for quota + broker_data_list = [] + if 'broker_data' in request.data.keys(): + for broker in request.data['broker_data']: + broker.update({'quota': quota.id}) + broker_value = CustomOperations().custom_update( + request=request, + view=QuotaBrokerValueViewSet(), + obj_id=broker['id'], + data=broker + ) + broker_data_list.append(broker_value) + + # create livestock allocations to quota + allocations_list = [] + if 'livestock_allocation_data' in request.data.keys(): + for ls_alloc in request.data['livestock_allocation_data']: + ls_alloc.update({'quota': quota.id}) + allocations = CustomOperations().custom_update( + request=request, + view=QuotaLiveStockAllocationViewSet(), + obj_id=ls_alloc['id'], + data=ls_alloc + ) + allocations_list.append(allocations) + # create livestock age limits for quota + livestock_age_limits = [] + if 'livestock_age_limitations' in request.data.keys(): + for age_limit in request.data['livestock_age_limitations']: + age_limit.update({'quota': quota.id}) + age_limit_creation_object = CustomOperations().custom_update( + request=request, + view=QuotaLiveStockAgeLimitation(), + obj_id=age_limit['id'], + data=age_limit + ) + livestock_age_limits.append(age_limit_creation_object) + + data = { + 'quota': serializer.data, + 'incentive_plan': plans_list, # noqa + 'attribute_values': attributes_value_list, + 'broker_values': broker_data_list, + 'live_stock_allocations': allocations_list, + 'livestock_age_limitations': livestock_age_limits + } + + # call save method to generate id & calculate quota final price + quota.save(calculate_final_price=True) + return Response(data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_403_FORBIDDEN) + + @action( + methods=['patch'], + detail=True, + url_path='close', + url_name='close', + name='close' + ) + @transaction.atomic + def close(self, request, pk=None): + """ to close quota """ + quota = self.get_object() + + # check quota expired time + if not quota.is_in_valid_time(): + raise QuotaExpiredTimeException() + + if quota.is_closed: + raise APIException("این سهمیه قبلا بسته شده است", status.HTTP_400_BAD_REQUEST) # noqa + + quota.is_closed = True + quota.closed_at = datetime.now() + quota.save() + + return Response(status.HTTP_200_OK) @action( methods=['get'], @@ -440,7 +560,7 @@ class QuotaViewSet(viewsets.ModelViewSet): # noqa """ list of quotas for creator """ assigner = product_models.UserRelations.objects.filter(user=request.user).first() serializers = self.serializer_class( - self.queryset.filter(registerer_organization=assigner), + self.queryset.filter(registerer_organization=assigner.organization), many=True ).data return Response(serializers.data, status=status.HTTP_200_OK) @@ -456,7 +576,7 @@ class QuotaViewSet(viewsets.ModelViewSet): # noqa """ list of quotas for assigned organizations """ assigned = product_models.UserRelations.objects.filter(user=request.user).first() serializer = self.serializer_class( - self.queryset.filter(assigned_organizations=assigned), + self.queryset.filter(assigned_organizations=assigned.organization), many=True ) diff --git a/apps/product/web/api/v1/product_serializers.py b/apps/product/web/api/v1/product_serializers.py index 5110cef..5898bd1 100644 --- a/apps/product/web/api/v1/product_serializers.py +++ b/apps/product/web/api/v1/product_serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from apps.product import models as product_models from apps.authorization.api.v1 import serializers as authorize_serializers +from apps.authentication.api.v1.serializers.serializer import OrganizationSerializer class ProductCategorySerializer(serializers.ModelSerializer): @@ -53,16 +54,14 @@ class AttributeValueSerializer(serializers.ModelSerializer): model = product_models.AttributeValue fields = '__all__' - def to_representation(self, instance): - """ Custom output """ + def update(self, instance, validated_data): - representation = super().to_representation(instance) - if instance.quota: - representation['quota'] = QuotaSerializer(instance.quota).data - if instance.attribute: - representation['attribute'] = AttributeSerializer(instance.attribute).data + instance.quota = validated_data.get('quota', instance.quota) + instance.attribute = validated_data.get('attribute', instance.attribute) + instance.value = validated_data.get('value', instance.value) + instance.save() - return representation + return instance class BrokerSerializer(serializers.ModelSerializer): @@ -74,9 +73,9 @@ class BrokerSerializer(serializers.ModelSerializer): def to_representation(self, instance): representation = super().to_representation(instance) - if instance.organization_relations: - representation['organization_relations'] = authorize_serializers.UserRelationSerializer( - instance.organization_relations + if instance.organization: + representation['organization'] = OrganizationSerializer( + instance.organization ).data return representation @@ -109,6 +108,66 @@ class QuotaSerializer(serializers.ModelSerializer): class Meta: model = product_models.Quota fields = '__all__' + depth = 0 + + def to_representation(self, instance): + representation = super().to_representation(instance) + if isinstance(instance, product_models.Quota): + representation['incentive_plan'] = QuotaIncentiveAssignmentSerializer( + instance.incentive_assignments.all(), + many=True + ).data + + representation['attribute_values'] = AttributeValueSerializer( + instance.attribute_values.all(), + many=True + ).data + + representation['brokers'] = QuotaBrokerValueSerializer( + instance.broker_values.all(), + many=True + ).data + + representation['livestock_allocations'] = QuotaLiveStockAllocationSerializer( + instance.livestock_allocations.all(), + many=True + ).data + + representation['livestock_limitations'] = QuotaLiveStockAgeLimitationSerializer( + instance.livestock_age_limitations.all(), + many=True + ).data + + return representation + + def update(self, instance, validated_data): + """ Custom Update """ + + instance.quota_id = validated_data.get('quota_id', instance.quota_id) + instance.quota_code = validated_data.get('quota_code', instance.quota_code) + instance.quota_weight = validated_data.get('quota_weight', instance.quota_weight) + instance.remaining_weight = validated_data.get('remaining_weight', instance.remaining_weight) + instance.quota_distributed = validated_data.get('quota_distributed', instance.quota_distributed) + instance.quota_balance = validated_data.get('quota_balance', instance.quota_balance) + instance.product = validated_data.get('product', instance.product) + instance.sale_type = validated_data.get('sale_type', instance.sale_type) + instance.month_choices = validated_data.get('month_choices', instance.month_choices) + instance.group = validated_data.get('group', instance.group) + instance.has_distribution_limit = validated_data.get('has_distribution_limit', instance.has_distribution_limit) + instance.distribution_mode = validated_data.get('distribution_mode', instance.distribution_mode) + instance.base_price_factory = validated_data.get('base_price_factory', instance.base_price_factory) + instance.base_price_cooperative = validated_data.get('base_price_cooperative', instance.base_price_cooperative) + instance.final_price = validated_data.get('final_price', instance.final_price) + instance.is_closed = validated_data.get('is_closed', instance.is_closed) + instance.closed_at = validated_data.get('closed_at', instance.closed_at) + instance.save() + + instance.assigned_organizations.clear() + instance.assigned_organizations.add( + *(validated_data.get('assigned_organizations', instance.assigned_organizations)) + ) + + return instance class QuotaIncentiveAssignmentSerializer(serializers.ModelSerializer): @@ -116,20 +175,74 @@ class QuotaIncentiveAssignmentSerializer(serializers.ModelSerializer): model = product_models.QuotaIncentiveAssignment fields = '__all__' + def update(self, instance, validated_data): + """ Custom Update """ + + instance.quota = validated_data.get('quota', instance.quota) + instance.incentive_plan = validated_data.get('incentive_plan', instance.incentive_plan) + instance.heavy_value = validated_data.get('heavy_value', instance.heavy_value) + instance.light_value = validated_data.get('light_value', instance.light_value) + instance.save() + + return instance + class QuotaBrokerValueSerializer(serializers.ModelSerializer): # noqa class Meta: model = product_models.QuotaBrokerValue fields = '__all__' + def update(self, instance, validated_data): + """ Custom Update """ + + instance.quota = validated_data.get('quota', instance.quota) + instance.broker = validated_data.get('broker', instance.broker) + instance.value = validated_data.get('value', instance.value) + instance.save() + + return instance + class QuotaLiveStockAllocationSerializer(serializers.ModelSerializer): class Meta: model = product_models.QuotaLivestockAllocation fields = '__all__' + extra_kwargs = { + 'livestock_group': { + 'required': False + }, + 'livestock_type': { + 'required': False + }, + 'livestock_subtype': { + 'required': False + } + } + + def update(self, instance, validated_data): + """ Custom Update """ + + instance.quota = validated_data.get('quota', instance.quota) + instance.livestock_group = validated_data.get('livestock_group', instance.livestock_group) + instance.livestock_type = validated_data.get('livestock_type', instance.livestock_type) + instance.livestock_subtype = validated_data.get('livestock_subtype', instance.livestock_subtype) + instance.save() + + return instance class QuotaLiveStockAgeLimitationSerializer(serializers.ModelSerializer): class Meta: model = product_models.QuotaLiveStockAgeLimitation fields = '__all__' + + def update(self, instance, validated_data): + """ Custom Update """ + + instance.quota = validated_data.get('quota', instance.quota) + instance.livestock_type = validated_data.get('livestock_type', instance.livestock_type) + instance.livestock_subtype = validated_data.get('livestock_subtype', instance.livestock_subtype) + instance.age_month = validated_data.get('age_month', instance.age_month) + instance.save() + + return instance diff --git a/apps/product/web/api/v1/quota_distribution_api.py b/apps/product/web/api/v1/quota_distribution_api.py index 2d1fc4c..e246c1b 100644 --- a/apps/product/web/api/v1/quota_distribution_api.py +++ b/apps/product/web/api/v1/quota_distribution_api.py @@ -42,7 +42,7 @@ class QuotaDistributionViewSet(viewsets.ModelViewSet): except APIException as e: raise APIException("unauthorized", code=status.HTTP_401_UNAUTHORIZED) - request.data.update({'assigner_organization': assigner_user.id}) + request.data.update({'assigner_organization': assigner_user.organization.id}) serializer = self.serializer_class(data=request.data) if serializer.is_valid(raise_exception=True): @@ -77,6 +77,14 @@ class QuotaDistributionViewSet(viewsets.ModelViewSet): @transaction.atomic def trash(self, request, pk=None): """ Sent quota distribution to trash """ + quota_distribution = self.get_object() + + # check if distribution has inventory entry + if quota_distribution.inventory_entry.exists(): + raise APIException( + "امکان حذف این توزیع وجود ندارد. ورود به انبار برای آن ثبت شده است", # noqa + status.HTTP_400_BAD_REQUEST + ) try: trash(self.queryset, pk) except APIException as e: @@ -92,6 +100,14 @@ class QuotaDistributionViewSet(viewsets.ModelViewSet): @transaction.atomic def delete(self, request, pk=None): """ Full delete of quota distribution object """ + quota_distribution = self.get_object() + + # check if distribution has inventory entry + if quota_distribution.inventory_entry.exists(): + raise APIException( + "امکان حذف این توزیع وجود ندارد. ورود به انبار برای آن ثبت شده است", # noqa + status.HTTP_400_BAD_REQUEST + ) try: delete(self.queryset, pk) return Response(status=status.HTTP_200_OK) diff --git a/apps/product/web/api/v1/quota_distribution_serializers.py b/apps/product/web/api/v1/quota_distribution_serializers.py index f75844d..4373e12 100644 --- a/apps/product/web/api/v1/quota_distribution_serializers.py +++ b/apps/product/web/api/v1/quota_distribution_serializers.py @@ -2,7 +2,11 @@ from rest_framework import serializers from apps.product import models as product_models from apps.product.web.api.v1.product_serializers import QuotaSerializer from django.db import models -from apps.product.exceptions import QuotaWeightException +from apps.product.exceptions import ( + QuotaWeightException, + QuotaClosedException, + QuotaExpiredTimeException +) from rest_framework import status @@ -17,13 +21,25 @@ class QuotaDistributionSerializer(serializers.ModelSerializer): } def validate(self, data): - """ to validate if distribution weight - more than quota weight raise exception """ + """ + to validate if distribution weight + more than quota weight raise exception + or if quota is closed raise exception + """ quota = data['quota'] amount = data['weight'] instance_id = self.instance.id if self.instance else None + # check quota expired time + if not quota.is_in_valid_time(): + raise QuotaExpiredTimeException() + + # check if quota is closed + if quota.is_closed: + raise QuotaClosedException() + + # total quota distributions weight total = product_models.QuotaDistribution.objects.filter( quota=quota ).exclude(id=instance_id).aggregate( diff --git a/apps/warehouse/apps.py b/apps/warehouse/apps.py index 64fa1be..019c386 100644 --- a/apps/warehouse/apps.py +++ b/apps/warehouse/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class WarehouseConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'apps.warehouse' + + def ready(self): + import apps.warehouse.signals diff --git a/apps/warehouse/exceptions.py b/apps/warehouse/exceptions.py new file mode 100644 index 0000000..2bcf795 --- /dev/null +++ b/apps/warehouse/exceptions.py @@ -0,0 +1,14 @@ +from rest_framework.exceptions import APIException +from rest_framework import status + + +class InventoryEntryWeightException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "مقدار وارد شده برای ورودی به انبار از مقدار کل سهمیه توزیع داده شده بیشتر است" # noqa + default_code = 'error' + + +class TotalInventorySaleException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "مقدار وارد شده برای فروش از انبار از موجودی انبار بیشتر میباشد" # noqa + default_code = 'error' diff --git a/apps/warehouse/migrations/0001_initial.py b/apps/warehouse/migrations/0001_initial.py new file mode 100644 index 0000000..792bc13 --- /dev/null +++ b/apps/warehouse/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 5.0 on 2025-06-28 10:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('authorization', '0019_page_is_active_permissions_is_active'), + ('product', '0029_remove_quota_assigned_organizations_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WareHouse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_date', models.DateTimeField(auto_now_add=True)), + ('modify_date', models.DateTimeField(auto_now=True)), + ('creator_info', models.CharField(max_length=100, null=True)), + ('modifier_info', models.CharField(max_length=100, null=True)), + ('trash', models.BooleanField(default=False)), + ('name', models.CharField(max_length=250, null=True)), + ('address', models.TextField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_createddby', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_modifiedby', to=settings.AUTH_USER_MODEL)), + ('user_relation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='warehouse', to='authorization.userrelations')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='InventoryEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_date', models.DateTimeField(auto_now_add=True)), + ('modify_date', models.DateTimeField(auto_now=True)), + ('creator_info', models.CharField(max_length=100, null=True)), + ('modifier_info', models.CharField(max_length=100, null=True)), + ('trash', models.BooleanField(default=False)), + ('document', models.CharField(max_length=250, null=True)), + ('is_confirmed', models.BooleanField(default=False)), + ('notes', models.TextField(blank=True, null=True)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_createddby', to=settings.AUTH_USER_MODEL)), + ('distribution', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_entry', to='product.quotadistribution')), + ('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_modifiedby', to=settings.AUTH_USER_MODEL)), + ('warehouse', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory', to='warehouse.warehouse')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/warehouse/migrations/0002_remove_warehouse_address_remove_warehouse_name_and_more.py b/apps/warehouse/migrations/0002_remove_warehouse_address_remove_warehouse_name_and_more.py new file mode 100644 index 0000000..bc91e97 --- /dev/null +++ b/apps/warehouse/migrations/0002_remove_warehouse_address_remove_warehouse_name_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0 on 2025-06-28 12:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('warehouse', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='warehouse', + name='address', + ), + migrations.RemoveField( + model_name='warehouse', + name='name', + ), + migrations.RemoveField( + model_name='warehouse', + name='user_relation', + ), + ] diff --git a/apps/warehouse/migrations/0003_remove_inventoryentry_distribution_and_more.py b/apps/warehouse/migrations/0003_remove_inventoryentry_distribution_and_more.py new file mode 100644 index 0000000..ebabca1 --- /dev/null +++ b/apps/warehouse/migrations/0003_remove_inventoryentry_distribution_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.0 on 2025-06-28 12:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0023_alter_organization_company_code_and_more'), + ('warehouse', '0002_remove_warehouse_address_remove_warehouse_name_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='inventoryentry', + name='distribution', + ), + migrations.AddField( + model_name='warehouse', + name='organization', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='warehouse', to='authentication.organization'), + ), + ] diff --git a/apps/warehouse/migrations/0004_inventoryentry_distribution.py b/apps/warehouse/migrations/0004_inventoryentry_distribution.py new file mode 100644 index 0000000..314f5e7 --- /dev/null +++ b/apps/warehouse/migrations/0004_inventoryentry_distribution.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0 on 2025-06-28 12:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0031_broker_organization_quota_assigned_organizations_and_more'), + ('warehouse', '0003_remove_inventoryentry_distribution_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryentry', + name='distribution', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_entry', to='product.quotadistribution'), + ), + ] diff --git a/apps/warehouse/migrations/0005_remove_inventoryentry_warehouse_and_more.py b/apps/warehouse/migrations/0005_remove_inventoryentry_warehouse_and_more.py new file mode 100644 index 0000000..e26fc12 --- /dev/null +++ b/apps/warehouse/migrations/0005_remove_inventoryentry_warehouse_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.0 on 2025-06-29 05:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0023_alter_organization_company_code_and_more'), + ('product', '0031_broker_organization_quota_assigned_organizations_and_more'), + ('warehouse', '0004_inventoryentry_distribution'), + ] + + operations = [ + migrations.RemoveField( + model_name='inventoryentry', + name='warehouse', + ), + migrations.AddField( + model_name='inventoryentry', + name='delivery_address', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='inventoryentry', + name='lading_number', + field=models.CharField(max_length=50, null=True), + ), + migrations.AddField( + model_name='inventoryentry', + name='organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory', to='authentication.organization'), + ), + migrations.AddField( + model_name='inventoryentry', + name='weight', + field=models.PositiveBigIntegerField(default=0), + ), + migrations.AlterField( + model_name='inventoryentry', + name='distribution', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_entry', to='product.quotadistribution'), + ), + migrations.DeleteModel( + name='WareHouse', + ), + ] diff --git a/apps/warehouse/migrations/0006_inventoryquotasale_inventorysaletransaction.py b/apps/warehouse/migrations/0006_inventoryquotasale_inventorysaletransaction.py new file mode 100644 index 0000000..46dfb61 --- /dev/null +++ b/apps/warehouse/migrations/0006_inventoryquotasale_inventorysaletransaction.py @@ -0,0 +1,68 @@ +# Generated by Django 5.0 on 2025-06-29 11:18 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0023_alter_organization_company_code_and_more'), + ('authorization', '0019_page_is_active_permissions_is_active'), + ('product', '0032_quota_closed_at_quota_is_closed'), + ('warehouse', '0005_remove_inventoryentry_warehouse_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='InventoryQuotaSale', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_date', models.DateTimeField(auto_now_add=True)), + ('modify_date', models.DateTimeField(auto_now=True)), + ('creator_info', models.CharField(max_length=100, null=True)), + ('modifier_info', models.CharField(max_length=100, null=True)), + ('trash', models.BooleanField(default=False)), + ('weight', models.PositiveBigIntegerField(default=0)), + ('herd_owners_number', models.PositiveBigIntegerField(default=0)), + ('transactions_number', models.PositiveBigIntegerField(default=0)), + ('sale_status', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=0)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_createddby', to=settings.AUTH_USER_MODEL)), + ('inventory_entry', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_sales', to='warehouse.inventoryentry')), + ('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_modifiedby', to=settings.AUTH_USER_MODEL)), + ('quota_distribution', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_sales', to='product.quotadistribution')), + ('seller_organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_sales', to='authentication.organization')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='InventorySaleTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_date', models.DateTimeField(auto_now_add=True)), + ('modify_date', models.DateTimeField(auto_now=True)), + ('creator_info', models.CharField(max_length=100, null=True)), + ('modifier_info', models.CharField(max_length=100, null=True)), + ('trash', models.BooleanField(default=False)), + ('transaction_id', models.CharField(max_length=50, null=True)), + ('weight', models.DecimalField(decimal_places=2, max_digits=12)), + ('delivery_address', models.TextField(blank=True, null=True)), + ('total_price', models.PositiveBigIntegerField(default=0)), + ('description', models.TextField(blank=True, null=True)), + ('buyer_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='buyer_sale_transactions', to='authorization.userrelations')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_createddby', to=settings.AUTH_USER_MODEL)), + ('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_modifiedby', to=settings.AUTH_USER_MODEL)), + ('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sale_transactions', to='product.product')), + ('quota_sale', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sale_transactions', to='warehouse.inventoryquotasale')), + ('seller_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seller_sale_transactions', to='authorization.userrelations')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/warehouse/migrations/0007_remove_inventorysaletransaction_buyer_user_and_more.py b/apps/warehouse/migrations/0007_remove_inventorysaletransaction_buyer_user_and_more.py new file mode 100644 index 0000000..07cf29b --- /dev/null +++ b/apps/warehouse/migrations/0007_remove_inventorysaletransaction_buyer_user_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0 on 2025-06-29 12:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('warehouse', '0006_inventoryquotasale_inventorysaletransaction'), + ] + + operations = [ + migrations.RemoveField( + model_name='inventorysaletransaction', + name='buyer_user', + ), + migrations.RemoveField( + model_name='inventorysaletransaction', + name='seller_user', + ), + ] diff --git a/apps/warehouse/migrations/0008_inventorysaletransaction_buyer_user_and_more.py b/apps/warehouse/migrations/0008_inventorysaletransaction_buyer_user_and_more.py new file mode 100644 index 0000000..3030658 --- /dev/null +++ b/apps/warehouse/migrations/0008_inventorysaletransaction_buyer_user_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0 on 2025-06-29 12:22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('warehouse', '0007_remove_inventorysaletransaction_buyer_user_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='inventorysaletransaction', + name='buyer_user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='buyer_sale_transactions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='inventorysaletransaction', + name='seller_user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seller_sale_transactions', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/warehouse/migrations/0009_inventorysaletransaction_buyer_organization.py b/apps/warehouse/migrations/0009_inventorysaletransaction_buyer_organization.py new file mode 100644 index 0000000..e587cba --- /dev/null +++ b/apps/warehouse/migrations/0009_inventorysaletransaction_buyer_organization.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0 on 2025-06-29 12:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0023_alter_organization_company_code_and_more'), + ('warehouse', '0008_inventorysaletransaction_buyer_user_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='inventorysaletransaction', + name='buyer_organization', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sale_transactions', to='authentication.organization'), + ), + ] diff --git a/apps/warehouse/migrations/0010_remove_inventorysaletransaction_quota_sale_and_more.py b/apps/warehouse/migrations/0010_remove_inventorysaletransaction_quota_sale_and_more.py new file mode 100644 index 0000000..9490de8 --- /dev/null +++ b/apps/warehouse/migrations/0010_remove_inventorysaletransaction_quota_sale_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.0 on 2025-07-01 07:05 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('warehouse', '0009_inventorysaletransaction_buyer_organization'), + ] + + operations = [ + migrations.RemoveField( + model_name='inventorysaletransaction', + name='quota_sale', + ), + migrations.RemoveField( + model_name='inventorysaletransaction', + name='buyer_organization', + ), + migrations.RemoveField( + model_name='inventorysaletransaction', + name='buyer_user', + ), + migrations.RemoveField( + model_name='inventorysaletransaction', + name='created_by', + ), + migrations.RemoveField( + model_name='inventorysaletransaction', + name='modified_by', + ), + migrations.RemoveField( + model_name='inventorysaletransaction', + name='product', + ), + migrations.RemoveField( + model_name='inventorysaletransaction', + name='seller_user', + ), + migrations.DeleteModel( + name='InventoryQuotaSale', + ), + migrations.DeleteModel( + name='InventorySaleTransaction', + ), + ] diff --git a/apps/warehouse/migrations/0011_inventoryquotasaletransaction.py b/apps/warehouse/migrations/0011_inventoryquotasaletransaction.py new file mode 100644 index 0000000..e43d1f8 --- /dev/null +++ b/apps/warehouse/migrations/0011_inventoryquotasaletransaction.py @@ -0,0 +1,49 @@ +# Generated by Django 5.0 on 2025-07-01 07:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0023_alter_organization_company_code_and_more'), + ('product', '0035_remove_quota_quota_balance'), + ('warehouse', '0010_remove_inventorysaletransaction_quota_sale_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='InventoryQuotaSaleTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_date', models.DateTimeField(auto_now_add=True)), + ('modify_date', models.DateTimeField(auto_now=True)), + ('creator_info', models.CharField(max_length=100, null=True)), + ('modifier_info', models.CharField(max_length=100, null=True)), + ('trash', models.BooleanField(default=False)), + ('transaction_id', models.CharField(max_length=50, null=True)), + ('weight', models.DecimalField(decimal_places=2, max_digits=12, null=True)), + ('delivery_address', models.TextField(blank=True, null=True)), + ('transaction_price', models.PositiveBigIntegerField(default=0)), + ('description', models.TextField(blank=True, null=True)), + ('herd_owners_number', models.PositiveBigIntegerField(default=0)), + ('transactions_number', models.PositiveBigIntegerField(default=0)), + ('sale_status', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=0)), + ('buyer_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='buyer_sale_transactions', to=settings.AUTH_USER_MODEL)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_createddby', to=settings.AUTH_USER_MODEL)), + ('inventory_entry', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_sales', to='warehouse.inventoryentry')), + ('modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_modifiedby', to=settings.AUTH_USER_MODEL)), + ('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sale_transactions', to='product.product')), + ('quota_distribution', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_sales', to='product.quotadistribution')), + ('seller_organization', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_sales', to='authentication.organization')), + ('seller_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seller_sale_transactions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/warehouse/models.py b/apps/warehouse/models.py index e69de29..84c8763 100644 --- a/apps/warehouse/models.py +++ b/apps/warehouse/models.py @@ -0,0 +1,103 @@ +from apps.product import models as product_models +from apps.authentication.models import User +from apps.core.models import BaseModel +from django.db import models + + +class InventoryEntry(BaseModel): + distribution = models.ForeignKey( + product_models.QuotaDistribution, + on_delete=models.CASCADE, + related_name='inventory_entry', + null=True + ) + organization = models.ForeignKey( + product_models.Organization, + on_delete=models.CASCADE, + related_name="inventory", + null=True + ) + weight = models.PositiveBigIntegerField(default=0) + balance = models + lading_number = models.CharField(max_length=50, null=True) + delivery_address = models.TextField(blank=True, null=True) + document = models.CharField(max_length=250, null=True) + is_confirmed = models.BooleanField(default=False) + notes = models.TextField(blank=True, null=True) + + @property + def total_sold(self): + return self.inventory_sales.aggregate(total=models.Sum('weight'))['total'] or 0 + + @property + def remaining_weight(self): + return self.weight - self.total_sold + + def __str__(self): + return f"distribution: {self.distribution.distribution_id}-{self.organization.name}" + + def save(self, *args, **kwargs): + super(InventoryEntry, self).save(*args, **kwargs) + + +class InventoryQuotaSaleTransaction(BaseModel): + transaction_id = models.CharField(max_length=50, null=True) + seller_organization = models.ForeignKey( + product_models.Organization, + on_delete=models.CASCADE, + related_name='inventory_sales', + null=True + ) + quota_distribution = models.ForeignKey( + product_models.QuotaDistribution, + on_delete=models.CASCADE, + related_name='inventory_sales', + null=True + ) + inventory_entry = models.ForeignKey( + InventoryEntry, + on_delete=models.CASCADE, + related_name='inventory_sales', + null=True + ) + buyer_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='buyer_sale_transactions', + null=True + ) + seller_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='seller_sale_transactions', + null=True + ) + weight = models.DecimalField(max_digits=12, decimal_places=2, null=True) + delivery_address = models.TextField(blank=True, null=True) + product = models.ForeignKey( + product_models.Product, + on_delete=models.CASCADE, + related_name='sale_transactions', + null=True + ) + transaction_price = models.PositiveBigIntegerField(default=0) + description = models.TextField(blank=True, null=True) + herd_owners_number = models.PositiveBigIntegerField(default=0) + transactions_number = models.PositiveBigIntegerField(default=0) + sale_status = models.BooleanField(default=False) + is_active = models.BooleanField(default=0) + + def buyers_count(self): + """ number of buyers from specific inventory """ + + unique_buyers_count = self.objects.filter( + inventory_entry=self.inventory_entry + ).values('buyer_user').distinct().count() + + return unique_buyers_count + + def __str__(self): + return f"Inventory Sale: {self.transaction_id}-{self.quota_distribution.distribution_id}" + + def save(self, *args, **kwargs): + super(InventoryQuotaSaleTransaction, self).save(*args, **kwargs) diff --git a/apps/warehouse/signals.py b/apps/warehouse/signals.py new file mode 100644 index 0000000..725d2eb --- /dev/null +++ b/apps/warehouse/signals.py @@ -0,0 +1,36 @@ +from django.db.models import Sum +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from apps.product.models import QuotaDistribution +from .models import InventoryEntry, InventoryQuotaSaleTransaction + + +def calculate_warehouse_entry(quota_distribution): + total_entry = quota_distribution.inventory_entry.aggregate( + total=Sum('weight') + )['total'] or 0 + + quota_distribution.warehouse_entry = total_entry + quota_distribution.save(update_fields=['warehouse_entry']) + + +def warehouse_sold_and_balance(quota_distribution): + total_sold = quota_distribution.inventory_sales.aggregate( + total=Sum('weight') + )['total'] or 0 + + quota_distribution.been_sold = total_sold + quota_distribution.warehouse_balance = quota_distribution.warehouse_entry - total_sold + quota_distribution.save(update_fields=['been_sold', 'warehouse_balance']) + + +@receiver(post_save, sender=InventoryEntry) +@receiver(post_delete, sender=InventoryEntry) +def update_distribution_warehouse_entry(sender, instance, **kwargs): + calculate_warehouse_entry(instance.distribution) + + +@receiver(post_save, sender=InventoryQuotaSaleTransaction) +@receiver(post_delete, sender=InventoryQuotaSaleTransaction) +def update_distribution_warehouse_sold_and_balance(sender, instance, **kwargs): + warehouse_sold_and_balance(instance.quota_distribution) diff --git a/apps/warehouse/urls.py b/apps/warehouse/urls.py index 0865d84..1f09e14 100644 --- a/apps/warehouse/urls.py +++ b/apps/warehouse/urls.py @@ -1 +1,7 @@ # Your urls go here +from django.urls import path, include + + +urlpatterns = [ + path('web/api/', include('apps.warehouse.web.api.v1.urls')) +] diff --git a/apps/warehouse/web/api/v1/api.py b/apps/warehouse/web/api/v1/api.py new file mode 100644 index 0000000..3807264 --- /dev/null +++ b/apps/warehouse/web/api/v1/api.py @@ -0,0 +1,17 @@ +from apps.warehouse.web.api.v1 import serializers as warehouse_serializers +from apps.warehouse import models as warehouse_models +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework import viewsets +from django.db import transaction +from rest_framework import status + + +class InventoryEntryViewSet(viewsets.ModelViewSet): + queryset = warehouse_models.InventoryEntry.objects.all() + serializer_class = warehouse_serializers.InventoryEntrySerializer + + +class InventoryQuotaSaleTransactionViewSet(viewsets.ModelViewSet): + queryset = warehouse_models.InventoryQuotaSaleTransaction.objects.all() + serializer_class = warehouse_serializers.InventoryQuotaSaleTransactionSerializer diff --git a/apps/warehouse/web/api/v1/serializers.py b/apps/warehouse/web/api/v1/serializers.py index e69de29..8f919a6 100644 --- a/apps/warehouse/web/api/v1/serializers.py +++ b/apps/warehouse/web/api/v1/serializers.py @@ -0,0 +1,90 @@ +from apps.warehouse.exceptions import ( + InventoryEntryWeightException, + TotalInventorySaleException +) +from apps.product.exceptions import QuotaExpiredTimeException +from apps.warehouse import models as warehouse_models +from apps.authorization.models import UserRelations +from rest_framework import serializers +from django.db import models + + +class InventoryEntrySerializer(serializers.ModelSerializer): + class Meta: + model = warehouse_models.InventoryEntry + fields = '__all__' + + def create(self, validated_data): + """ Custom create & set organization """ + + distribution = validated_data['distribution'] + organization = distribution.assigned_organization + + return warehouse_models.InventoryEntry.objects.create( + organization=organization, + **validated_data + ) + + def validate(self, attrs): + """ + check if inventory entries weight is not more than + distribution weight & check quota expired time + """ + + distribution = attrs['distribution'] + + # check for quota expired time + if not distribution.quota.is_in_valid_time(): + raise QuotaExpiredTimeException() + + # total inventory entries weight + total_entered = distribution.inventory_entry.filter(is_confirmed=True).aggregate( + total=models.Sum('weight') + )['total'] or 0 + + if total_entered + attrs['weight'] > distribution.weight: + raise InventoryEntryWeightException() + + return attrs + + +class InventoryQuotaSaleTransactionSerializer(serializers.ModelSerializer): + class Meta: + model = warehouse_models.InventoryQuotaSaleTransaction + fields = '__all__' + depth = 0 + + def validate(self, attrs): + """ + validate total inventory sale should be fewer than + inventory entry from distribution + """ + inventory_entry = attrs['inventory_entry'] + distribution = attrs['quota_distribution'] + + total_sale_weight = inventory_entry.inventory_sales.aggregate( + total=models.Sum('weight') + )['total'] or 0 + + if total_sale_weight + attrs['weight'] > distribution.warehouse_balance: + raise TotalInventorySaleException() + + return attrs + + def create(self, validated_data): + """ Custom create & set some parameters like seller & buyer """ + + distribution = validated_data['quota_distribution'] + seller_organization = distribution.assigned_organization + + user = self.context['request'].user + buyer_user = user + seller_user = validated_data['inventory_entry'].created_by + + return warehouse_models.InventoryQuotaSaleTransaction.objects.create( + seller_organization=seller_organization, + seller_user=seller_user, + buyer_user=buyer_user, + **validated_data + ) + diff --git a/apps/warehouse/web/api/v1/urls.py b/apps/warehouse/web/api/v1/urls.py index e69de29..9418340 100644 --- a/apps/warehouse/web/api/v1/urls.py +++ b/apps/warehouse/web/api/v1/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from apps.warehouse.web.api.v1 import api + +router = DefaultRouter() +router.register(r'inventory_entry', api.InventoryEntryViewSet, basename='inventory_entry') +router.register( + r'inventory_sale_transaction', + api.InventoryQuotaSaleTransactionViewSet, + basename='inventory_sale_transaction' +) + + +urlpatterns = [ + path('v1/', include(router.urls)), +] diff --git a/common/tools.py b/common/tools.py index da6fb85..d2f61b2 100644 --- a/common/tools.py +++ b/common/tools.py @@ -35,7 +35,6 @@ class CustomOperations: serializer = view.serializer_class(data=data) # noqa serializer.is_valid(raise_exception=True) # noqa view.perform_create(serializer) # noqa - headers = view.get_success_headers(serializer.data) # noqa return serializer.data @@ -50,16 +49,17 @@ class CustomOperations: additional_data: dict = None ) -> typing.Any: view_data = request.data # included needed data for view set # noqa + queryset = view.queryset.get(id=obj_id) if user: view_data[data_key].update({'user': user.id}) # noqa if additional_data: view_data[data_key].update(additional_data) if data_key: - serializer = view.serializer_class(data=view_data[data_key]) # noqa + serializer = view.serializer_class(data=view_data[data_key], instance=queryset, partial=True) # noqa if data: - serializer = view.serializer_class(data=data) # noqa + serializer = view.serializer_class(data=data, instance=queryset, partial=True) # noqa serializer.is_valid(raise_exception=True) # noqa - serializer.update(view.queryset.get(id=obj_id), view_data[data_key]) # noqa - # view.perform_update(serializer) # noqa + object_data = data if data else view_data[data_key] + serializer.save() # noqa headers = view.get_success_headers(serializer.data) # noqa return serializer.data diff --git a/requirements.txt b/requirements.txt index 80f275b..ab456fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -74,4 +74,5 @@ django_elasticsearch_dsl_drf django-crum django-rest-swagger drf-yasg -tinydb \ No newline at end of file +tinydb +django-simple-history \ No newline at end of file