quota, distribution, inventory entry, quota sale transaction, product informations, signals ,....

This commit is contained in:
2025-07-02 15:42:51 +03:30
parent 2f23c5104d
commit 279afba977
45 changed files with 1408 additions and 88 deletions

View File

@@ -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 <token>"',
},
'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'

View File

@@ -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'),
]

View File

@@ -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

View File

@@ -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

View File

@@ -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),
),
]

View File

@@ -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,

View File

@@ -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)))

View File

@@ -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)

View File

@@ -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]
)

View File

@@ -8,3 +8,19 @@ class QuotaWeightException(APIException):
status_code = status.HTTP_400_BAD_REQUEST
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'

View File

@@ -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',
),
]

View File

@@ -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',
),
]

View File

@@ -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'),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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(),
),
]

View File

@@ -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',
),
]

View File

@@ -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),
),
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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():
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',
additional_data={'quota': quota.id}
data=plan
)
else:
incentive_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
)

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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

View File

@@ -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'

View File

@@ -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,
},
),
]

View File

@@ -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',
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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',
),
]

View File

@@ -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,
},
),
]

View File

@@ -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',
),
]

View File

@@ -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),
),
]

View File

@@ -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'),
),
]

View File

@@ -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',
),
]

View File

@@ -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,
},
),
]

View File

@@ -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)

36
apps/warehouse/signals.py Normal file
View File

@@ -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)

View File

@@ -1 +1,7 @@
# Your urls go here
from django.urls import path, include
urlpatterns = [
path('web/api/', include('apps.warehouse.web.api.v1.urls'))
]

View File

@@ -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

View File

@@ -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
)

View File

@@ -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)),
]

View File

@@ -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

View File

@@ -75,3 +75,4 @@ django-crum
django-rest-swagger
drf-yasg
tinydb
django-simple-history