from datetime import date, timedelta from decimal import Decimal, ROUND_HALF_UP from django.core.management.base import BaseCommand, CommandError from django.db import transaction from django.utils import timezone from django.contrib.auth import get_user_model from billing.models import Invoice, InvoiceItem from domains.models import Domain, DomainTrafficDaily from core.models import SystemSettings User = get_user_model() def month_range_from_str(month_str: str): try: year, month = map(int, month_str.split('-')) start = date(year, month, 1) except Exception as e: raise CommandError(f"无效的 --month 参数,格式应为 YYYY-MM:{e}") # 下月第一天 if month == 12: next_month_start = date(year + 1, 1, 1) else: next_month_start = date(year, month + 1, 1) end = next_month_start - timedelta(days=1) return start, end def previous_month_range(): today = timezone.now().date() this_month_start = today.replace(day=1) last_day_prev = this_month_start - timedelta(days=1) start_prev = last_day_prev.replace(day=1) return start_prev, last_day_prev class Command(BaseCommand): help = "按自然月生成用户账单(套餐费 + 超量流量)。默认生成上一自然月,可用 --month 指定。" def add_arguments(self, parser): parser.add_argument('--month', type=str, help='计费月份,格式 YYYY-MM,如 2025-10') parser.add_argument('--user-id', type=int, help='仅为指定用户生成账单(可选)') parser.add_argument('--overwrite', action='store_true', help='若已存在账单则覆盖重建(删除后重建)') parser.add_argument('--dry-run', action='store_true', help='试运行,仅输出计算结果,不写入数据库') @transaction.atomic def handle(self, *args, **options): # 计费周期 if options.get('month'): period_start, period_end = month_range_from_str(options['month']) else: period_start, period_end = previous_month_range() user_id = options.get('user_id') overwrite = options.get('overwrite', False) dry_run = options.get('dry_run', False) self.stdout.write(self.style.NOTICE( f"生成账单周期:{period_start} → {period_end}" )) users_qs = User.objects.all() if user_id: users_qs = users_qs.filter(id=user_id) if not users_qs.exists(): raise CommandError(f"用户 {user_id} 不存在") sys = SystemSettings.objects.order_by('id').first() total_invoices = 0 total_amount = Decimal('0.00') for user in users_qs: # 处理已存在账单 existing = Invoice.objects.filter(user=user, period_start=period_start, period_end=period_end).first() if existing and not overwrite: self.stdout.write(self.style.WARNING( f"跳过用户 {user.id}({user.username}),该周期账单已存在:Invoice#{existing.id}" )) continue if existing and overwrite and not dry_run: existing.delete() self.stdout.write(self.style.WARNING( f"已删除旧账单:用户 {user.id}({user.username}) Invoice#{existing.id}" )) # 汇总用户域名的费用 domains = Domain.objects.filter(user=user, status=Domain.STATUS_ACTIVE) amount_plan_total = Decimal('0.00') amount_overage_total = Decimal('0.00') items = [] for d in domains: plan = d.current_plan base_price = Decimal(str(plan.base_price_per_domain)) if plan and plan.base_price_per_domain is not None else Decimal('0.00') included_gb = Decimal(str(plan.included_traffic_gb_per_domain)) if plan and plan.included_traffic_gb_per_domain is not None else None overage_price = Decimal(str(plan.overage_price_per_gb)) if plan and plan.overage_price_per_gb is not None else Decimal('0.00') # 若域名自定义超量单价优先 if d.custom_overage_price_per_gb is not None: overage_price = Decimal(str(d.custom_overage_price_per_gb)) # 用户级默认免费额度覆盖 user_default_override = None if hasattr(user, 'profile') and user.profile.default_free_traffic_gb_per_domain_override is not None: user_default_override = Decimal(str(user.profile.default_free_traffic_gb_per_domain_override)) # 系统全局默认 sys_default_gb = Decimal(str(sys.default_free_traffic_gb_per_domain)) if sys and sys.default_free_traffic_gb_per_domain is not None else Decimal('0') # 最终基础配额:优先 Plan,其次用户覆盖,再次系统默认 base_quota_gb = Decimal('0') if included_gb is not None: base_quota_gb = Decimal(str(included_gb)) elif user_default_override is not None: base_quota_gb = user_default_override else: base_quota_gb = sys_default_gb # 本周期域名额外赠送 extra_gb = Decimal(str(d.extra_free_traffic_gb_current_cycle or 0)) # 统计周期用量 traffic_qs = DomainTrafficDaily.objects.filter(domain=d, day__gte=period_start, day__lte=period_end) total_bytes = sum(t.bytes for t in traffic_qs) used_gb = Decimal(str(total_bytes)) / Decimal('1073741824') # 1024^3 used_gb = used_gb.quantize(Decimal('0.001'), rounding=ROUND_HALF_UP) # 超量(不为负) over_gb = used_gb - (base_quota_gb + extra_gb) if over_gb < Decimal('0'): over_gb = Decimal('0') over_gb = over_gb.quantize(Decimal('0.001'), rounding=ROUND_HALF_UP) # 费用累计 if base_price > 0: amount_plan_total += base_price items.append({ 'domain': d, 'description': '基础套餐费用', 'quantity': Decimal('1'), 'unit_price': base_price, 'amount': base_price, }) if over_gb > 0 and overage_price > 0: over_amount = (over_gb * overage_price).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) amount_overage_total += over_amount items.append({ 'domain': d, 'description': '超量流量费用', 'quantity': over_gb, 'unit_price': overage_price, 'amount': over_amount, }) amount_adjustment = Decimal('0.00') amount_total = (amount_plan_total + amount_overage_total + amount_adjustment).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP) if dry_run: self.stdout.write(self.style.SUCCESS( f"[DRY] 用户 {user.id}({user.username}) 套餐费:¥{amount_plan_total} 超量费:¥{amount_overage_total} 总计:¥{amount_total}" )) continue # 创建账单与明细 invoice = Invoice.objects.create( user=user, period_start=period_start, period_end=period_end, amount_plan_total=amount_plan_total, amount_overage_total=amount_overage_total, amount_adjustment=amount_adjustment, amount_total=amount_total, status='unpaid', ) for it in items: InvoiceItem.objects.create( invoice=invoice, domain=it['domain'], description=it['description'], quantity=it['quantity'], unit_price=it['unit_price'], amount=it['amount'], ) total_invoices += 1 total_amount += amount_total self.stdout.write(self.style.SUCCESS( f"已生成账单:Invoice#{invoice.id} 用户 {user.id}({user.username}) 总额:¥{amount_total}" )) self.stdout.write(self.style.SUCCESS( f"生成完成:共 {total_invoices} 个账单,合计金额 ¥{total_amount}" ))