ShopOrderModel and ShopDailyReport system
This commit is contained in:
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user