ShopOrderModel and ShopDailyReport system

This commit is contained in:
Parsa Nazer
2025-11-18 14:27:22 +03:30
parent be23b087f1
commit ce18f602c4
5 changed files with 307 additions and 3 deletions
+4
View File
@@ -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
},
}
# ==============================================================================
@@ -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')},
},
),
]
+63
View File
@@ -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}'
+109 -2
View File
@@ -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),
)
+59
View File
@@ -61,3 +61,62 @@ def send_change_status_sms(instance_pk, new_status):
return 'done log later'
else:
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