200 lines
8.5 KiB
Python
200 lines
8.5 KiB
Python
|
|
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}"
|
|||
|
|
))
|