From ce18f602c4364167430a626d7772efb27c609ad9 Mon Sep 17 00:00:00 2001 From: Parsa Nazer Date: Tue, 18 Nov 2025 14:27:22 +0330 Subject: [PATCH] ShopOrderModel and ShopDailyReport system --- backend/core/settings/production.py | 4 + ...rdermodel_shoporderitem_shopdailyreport.py | 71 +++++++++++ backend/order/models.py | 63 ++++++++++ backend/order/signals.py | 111 +++++++++++++++++- backend/order/tasks.py | 61 +++++++++- 5 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 backend/order/migrations/0040_shopordermodel_shoporderitem_shopdailyreport.py diff --git a/backend/core/settings/production.py b/backend/core/settings/production.py index 1dafed5..2e8dee7 100644 --- a/backend/core/settings/production.py +++ b/backend/core/settings/production.py @@ -69,6 +69,10 @@ CELERY_BEAT_SCHEDULE = { 'task': 'order.tasks.udpate_bank_status', 'schedule': crontab(minute='*'), }, + 'generate-daily-shop-reports': { + 'task': 'order.tasks.generate_daily_shop_reports', + 'schedule': crontab(hour=0, minute=5), # Run daily at 00:05 UTC + }, } # ============================================================================== diff --git a/backend/order/migrations/0040_shopordermodel_shoporderitem_shopdailyreport.py b/backend/order/migrations/0040_shopordermodel_shoporderitem_shopdailyreport.py new file mode 100644 index 0000000..0dc1ff9 --- /dev/null +++ b/backend/order/migrations/0040_shopordermodel_shoporderitem_shopdailyreport.py @@ -0,0 +1,71 @@ +# Generated by Django 5.1.2 on 2025-11-18 10:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0033_shopmodel_commission_percent'), + ('order', '0039_remove_cartitem_special_discount_amount'), + ] + + operations = [ + migrations.CreateModel( + name='ShopOrderModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subtotal', models.BigIntegerField(default=0, verbose_name='جمع جزئیات')), + ('items_count', models.PositiveIntegerField(default=0)), + ('discount_amount', models.BigIntegerField(default=0, verbose_name='تخفیف اختصاصی فروشگاه')), + ('special_discount_amount', models.BigIntegerField(default=0, verbose_name='تخفیف ویژه اختصاصی')), + ('commission_percent', models.DecimalField(decimal_places=2, max_digits=5, verbose_name='درصد کمیسیون')), + ('commission_amount', models.BigIntegerField(default=0, verbose_name='مبلغ کمیسیون')), + ('tax_amount', models.BigIntegerField(default=0, verbose_name='مالیات')), + ('payable_amount', models.BigIntegerField(default=0, verbose_name='قابل پرداخت به فروشگاه')), + ('is_settled', models.BooleanField(default=False, verbose_name='تسویه شده')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shop_orders', to='order.ordermodel')), + ('shop', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shop_orders', to='account.shopmodel')), + ], + options={ + 'verbose_name': 'سفارش به ازای فروشگاه', + 'verbose_name_plural': 'سفارشات به ازای فروشگاه', + }, + ), + migrations.CreateModel( + name='ShopOrderItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField()), + ('unit_price', models.BigIntegerField()), + ('total_price', models.BigIntegerField()), + ('discount_amount', models.BigIntegerField(default=0)), + ('special_discount_amount', models.BigIntegerField(default=0)), + ('order_item', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='shop_items', to='order.orderitemmodel')), + ('shop_order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='order.shopordermodel')), + ], + options={ + 'verbose_name': 'ایتم سفارش فروشگاه', + 'verbose_name_plural': 'ایتم های سفارش فروشگاه', + }, + ), + migrations.CreateModel( + name='ShopDailyReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('total_sales', models.BigIntegerField(default=0)), + ('total_commission', models.BigIntegerField(default=0)), + ('total_payable', models.BigIntegerField(default=0)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('shop', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='daily_reports', to='account.shopmodel')), + ], + options={ + 'verbose_name': 'گزارش روزانه فروشگاه', + 'verbose_name_plural': 'گزارش های روزانه فروشگاه', + 'unique_together': {('shop', 'date')}, + }, + ), + ] diff --git a/backend/order/models.py b/backend/order/models.py index 7dd21ba..15cc7fd 100644 --- a/backend/order/models.py +++ b/backend/order/models.py @@ -248,3 +248,66 @@ class OrderItemModel(models.Model): def __str__(self): return f'({self.product}) - ({self.order.user})' + + +class ShopOrderModel(models.Model): + """Represents the portion of a customer Order that belongs to a single Shop. + + This model is created automatically when an `OrderModel` is finalized/paid. + It holds aggregated financial details for that shop (subtotal, discounts, + commission and final payable amount). + """ + order = models.ForeignKey(OrderModel, on_delete=models.CASCADE, related_name='shop_orders') + shop = models.ForeignKey('account.ShopModel', on_delete=models.CASCADE, related_name='shop_orders') + subtotal = models.BigIntegerField(verbose_name='جمع جزئیات', default=0) + items_count = models.PositiveIntegerField(default=0) + discount_amount = models.BigIntegerField(default=0, verbose_name='تخفیف اختصاصی فروشگاه') + special_discount_amount = models.BigIntegerField(default=0, verbose_name='تخفیف ویژه اختصاصی') + commission_percent = models.DecimalField(max_digits=5, decimal_places=2, verbose_name='درصد کمیسیون') + commission_amount = models.BigIntegerField(default=0, verbose_name='مبلغ کمیسیون') + tax_amount = models.BigIntegerField(default=0, verbose_name='مالیات') + payable_amount = models.BigIntegerField(default=0, verbose_name='قابل پرداخت به فروشگاه') + is_settled = models.BooleanField(default=False, verbose_name='تسویه شده') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = 'سفارش به ازای فروشگاه' + verbose_name_plural = 'سفارشات به ازای فروشگاه' + + def __str__(self): + return f'ShopOrder({self.shop.shop_name}) for Order {self.order.pk}' + + +class ShopOrderItem(models.Model): + shop_order = models.ForeignKey(ShopOrderModel, on_delete=models.CASCADE, related_name='items') + order_item = models.ForeignKey(OrderItemModel, on_delete=models.PROTECT, related_name='shop_items') + quantity = models.PositiveIntegerField() + unit_price = models.BigIntegerField() + total_price = models.BigIntegerField() + discount_amount = models.BigIntegerField(default=0) + special_discount_amount = models.BigIntegerField(default=0) + + class Meta: + verbose_name = 'ایتم سفارش فروشگاه' + verbose_name_plural = 'ایتم های سفارش فروشگاه' + + def __str__(self): + return f'{self.order_item} for {self.shop_order}' + + +class ShopDailyReport(models.Model): + """Daily aggregated report per shop. Run once per day to record totals.""" + shop = models.ForeignKey('account.ShopModel', on_delete=models.CASCADE, related_name='daily_reports') + date = models.DateField() + total_sales = models.BigIntegerField(default=0) + total_commission = models.BigIntegerField(default=0) + total_payable = models.BigIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = 'گزارش روزانه فروشگاه' + verbose_name_plural = 'گزارش های روزانه فروشگاه' + unique_together = (('shop', 'date'),) + + def __str__(self): + return f'Report {self.shop.shop_name} - {self.date}' diff --git a/backend/order/signals.py b/backend/order/signals.py index 64555d0..dcb9b41 100644 --- a/backend/order/signals.py +++ b/backend/order/signals.py @@ -1,3 +1,6 @@ +from .models import ShopOrderModel, ShopOrderItem +from django.db import transaction +from django.db.models.signals import post_save from django.db.models.signals import pre_save from django.dispatch import receiver from .models import OrderModel @@ -26,17 +29,121 @@ def order_status_changed(sender, instance, **kwargs): @receiver(pre_save, sender=OrderModel) def set_default_address(sender, instance, **kwargs): if instance.address is None and instance.user: - default_address = UserAddressModel.objects.filter(user=instance.user, is_main=True).first() + default_address = UserAddressModel.objects.filter( + user=instance.user, is_main=True).first() if default_address: instance.address = default_address - # def update_cart_price_fields(order): # pass def update_sell_data(order): pass + def update_quantity(order): pass + + +@receiver(post_save, sender=OrderModel) +def create_shop_orders_on_payment(sender, instance: OrderModel, created, **kwargs): + """When an order becomes paid, split it into per-shop ShopOrderModel records. + + This handler is safe to run multiple times (it checks if shop_orders exist). + It triggers only when `is_paid` is True. + """ + # Only generate when order is paid and we don't already have shop orders + if not instance.is_paid: + return + + if instance.shop_orders.exists(): + return + + # Collect order items grouped by shop + items = instance.items.select_related('product__product__shop') + + shop_groups = {} + for item in items: + # product is ProductVariant -> product.product is ProductModel -> shop on ProductModel + shop = None + try: + shop = item.product.product.shop + except Exception: + shop = None + + if not shop: + # If product has no shop, skip (or you might want a default platform shop) + continue + + shop_groups.setdefault(shop, []).append(item) + + if not shop_groups: + return + + # Totals for proportional distribution + shop_subtotals = {} + for shop, items_list in shop_groups.items(): + subtotal = 0 + for it in items_list: + subtotal += int(it.price) * int(it.quantity) + shop_subtotals[shop] = subtotal + + total_subtotal = sum(shop_subtotals.values()) or 1 + + order_discount = int(instance.discount_amount or 0) + order_special_discount = int(instance.special_discount_total or 0) + order_tax = int(instance.tax or 0) + + with transaction.atomic(): + for shop, items_list in shop_groups.items(): + shop_subtotal = shop_subtotals.get(shop, 0) + # proportionally allocate cart-level discount, special discount and tax + allocated_discount = int( + order_discount * shop_subtotal / total_subtotal) if order_discount else 0 + allocated_special_discount = int( + order_special_discount * shop_subtotal / total_subtotal) if order_special_discount else 0 + allocated_tax = int(order_tax * shop_subtotal / + total_subtotal) if order_tax else 0 + + commission_percent = getattr(shop, 'commission_percent', 0) or 0 + try: + commission_percent_value = float(commission_percent) + except Exception: + commission_percent_value = 0.0 + + # commission is calculated on the shop subtotal after discounts + base_for_commission = max( + 0, shop_subtotal - allocated_discount - allocated_special_discount) + commission_amount = int( + base_for_commission * (commission_percent_value / 100.0)) + + payable = shop_subtotal - allocated_discount - \ + allocated_special_discount - commission_amount + allocated_tax + + shop_order = ShopOrderModel.objects.create( + order=instance, + shop=shop, + subtotal=shop_subtotal, + items_count=sum(int(it.quantity) for it in items_list), + discount_amount=allocated_discount, + special_discount_amount=allocated_special_discount, + commission_percent=commission_percent_value, + commission_amount=commission_amount, + tax_amount=allocated_tax, + payable_amount=payable, + ) + + # Create ShopOrderItem rows linking to original OrderItemModel + for it in items_list: + ShopOrderItem.objects.create( + shop_order=shop_order, + order_item=it, + quantity=int(it.quantity), + unit_price=int(it.price), + total_price=int(it.price) * int(it.quantity), + discount_amount=int( + it.price) * int(it.quantity) * (int(it.discount_percent or 0) / 100.0), + special_discount_amount=int( + it.special_discount_amount or 0), + ) diff --git a/backend/order/tasks.py b/backend/order/tasks.py index aa2d481..6cb1c62 100644 --- a/backend/order/tasks.py +++ b/backend/order/tasks.py @@ -60,4 +60,63 @@ def send_change_status_sms(instance_pk, new_status): if response['statusCode'] == 200: return 'done log later' else: - return f'error: {response}' \ No newline at end of file + return f'error: {response}' + + +@shared_task +def generate_daily_shop_reports(): + """Generate daily shop reports for the previous day. + + This task aggregates ShopOrderModel entries from yesterday + and creates/updates ShopDailyReport records for each shop. + Scheduled to run daily at midnight. + """ + from django.utils import timezone + from datetime import timedelta + from django.db.models import Sum + from .models import ShopOrderModel, ShopDailyReport + + target_date = (timezone.now() - timedelta(days=1)).date() + logging.info(f'Generating shop reports for {target_date}') + + shop_orders = ShopOrderModel.objects.filter(created_at__date=target_date) + if not shop_orders.exists(): + logging.warning(f'No shop orders found for {target_date}') + return f'No shop orders for {target_date}' + + shops = shop_orders.values('shop').distinct() + reports_created = 0 + reports_updated = 0 + + for s in shops: + shop_id = s['shop'] + aggr = shop_orders.filter(shop_id=shop_id).aggregate( + total_sales=Sum('subtotal'), + total_commission=Sum('commission_amount'), + total_payable=Sum('payable_amount') + ) + + total_sales = aggr['total_sales'] or 0 + total_commission = aggr['total_commission'] or 0 + total_payable = aggr['total_payable'] or 0 + + report, created = ShopDailyReport.objects.update_or_create( + shop_id=shop_id, + date=target_date, + defaults={ + 'total_sales': total_sales, + 'total_commission': total_commission, + 'total_payable': total_payable, + } + ) + + if created: + reports_created += 1 + else: + reports_updated += 1 + + logging.info(f"Shop {shop_id}: sales={total_sales}, commission={total_commission}, payable={total_payable}") + + result = f'Generated reports for {target_date}: {reports_created} created, {reports_updated} updated' + logging.info(result) + return result \ No newline at end of file