From d63aa36a195250e17790486b5d414f3419899b75 Mon Sep 17 00:00:00 2001 From: Mojtaba-z Date: Wed, 26 Nov 2025 09:52:14 +0330 Subject: [PATCH] add & fix - transaction dashboard / edit distribution with stat validation bug --- apps/herd/services/services.py | 6 +- .../api/v1/serializers/quota_serializers.py | 84 ++++++++++++++++- apps/product/pos/api/v1/viewsets/quota_api.py | 2 +- apps/product/signals.py | 3 +- .../validators/quota_stats_validator.py | 4 +- .../services/transaction_dashboard_service.py | 92 +++++++++++++++++++ apps/warehouse/web/api/v1/api.py | 26 +++++- 7 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 apps/warehouse/services/transaction_dashboard_service.py diff --git a/apps/herd/services/services.py b/apps/herd/services/services.py index 8d0cf56..b2f31f1 100644 --- a/apps/herd/services/services.py +++ b/apps/herd/services/services.py @@ -81,12 +81,14 @@ def get_rancher_statistic_by_herd(rancher: Rancher = None) -> typing.Any: def rancher_quota_weight( rancher: Rancher, inventory_entry: InventoryEntry = None, - distribution: QuotaDistribution = None + distribution: QuotaDistribution = None, + quota: Quota = None ): """ :param rancher: Rancher instance :param inventory_entry: InventoryEntry instance :param distribution: QuotaDistribution instance + :param quota: Quota instance :return: dict {total, by_type} """ @@ -103,7 +105,7 @@ def rancher_quota_weight( elif distribution: quota: Quota = distribution.quota else: - quota: Quota = Quota() + quota: Quota = quota # list of quota live stock allocations allocations = list(quota.livestock_allocations.all().select_related('livestock_type')) diff --git a/apps/product/pos/api/v1/serializers/quota_serializers.py b/apps/product/pos/api/v1/serializers/quota_serializers.py index 9c17407..eb56bb9 100644 --- a/apps/product/pos/api/v1/serializers/quota_serializers.py +++ b/apps/product/pos/api/v1/serializers/quota_serializers.py @@ -1,7 +1,12 @@ from rest_framework import serializers +from apps.core.models import SystemConfig +from apps.herd.services.services import get_rancher_statistic_by_herd, rancher_quota_weight from apps.livestock.web.api.v1.serializers import LiveStockTypeSerializer +from apps.pos_device.services.services import pos_organizations_sharing_information from apps.product import models as product_models +from apps.product.services.services import quota_live_stock_allocation_info, quota_incentive_plans_info, \ + quota_attribute_value, quota_pricing_items_by_type from apps.product.web.api.v1.serializers import product_serializers @@ -239,4 +244,81 @@ class QuotaLiveStockAgeLimitationSerializer(serializers.ModelSerializer): class OrganizationQuotaStatsSerializer(serializers.ModelSerializer): class Meta: model = product_models.OrganizationQuotaStats - fields = '__all__' + fields = [ + 'id', + 'create_date', + 'modify_date', + 'creator_info', + 'modifier_info', + ] + + def to_representation(self, instance: product_models.OrganizationQuotaStats): + """ Custom output of serializer """ + + representation = super().to_representation(instance) + + organization = self.context['organization'] + rancher = self.context['rancher'] + device = self.context['device'] + + representation['weight'] = instance.total_amount + representation['remaining_weight'] = instance.remaining_amount + representation['distributed'] = instance.total_distributed + representation['warehouse_entry'] = instance.inventory_received + representation['warehouse_balance'] = instance.inventory_entry_balance + representation['been_sold'] = instance.sold_amount + representation['pre_sale_balance'] = instance.quota.pre_sale_balance + representation['free_sale_balance'] = instance.quota.free_sale_balance + + representation['free_sale'] = instance.quota.free_sale + representation['pre_sale'] = instance.quota.pre_sale + + if instance.quota: + representation['quota'] = { + 'quota_identity': instance.quota.quota_id, + 'quota_weight': instance.quota.quota_weight, + 'pos_sale_type': instance.quota.pos_sale_type, + 'quota_livestock_allocations': quota_live_stock_allocation_info( + instance.quota + ), + 'quota_incentive_plans': quota_incentive_plans_info(instance.quota, rancher), + 'quota_sale_license': instance.quota.sale_license, + 'has_sale_license': instance.quota.is_in_valid_time(), + } + + sale_limitation = SystemConfig.get( + "IGNORE_ALL_RANCHER_PURCHASE_LIMITS", + quota_identity=instance.quota.quota_id + ) + representation['product'] = { + 'image': instance.quota.product.img, + 'name': instance.quota.product.name, + 'id': instance.quota.product.id, + 'free_sale_for_all': sale_limitation if sale_limitation else False, + 'free_sale_for_this_rancher': rancher.ignore_purchase_limit + } + + sharing_list = pos_organizations_sharing_information( + device, + instance.quota, + owner_org=organization + ) + + representation['pricing'] = { # noqa + 'main_account_sheba': "IR" + organization.bank_information.first().sheba, + 'pricing_attributes': quota_attribute_value(instance.quota), + 'sharing': sharing_list, + 'base_prices': quota_pricing_items_by_type(instance.quota, sharing=sharing_list) + } + + if 'rancher' in self.context.keys(): + # rancher herd & live stock statistics + livestock_counts_list, livestock_counts_dict = get_rancher_statistic_by_herd(rancher) + representation['rancher_statistics'] = livestock_counts_list + + # rancher live stock statistics by quota distributions + representation['rancher_quota_weight_statistics'] = rancher_quota_weight( + rancher, quota=instance.quota + ) + + return representation diff --git a/apps/product/pos/api/v1/viewsets/quota_api.py b/apps/product/pos/api/v1/viewsets/quota_api.py index 291c462..e310fb1 100644 --- a/apps/product/pos/api/v1/viewsets/quota_api.py +++ b/apps/product/pos/api/v1/viewsets/quota_api.py @@ -230,7 +230,7 @@ class OrganizationQuotaStatsViewSet(viewsets.ModelViewSet, DynamicSearchMixin, P Q(quota__pre_sale=True) | Q(quota__free_sale=True) | Q(inventory_received__gt=0) ) ).order_by('-create_date') - + print(quotas) # check quota distributions for rancher # available_distributions = [ # distribution for distribution in distributions if ( diff --git a/apps/product/signals.py b/apps/product/signals.py index 978dbd3..a3a5d77 100644 --- a/apps/product/signals.py +++ b/apps/product/signals.py @@ -84,7 +84,8 @@ def update_product_stats(instance: Product, distribution: QuotaDistribution = No QuotaStatsValidator.validate_assigner_has_enough( organization, distribution.quota, - distribution.weight + distribution.weight, + allow_zero=True ) if ProductStats.objects.filter( organization=organization, diff --git a/apps/product/validators/quota_stats_validator.py b/apps/product/validators/quota_stats_validator.py index 398f3e9..5554b34 100644 --- a/apps/product/validators/quota_stats_validator.py +++ b/apps/product/validators/quota_stats_validator.py @@ -50,14 +50,14 @@ class QuotaStatsValidator: if amount < 0: raise DistributionException("مقدار وزن نباید منفی باشد", status.HTTP_403_FORBIDDEN) # noqa - if remaining < amount and not allow_zero: + if remaining + amount < amount and not allow_zero: if update_operation: raise DistributionException( "مقدار وزن ویرایش شده از وزن باقیمانده بیشتر است", # noqa status.HTTP_403_FORBIDDEN ) raise DistributionException( - f"تخصیص مقدار {amount} بیشتر از مقدار باقیمانده {remaining} برای سازمان {assigner_org.name} میباشد.", + f"تخصیص مقدار {amount} بیشتر از مقدار باقیمانده {remaining + amount} برای سازمان {assigner_org.name} میباشد.", # noqa status.HTTP_403_FORBIDDEN ) diff --git a/apps/warehouse/services/transaction_dashboard_service.py b/apps/warehouse/services/transaction_dashboard_service.py new file mode 100644 index 0000000..a8c7d53 --- /dev/null +++ b/apps/warehouse/services/transaction_dashboard_service.py @@ -0,0 +1,92 @@ +from collections import defaultdict + +from django.db.models import Sum, Count, Case, When, Q, Value +from django.db.models.functions import Coalesce + +from apps.warehouse.models import InventoryQuotaSaleTransaction, InventoryQuotaSaleItem + + +class TransactionDashboardService: + + @staticmethod + def get_dashboard(org): + transactions = InventoryQuotaSaleTransaction.objects.filter( + seller_organization=org + ) + + transaction_stats = transactions.aggregate( + total_transactions=Count("id"), + success_transactions=Count("id", filter=Q(transaction_status="success")), + failed_transactions=Count("id", filter=Q(transaction_status="failed")), + waiting_transactions=Count("id", filter=Q(transaction_status="waiting")), + total_amount=Coalesce(Sum("price_paid"), 0), + total_weight=Coalesce(Sum("weight"), 0), + unique_ranchers=Count("rancher", distinct=True), + ) + + items = InventoryQuotaSaleItem.objects.filter( + transaction__seller_organization=org + ).select_related("gov_product", "free_product") + + products_stats = items.values( + product_id=Case( + When(gov_product__isnull=False, then="gov_product_id"), + When(free_product__isnull=False, then="free_product_id"), + ), + product_name=Case( + When(gov_product__isnull=False, then="gov_product__name"), + When(free_product__isnull=False, then="free_product__product__name"), + ), + product_type=Case( + When(gov_product__isnull=False, then=Value("gov")), + When(free_product__isnull=False, then=Value("free")), + ) + ).annotate( + total_sales=Count("id"), + total_weight=Coalesce(Sum("weight"), 0), + # total_price=Coalesce(Sum("total_price"), 0), + # avg_unit_price=Coalesce(Sum("total_price") / Sum("weight"), 0), + success_sales=Count("id", filter=Q(transaction__transaction_status="success")), + failed_sales=Count("id", filter=Q(transaction__transaction_status="failed")), + waiting_sales=Count("id", filter=Q(transaction__transaction_status="waiting")), + card_payments=Count("id", filter=Q(transaction__price_type="card")), + cash_payments=Count("id", filter=Q(transaction__price_type="cash")), + check_payments=Count("id", filter=Q(transaction__price_type="check")), + credit_payments=Count("id", filter=Q(transaction__price_type="credit")), + extra_items=Count("id", filter=Q(is_extra=True)), + pre_sale_items=Count("id", filter=Q(is_pre_sale=True)), + ).order_by("-total_sales") + + # calculate sum of item share percentage by product + items_by_product = defaultdict(list) + + for item in items: + pid = item.gov_product_id or item.free_product_id + items_by_product[pid].append(item) + + for product in products_stats: + pid = product["product_id"] + share_totals = defaultdict(lambda: {"total_price": 0, "count": 0}) + + for item in items_by_product.get(pid, []): + if item.item_share: + for share in item.item_share: + # share: {"name": ..., "price": ..., "shaba": ...} + name = share.get("name") + price = share.get("price", 0) + + share_totals[name]["total_price"] += price + share_totals[name]["count"] += 1 + + product["item_share_stats"] = sorted( + [ + {"name": name, **val} + for name, val in share_totals.items() + ], + key=lambda x: -x["total_price"] + ) + + return { + "transaction_summary": transaction_stats, + "product_summary": list(products_stats), + } diff --git a/apps/warehouse/web/api/v1/api.py b/apps/warehouse/web/api/v1/api.py index 9a6be27..fda708b 100644 --- a/apps/warehouse/web/api/v1/api.py +++ b/apps/warehouse/web/api/v1/api.py @@ -10,6 +10,7 @@ from apps.core.api import BaseViewSet from apps.core.mixins.search_mixin import DynamicSearchMixin from apps.core.mixins.soft_delete_mixin import SoftDeleteMixin from apps.warehouse import models as warehouse_models +from apps.warehouse.services.transaction_dashboard_service import TransactionDashboardService from apps.warehouse.web.api.v1 import serializers as warehouse_serializers from common.generics import base64_to_image_file from common.helpers import get_organization_by_user @@ -158,7 +159,13 @@ class InventoryEntryViewSet(BaseViewSet, SoftDeleteMixin, viewsets.ModelViewSet, return self.get_paginated_response(serializer.data) -class InventoryQuotaSaleTransactionViewSet(BaseViewSet, SoftDeleteMixin, DynamicSearchMixin, viewsets.ModelViewSet): +class InventoryQuotaSaleTransactionViewSet( + BaseViewSet, + SoftDeleteMixin, + TransactionDashboardService, + DynamicSearchMixin, + viewsets.ModelViewSet, +): queryset = warehouse_models.InventoryQuotaSaleTransaction.objects.all() serializer_class = warehouse_serializers.InventoryQuotaSaleTransactionSerializer filter_backends = [filters.SearchFilter] @@ -212,6 +219,23 @@ class InventoryQuotaSaleTransactionViewSet(BaseViewSet, SoftDeleteMixin, Dynamic serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) + @action( + methods=['get'], + detail=False, + url_path='transactions_dashboard', + url_name='transactions_dashboard', + name='transactions_dashboard' + ) + def transactions_dashboard(self, request): + """ + dashboard of full detail of all my transactions + """ + + org = get_organization_by_user(request.user) + transaction_dashboard_data = self.get_dashboard(org) + + return Response(transaction_dashboard_data, status=status.HTTP_200_OK) + class InventoryQuotaSaleItemViewSet(SoftDeleteMixin, BaseViewSet, viewsets.ModelViewSet): queryset = warehouse_models.InventoryQuotaSaleItem.objects.all()