diff --git a/apps/herd/management/commands/fix_herd_status_of_rancher.py b/apps/herd/management/commands/fix_herd_status_of_rancher.py new file mode 100644 index 0000000..4db66f0 --- /dev/null +++ b/apps/herd/management/commands/fix_herd_status_of_rancher.py @@ -0,0 +1,23 @@ +from django.core.management.base import BaseCommand +from django.db.models import Count + +from apps.herd.models import Rancher + + +class Command(BaseCommand): + help = "Fast update ranchers without herds using bulk updates" + + def handle(self, *args, **kwargs): + self.stdout.write("🔄 Bulk updating ranchers...") + + no_herd_count = Rancher.objects.annotate( + herd_count=Count("herd") + ).filter(herd_count=0).update(without_herd=True) + + has_herd_count = Rancher.objects.annotate( + herd_count=Count("herd") + ).filter(herd_count__gt=0).update(without_herd=False) + + self.stdout.write(self.style.SUCCESS( + f"✅ Done! {no_herd_count} set to True, {has_herd_count} set to False" + )) diff --git a/apps/herd/services/services.py b/apps/herd/services/services.py index b2f31f1..4a25653 100644 --- a/apps/herd/services/services.py +++ b/apps/herd/services/services.py @@ -167,6 +167,7 @@ def rancher_quota_weight( "total_weight": total_weight, "remaining_weight": remaining_weight, 'free_sale': free_sale, + 'total_purchase': 0, "rancher_temporary_livestock": rancher.without_herd, "by_type": [{ "name": key, diff --git a/apps/product/migrations/0098_quotausage_quota_stat.py b/apps/product/migrations/0098_quotausage_quota_stat.py new file mode 100644 index 0000000..569d8db --- /dev/null +++ b/apps/product/migrations/0098_quotausage_quota_stat.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0 on 2025-11-26 11:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('product', '0097_organizationquotastats_inventory_entry_balance'), + ] + + operations = [ + migrations.AddField( + model_name='quotausage', + name='quota_stat', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='usages', to='product.organizationquotastats'), + ), + ] diff --git a/apps/product/models.py b/apps/product/models.py index 9963528..2eeff3d 100644 --- a/apps/product/models.py +++ b/apps/product/models.py @@ -580,6 +580,12 @@ class QuotaStats(BaseModel): class QuotaUsage(BaseModel): + quota_stat = models.ForeignKey( + 'OrganizationQuotaStats', + on_delete=models.CASCADE, + related_name='usages', + null=True + ) distribution = models.ForeignKey( "QuotaDistribution", on_delete=models.CASCADE, diff --git a/apps/product/pos/api/v1/serializers/quota_serializers.py b/apps/product/pos/api/v1/serializers/quota_serializers.py index 89d79eb..dad6918 100644 --- a/apps/product/pos/api/v1/serializers/quota_serializers.py +++ b/apps/product/pos/api/v1/serializers/quota_serializers.py @@ -274,7 +274,7 @@ class OrganizationQuotaStatsSerializer(serializers.ModelSerializer): representation['pre_sale'] = instance.quota.pre_sale if instance.distributions: - representation['distributions'] = [dist.id for dist in instance.distributions.all()] + representation['distribution'] = instance.distributions.all().order_by('-create_date').first().id if instance.quota: representation['quota'] = { diff --git a/apps/product/pos/api/v1/viewsets/quota_api.py b/apps/product/pos/api/v1/viewsets/quota_api.py index e310fb1..a03fa2c 100644 --- a/apps/product/pos/api/v1/viewsets/quota_api.py +++ b/apps/product/pos/api/v1/viewsets/quota_api.py @@ -4,6 +4,7 @@ from rest_framework import status from rest_framework import viewsets, filters from rest_framework.decorators import action from rest_framework.exceptions import APIException +from rest_framework.permissions import AllowAny from rest_framework.response import Response from apps.core.mixins.search_mixin import DynamicSearchMixin @@ -198,6 +199,7 @@ class QuotaLiveStockAgeLimitation(viewsets.ModelViewSet): class OrganizationQuotaStatsViewSet(viewsets.ModelViewSet, DynamicSearchMixin, POSDeviceMixin): queryset = OrganizationQuotaStats.objects.all() serializer_class = quota_serializers.OrganizationQuotaStatsSerializer + permission_classes = [AllowAny] filter_backends = [filters.SearchFilter] search_fields = [ "quota__registerer_organization__name", diff --git a/apps/warehouse/pos/api/v2/__init__.py b/apps/warehouse/pos/api/v2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/warehouse/pos/api/v2/api.py b/apps/warehouse/pos/api/v2/api.py new file mode 100644 index 0000000..ce3b0e3 --- /dev/null +++ b/apps/warehouse/pos/api/v2/api.py @@ -0,0 +1,148 @@ +from django.db import transaction +from rest_framework import status +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import AllowAny +from rest_framework.response import Response + +from apps.core.mixins.search_mixin import DynamicSearchMixin +from apps.herd.models import Rancher +from apps.pos_device.mixins.pos_device_mixin import POSDeviceMixin +from apps.warehouse import models as warehouse_models +from apps.warehouse.pos.api.v2 import serializers as warehouse_serializers +from apps.warehouse.services.services import ( + can_buy_from_inventory +) + + +class InventoryEntryViewSet(viewsets.ModelViewSet, DynamicSearchMixin, POSDeviceMixin): + queryset = warehouse_models.InventoryEntry.objects.all() + serializer_class = warehouse_serializers.InventoryEntrySerializer + permission_classes = [AllowAny] + search_fields = [ + "distribution__distribution_id", + "organization__name", + "weight", + "balance", + "lading_number", + "is_confirmed", + ] + date_field = "create_date" + + @action( + methods=['get'], + detail=False, + url_path='my_entries', + url_name='my_entries', + name='my_entries' + ) + def inventory_entries(self, request): + """ list of pos inventory entries """ + + organization = self.get_device_organization() + device = self.get_pos_device() + + entries = self.queryset.filter(organization=organization) + queryset = self.filter_query(entries) # return by search param or all objects + + # paginate & response + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True, context={'device': device}) + return self.get_paginated_response(serializer.data) + + @action( + methods=['get'], + detail=False, + url_path='rancher_inventory_entries', + url_name='rancher_inventory_entries', + name='rancher_inventory_entries' + ) + @transaction.atomic + def rancher_inventory_entries(self, request): + """ list of inventory entries (quotas) for rancher """ + + organization = self.get_device_organization() + device = self.get_pos_device() + rancher = Rancher.objects.filter(national_code=request.GET['national_code']) + entries = self.queryset.filter(organization=organization) + + # check quota inventory entries for rancher + available_entries = [ + entry for entry in entries if (can_buy_from_inventory(rancher.first(), entry) & rancher.exists()) + ] + + # paginate & response + page = self.paginate_queryset(available_entries) # noqa + if page is not None: # noqa + serializer = self.get_serializer(page, many=True, context={'rancher': rancher.first(), 'device': device}) + # set custom message for paginator + if not rancher: + self.paginator.set_message("دامدار با کد ملی مد نظر یافت نشد") # noqa + elif not available_entries: + self.paginator.set_message("دامدار با کد ملی مد نظر سهمیه ایی ندارد") # noqa + return self.get_paginated_response(serializer.data) + + +class InventoryQuotaSaleTransactionViewSet(viewsets.ModelViewSet, DynamicSearchMixin, POSDeviceMixin): + queryset = warehouse_models.InventoryQuotaSaleTransaction.objects.all().prefetch_related('items') + serializer_class = warehouse_serializers.InventoryQuotaSaleTransactionSerializer + permission_classes = [AllowAny] + search_fields = [ + "rancher__union_name", + "rancher__union_code", + "rancher__first_name", + "rancher__last_name", + "rancher__national_code", + "pos_device__device_identity", + "pos_device__serial", + "transaction_id", + "seller_organization__name", + "quota_distribution__distribution_id", + "inventory_entry__distribution__distribution_id", + "transaction_status", + "delivery_address", + "product_type", + ] + date_field = "create_date" + + def list(self, request, *args, **kwargs): + """ pos transactions list """ + + # get device object + device = self.get_pos_device() + + queryset = self.queryset.filter(pos_device=device).order_by('-modify_date') + queryset = self.filter_query(queryset) + + # paginate & response + page = self.paginate_queryset(queryset) + if page is not None: # noqa + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + @transaction.atomic + def create(self, request, *args, **kwargs): + """ create transaction with product items """ + + organization = self.get_device_organization() + device = self.get_pos_device() + + serializer = self.serializer_class(data=request.data, context={ + 'organization': organization, + 'pos_device': device, + 'request': self.request + + }) + if serializer.is_valid(raise_exception=True): + serializer.save() + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_403_FORBIDDEN) + + +class QuotaPreSaleItemViewSet(viewsets.ModelViewSet, DynamicSearchMixin, POSDeviceMixin): + queryset = warehouse_models.QuotaPreSaleItem.objects.all().select_related( + 'organization', 'transaction', 'sale_item' + ) + serializer_class = warehouse_serializers.QuotaPreSaleItemSerializer diff --git a/apps/warehouse/pos/api/v2/serializers.py b/apps/warehouse/pos/api/v2/serializers.py new file mode 100644 index 0000000..892aa39 --- /dev/null +++ b/apps/warehouse/pos/api/v2/serializers.py @@ -0,0 +1,322 @@ +from django.db import models +from django.db.models import Q +from django.db.transaction import atomic +from rest_framework import serializers, status + +from apps.herd.models import Rancher +from apps.herd.pos.api.v1.serializers import RancherSerializer +from apps.herd.services.services import get_rancher_statistics, rancher_quota_weight +from apps.pos_device.models import POSFreeProducts +from apps.pos_device.pos.api.v1.serializers.device import DeviceSerializer +from apps.pos_device.services.services import pos_organizations_sharing_information +from apps.product.exceptions import QuotaSaleTimeException +from apps.product.models import ( + QuotaDistribution, + Product, OrganizationQuotaStats +) +from apps.product.services.services import ( + quota_live_stock_allocation_info, + quota_incentive_plans_info, + quota_attribute_value +) +from apps.warehouse import models as warehouse_models +from apps.warehouse.exceptions import WareHouseException +from apps.warehouse.services.quota_usage_services import QuotaUsageService + + +class InventoryEntrySerializer(serializers.ModelSerializer): + class Meta: + model = warehouse_models.InventoryEntry + fields = [ + "id", + "entry_identity", + "create_date", + "modify_date", + "organization", + "distribution", + "weight", + "balance", + "lading_number", + "delivery_address", + "is_confirmed", + "notes", + ] + + def to_representation(self, instance): + """ custom output of inventory entry serializer """ + + representation = super().to_representation(instance) + if instance.document: + representation['document'] = instance.document + if instance.distribution: + # distribution data + representation['distribution'] = { + 'distribution_identity': instance.distribution.distribution_id, + 'sale_unit': instance.distribution.quota.sale_unit.unit, + 'id': instance.distribution.id + } + + representation['quota'] = { + 'quota_identity': instance.distribution.quota.quota_id, + 'quota_weight': instance.distribution.quota.quota_weight, + 'quota_livestock_allocations': quota_live_stock_allocation_info( + instance.distribution.quota + ), + 'quota_incentive_plans': quota_incentive_plans_info(instance.distribution.quota) + } + + representation['product'] = { + 'image': instance.distribution.quota.product.img, + 'name': instance.distribution.quota.product.name, + 'id': instance.distribution.quota.product.id, + } + + representation['pricing'] = { + 'pricing_attributes': quota_attribute_value(instance.distribution.quota), + 'sharing': pos_organizations_sharing_information( + self.context['device'], + instance.distribution.quota, + instance.distribution + ), + 'base_prices': [ + { + "text": "درب کارخانه", # noqa + "name": "base_price_factory", + "value": instance.distribution.quota.base_price_factory + }, + { + "text": "درب اتحادیه", # noqa + "name": "base_price_cooperative", + "value": instance.distribution.quota.base_price_cooperative + } + ] + } + + if 'rancher' in self.context.keys(): + # rancher herd & live stock statistics + representation['rancher_statistics'] = get_rancher_statistics(self.context['rancher']) + + # rancher live stock statistics by inventory entry + representation['rancher_quota_weight_statistics'] = rancher_quota_weight( + self.context['rancher'], instance + ) + + return representation + + +class InventoryQuotaSaleTransactionSerializer(serializers.ModelSerializer): + rancher_national_code = serializers.CharField(max_length=50, required=False) + + class Meta: # noqa + model = warehouse_models.InventoryQuotaSaleTransaction + fields = '__all__' + depth = 0 + + def create(self, validated_data): + items_data = self.context['request'].data['items'] + rancher_code = validated_data.pop( + 'rancher_national_code' + ) if 'rancher_national_code' in self.context['request'].data.keys() else None + + with atomic(): + # get rancher with national code + rancher = None # noqa + if rancher_code: + rancher = Rancher.objects.get(national_code=rancher_code) + validated_data['rancher'] = rancher + + # if transaction exists, update transaction status + transaction = self.Meta.model.objects.filter( + transaction_id=validated_data.get('transaction_id') + ) + if transaction.exists(): + transaction = transaction.first() + + # --- Case 1: success => only update items + if transaction.transaction_status == 'success': + for item_data in items_data: + qs = warehouse_models.InventoryQuotaSaleItem.objects.filter( + Q(transaction=transaction) & ( + Q(free_product_id=item_data.get('free_product', None)) | + Q(gov_product_id=item_data.get('gov_product', None)) + ) + ).update(**item_data) + return transaction + + # --- Case 2: not success => update transaction fields + items + for field in [ + 'transaction_status', + 'transaction_status_code', + 'result_text', + 'ref_num', + 'terminal', + 'payer_cart', + 'pos_date', + 'transaction_date', + ]: + if field in validated_data: + setattr(transaction, field, validated_data[field]) + + transaction.save(update_fields=[ + 'transaction_status', + 'transaction_status_code', + 'result_text', + 'ref_num', + 'terminal', + 'payer_cart', + 'pos_date', + 'transaction_date', + ]) + + # items can change + for item_data in items_data: + items = warehouse_models.InventoryQuotaSaleItem.objects.filter( + Q(transaction=transaction) & ( + Q(free_product_id=item_data.get('free_product', None)) | + Q(gov_product_id=item_data.get('gov_product', None)) + ) + ) + items.update(**item_data) + + # if transaction status updated as success, call signal for inventory management + if validated_data['transaction_status'] == 'success': + for sale_item in items: + sale_item.inventory_calculation = True + sale_item.save() + + return transaction + + # --- Case 3: create new transaction + transaction = warehouse_models.InventoryQuotaSaleTransaction.objects.create( + seller_organization=self.context['organization'], + pos_device=self.context['pos_device'], + **validated_data + ) + + # calculate total price of product items in shopping cart + total_price = 0 + for item_data in items_data: + # get product by type + gov_product = item_data.pop('gov_product', None) + free_product = item_data.pop('free_product', None) + + distribution = QuotaDistribution.objects.filter( + id=item_data.pop('quota_distribution') + ).first() if 'quota_distribution' in item_data.keys() else None + + quota_stat = OrganizationQuotaStats.objects.get(id=item_data.pop('quota_stat')) + + # create item for transaction + item = warehouse_models.InventoryQuotaSaleItem.objects.create( + transaction=transaction, + quota_distribution=distribution, + quota_stat=quota_stat, + gov_product=Product.objects.get( + id=gov_product + ) if Product.objects.filter(id=gov_product).exists() else None, + free_product=POSFreeProducts.objects.get( + id=free_product + ) if POSFreeProducts.objects.filter(id=free_product).exists() else None, + **item_data + ) + total_price += item.total_price + + # IF WE DO NOT HAVE DISTRIBUTION, THEN IT IS A FREE PRODUCT TRANSACTION + if 'quota_distribution' and 'quota_stat' in item_data.keys(): + # # create extra sale for distribution + # create_extra_sale(transaction=transaction, sale_item=item) + # + # # create pre sale for distribution + # create_pre_sale(transaction=transaction, sale_item=item) + + # calculate quota usage of rancher + QuotaUsageService.allocate_usage( + rancher=rancher, + distribution=distribution, + quota_stat=quota_stat, + item_data=item_data + ) + + transaction.transaction_price = total_price + transaction.save() + + return transaction + + def validate(self, attrs): + """ + validate total inventory sale should be fewer than + distribution weight + """ + + items = self.context['request'].data['items'] + for item in items: + if 'quota_stat' in item.keys(): + + # get quota stat object + quota_stat = OrganizationQuotaStats.objects.get( + id=item.get('quota_stat') + ) + if not quota_stat.quota.pre_sale and not quota_stat.quota.free_sale: + # if quota has not been in sale time + if not quota_stat.quota.is_in_sale_licence_time(): + raise QuotaSaleTimeException() + total_sale_weight = quota_stat.sale_items.aggregate( + total=models.Sum('weight') + )['total'] or 0 + + if total_sale_weight + item.get('weight') > quota_stat.remaining_amount: + raise WareHouseException( + "وزن وارد شده بیشتر از باقیمانده سهمیه میباشد", # noqa + status_code=status.HTTP_403_FORBIDDEN + ) + + return attrs + + def to_representation(self, instance): + """ customize output of transactions serializer """ + + representation = super().to_representation(instance) + + if instance.rancher: + representation['rancher'] = RancherSerializer(instance.rancher).data + representation['pos_device'] = DeviceSerializer(instance.pos_device).data + if instance.seller_organization: + representation['seller_organization'] = instance.seller_organization.name + + # get product items of transaction + representation['items'] = InventoryQuotaSaleItemSerializer(instance.items.all(), many=True).data + + return representation + + +class InventoryQuotaSaleItemSerializer(serializers.ModelSerializer): + product_name = serializers.CharField(source='product.name', read_only=True) + + class Meta: + model = warehouse_models.InventoryQuotaSaleItem + fields = [ + 'id', + "transaction", + "quota_stat", + "gov_product", + "free_product", + "product_name", + "image", + "name", + "price_type", + "delivery_type", + "item_type", + "paid_type", + "weight", + "unit_price", + "total_price", + "paid_price", + "livestock_statistic", + "item_share", + ] + + +class QuotaPreSaleItemSerializer(serializers.ModelSerializer): + class Meta: + model = warehouse_models.QuotaPreSaleItem + fields = '__all__' diff --git a/apps/warehouse/pos/api/v2/urls.py b/apps/warehouse/pos/api/v2/urls.py new file mode 100644 index 0000000..5a1fcec --- /dev/null +++ b/apps/warehouse/pos/api/v2/urls.py @@ -0,0 +1,14 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from . import api + +router = DefaultRouter() + +router.register(r'inventory_entry', api.InventoryEntryViewSet, basename='inventory_entry') +router.register(r'transaction', api.InventoryQuotaSaleTransactionViewSet, basename='transaction') +router.register(r'pre_sale', api.QuotaPreSaleItemViewSet, basename='pre_sale') + +urlpatterns = [ + path('v2/', include(router.urls)) +] diff --git a/apps/warehouse/services/quota_usage_services.py b/apps/warehouse/services/quota_usage_services.py index 5fd9236..1222b9f 100644 --- a/apps/warehouse/services/quota_usage_services.py +++ b/apps/warehouse/services/quota_usage_services.py @@ -1,15 +1,15 @@ -from apps.product.models import QuotaUsage, IncentivePlan from apps.livestock.models import LiveStockType +from apps.product.models import QuotaUsage, IncentivePlan class QuotaUsageService: @staticmethod - def allocate_usage(rancher, distribution, item_data): + def allocate_usage(rancher, distribution, item_data, quota_stat=None): """ save & calculate quota usage of rancher """ # purchase quota usage of rancher - if 'livestock_statistic' in item_data.keys(): + if 'livestock_statistic' in item_data.keys(): # noqa # get list of livestock types object for transaction item livestock_types = { @@ -40,6 +40,7 @@ class QuotaUsageService: rancher=rancher, livestock_type=livestock_type, distribution=distribution, + quota_stat=quota_stat, incentive_plan=incentive_plans.get(item['id']) if is_incentive else None, defaults={ "count": item['count'], diff --git a/apps/warehouse/signals/signals_v1.py b/apps/warehouse/signals/signals_v1.py index 0c3d5f2..2c42a32 100644 --- a/apps/warehouse/signals/signals_v1.py +++ b/apps/warehouse/signals/signals_v1.py @@ -24,32 +24,34 @@ def warehouse_sold_and_balance(quota_distribution: QuotaDistribution): quota_distribution.been_sold = total_sold quota_distribution.warehouse_balance = quota_distribution.warehouse_entry - total_sold - if quota_distribution.warehouse_balance >= 0: + # if quota_distribution.warehouse_balance >= 0: + # + # # calculate extra sales & mines total extra sales weight from new inventory entry + # # and set the warehouse balance + # extra_sales = quota_distribution.extra_sales.all() + # total_extra_sales_weight = extra_sales.aggregate(total=Sum('weight'))['total'] or 0 + # if quota_distribution.free_sale_balance != 0: + # if quota_distribution.warehouse_balance >= quota_distribution.free_sale_balance: + # quota_distribution.warehouse_balance -= total_extra_sales_weight + # quota_distribution.free_sale_balance = 0 + # else: + # quota_distribution.free_sale_balance -= quota_distribution.warehouse_balance + # quota_distribution.warehouse_balance = 0 + # + # # calculate pre_sales & mines total pre_sales weight from new inventory entry + # # and set the warehouse balance + # pre_sales = quota_distribution.pre_sales.all() + # total_pre_sales_weight = pre_sales.aggregate(total=Sum('weight'))['total'] or 0 + # if total_pre_sales_weight != 0: + # if quota_distribution.warehouse_balance >= quota_distribution.pre_sale_balance: + # quota_distribution.warehouse_balance -= total_pre_sales_weight + # quota_distribution.pre_sale_balance = 0 + # else: + # quota_distribution.pre_sale_balance -= quota_distribution.warehouse_balance + # quota_distribution.warehouse_balance = 0 - # calculate extra sales & mines total extra sales weight from new inventory entry - # and set the warehouse balance - extra_sales = quota_distribution.extra_sales.all() - total_extra_sales_weight = extra_sales.aggregate(total=Sum('weight'))['total'] or 0 - if quota_distribution.free_sale_balance != 0: - if quota_distribution.warehouse_balance >= quota_distribution.free_sale_balance: - quota_distribution.warehouse_balance -= total_extra_sales_weight - quota_distribution.free_sale_balance = 0 - else: - quota_distribution.free_sale_balance -= quota_distribution.warehouse_balance - quota_distribution.warehouse_balance = 0 - - # calculate pre_sales & mines total pre_sales weight from new inventory entry - # and set the warehouse balance - pre_sales = quota_distribution.pre_sales.all() - total_pre_sales_weight = pre_sales.aggregate(total=Sum('weight'))['total'] or 0 - if total_pre_sales_weight != 0: - if quota_distribution.warehouse_balance >= quota_distribution.pre_sale_balance: - quota_distribution.warehouse_balance -= total_pre_sales_weight - quota_distribution.pre_sale_balance = 0 - else: - quota_distribution.pre_sale_balance -= quota_distribution.warehouse_balance - quota_distribution.warehouse_balance = 0 - quota_distribution.save(update_fields=['been_sold', 'warehouse_balance', 'free_sale_balance', 'pre_sale_balance']) + # 'free_sale_balance', 'pre_sale_balance' + quota_distribution.save(update_fields=['been_sold', 'warehouse_balance']) @receiver(post_init, sender=InventoryEntry) diff --git a/apps/warehouse/urls.py b/apps/warehouse/urls.py index 455a79a..941cd3f 100644 --- a/apps/warehouse/urls.py +++ b/apps/warehouse/urls.py @@ -1,10 +1,10 @@ # Your urls go here from django.urls import path, include - urlpatterns = [ path('web/api/', include('apps.warehouse.web.api.v1.urls')), path('pos/api/', include('apps.warehouse.pos.api.v1.urls')), + path('pos/api/', include('apps.warehouse.pos.api.v2.urls')), path('excel/', include('apps.warehouse.services.excel.urls')), ]