diff --git a/config/management/commands/rebuild_income_stats.py b/config/management/commands/rebuild_income_stats.py new file mode 100644 index 0000000..9dc01da --- /dev/null +++ b/config/management/commands/rebuild_income_stats.py @@ -0,0 +1,70 @@ +""" +【终极恢复】清空全部收支脏数据,按真实微信支付逐笔重建。 + +只执行这一条,不要再用 repair / undo / fix: + + 预览:python manage.py rebuild_income_stats + 执行:python manage.py rebuild_income_stats --yes +""" +from datetime import date + +from django.core.management.base import BaseCommand +from django.db.models import Sum + +from config.models import DailyIncomeStat +from jituan.services.szjilu_accounting import rebuild_income_stats_from_scratch + + +class Command(BaseCommand): + help = '清空收支脏数据,按真实微信支付逐笔重建(终极恢复)' + + def add_arguments(self, parser): + parser.add_argument('--since-year', type=int, default=0, help='从哪一年扫,默认去年') + parser.add_argument('--until-year', type=int, default=0, help='到哪一年,默认今年') + parser.add_argument('--yes', action='store_true', help='确认执行') + + def handle(self, *args, **options): + until_year = options['until_year'] or date.today().year + since_year = options['since_year'] or (until_year - 1) + dry_run = not options['yes'] + + if dry_run: + self.stdout.write(self.style.WARNING('【预览】未改数据库\n')) + else: + self.stdout.write(self.style.ERROR( + '【执行】删除全部收入日志和日统计,按真实支付重建...\n' + )) + + r = rebuild_income_stats_from_scratch( + since_year=since_year, until_year=until_year, dry_run=dry_run, + ) + + self.stdout.write(f'=== {since_year} ~ {until_year} ===') + self.stdout.write(f' 错乱前 daily 合计: {r["before_daily_total"]:.2f} 元') + self.stdout.write(f' 错乱前 log: {r["before_log_total"]:.2f} 元 / {r["before_log_count"]} 条') + self.stdout.write(self.style.SUCCESS( + f' 重建后合计: {r["after_total"]:.2f} 元 / {r["after_count"]} 笔' + )) + self.stdout.write(self.style.SUCCESS( + f' 重建后本月: {r["month_amount"]:.2f} 元' + )) + self.stdout.write(self.style.SUCCESS( + f' 重建后今日: {r["today_amount"]:.2f} 元 / {r["today_count"]} 笔 ← 看这条' + )) + + if not dry_run: + self.stdout.write(self.style.SUCCESS( + f'\ndaily 表合计: {r.get("after_daily_total", 0):.2f} 元' + )) + agg = DailyIncomeStat.objects.filter(date=date.today()).aggregate( + amt=Sum('total_amount'), cnt=Sum('total_count'), + ) + self.stdout.write( + f'今日 daily 行合计: {float(agg["amt"] or 0):.2f} 元 / {int(agg["cnt"] or 0)} 笔' + ) + self.stdout.write(self.style.SUCCESS('\n完成。刷新后台财务页。')) + else: + self.stdout.write(self.style.WARNING( + f'\n今日 {r["today_amount"]:.2f} 元对的话:\n' + f' python manage.py rebuild_income_stats --yes' + )) diff --git a/config/management/commands/repopulate_today_income.py b/config/management/commands/repopulate_today_income.py new file mode 100644 index 0000000..f97acd6 --- /dev/null +++ b/config/management/commands/repopulate_today_income.py @@ -0,0 +1,48 @@ +"""只恢复今日收入(修复后台四个板子显示 0)。""" +from datetime import date + +from django.core.management.base import BaseCommand +from django.db.models import Sum + +from config.models import DailyIncomeStat +from jituan.services.szjilu_accounting import repopulate_today_income_stats + + +class Command(BaseCommand): + help = '重建今日 daily_income_stat(修复财务页今日收入为 0)' + + def add_arguments(self, parser): + parser.add_argument('--date', type=str, default='', help='日期 YYYY-MM-DD,默认今天') + parser.add_argument('--yes', action='store_true', help='确认执行') + + def handle(self, *args, **options): + stat_date = date.fromisoformat(options['date']) if options['date'] else date.today() + dry_run = not options['yes'] + + if dry_run: + self.stdout.write(self.style.WARNING('【预览】\n')) + else: + self.stdout.write(self.style.WARNING('【执行】重建今日收入...\n')) + + r = repopulate_today_income_stats(stat_date, dry_run=dry_run) + self.stdout.write(f'日期: {r["date"]}') + self.stdout.write(f' 当前: {r["before_amount"]:.2f} 元 / {r["before_count"]} 笔') + self.stdout.write(self.style.SUCCESS( + f' 重建后: {r["after_amount"]:.2f} 元 / {r["after_count"]} 笔' + )) + if r['items']: + for ref, cid, amt, kind in r['items']: + self.stdout.write(f' {ref} {amt}元 club={cid} ({kind})') + + if not dry_run: + agg = DailyIncomeStat.objects.filter(date=stat_date).aggregate( + amt=Sum('total_amount'), cnt=Sum('total_count'), + ) + self.stdout.write(self.style.SUCCESS( + f'\n完成。今日日统计: {float(agg["amt"] or 0):.2f} 元 / {int(agg["cnt"] or 0)} 笔' + )) + self.stdout.write('请刷新后台财务页。') + else: + self.stdout.write(self.style.WARNING( + '\n确认后: python manage.py repopulate_today_income --yes' + )) diff --git a/jituan/services/caiwu_stats.py b/jituan/services/caiwu_stats.py index e499008..bdf73e5 100644 --- a/jituan/services/caiwu_stats.py +++ b/jituan/services/caiwu_stats.py @@ -90,26 +90,20 @@ def build_caiwu_payload(request): scope = resolve_club_scope(request) today_income_qs = income_qs.filter(date=today) today_payout_qs = payout_qs.filter(date=today) - if scope == DATA_SCOPE_ALL: - inc_agg = today_income_qs.aggregate( - total_amount=Sum('total_amount'), - total_count=Sum('total_count'), - ) - today_income_amount = float(inc_agg['total_amount'] or 0.00) - today_income_count = int(inc_agg['total_count'] or 0) - pay_agg = today_payout_qs.aggregate( - total_amount=Sum('total_amount'), - total_count=Sum('total_count'), - ) - today_payout_amount = float(pay_agg['total_amount'] or 0.00) - today_payout_count = int(pay_agg['total_count'] or 0) - else: - today_income = today_income_qs.first() - today_income_amount = float(today_income.total_amount) if today_income else 0.00 - today_income_count = today_income.total_count if today_income else 0 - today_payout = today_payout_qs.first() - today_payout_amount = float(today_payout.total_amount) if today_payout else 0.00 - today_payout_count = today_payout.total_count if today_payout else 0 + + inc_agg = today_income_qs.aggregate( + total_amount=Sum('total_amount'), + total_count=Sum('total_count'), + ) + today_income_amount = float(inc_agg['total_amount'] or 0.00) + today_income_count = int(inc_agg['total_count'] or 0) + + pay_agg = today_payout_qs.aggregate( + total_amount=Sum('total_amount'), + total_count=Sum('total_count'), + ) + today_payout_amount = float(pay_agg['total_amount'] or 0.00) + today_payout_count = int(pay_agg['total_count'] or 0) today_orders = order_qs.filter( CreateTime__gte=today_start, @@ -160,10 +154,17 @@ def build_caiwu_payload(request): szjilu_payload = _build_szjilu_payload(request) - # 账户总收入/总支出:日收支统计表全历史 SUM(与「每日收支详细」按年汇总一致) - total_income = float(income_qs.aggregate(total=Sum('total_amount'))['total'] or 0.00) - total_payout = float(payout_qs.aggregate(total=Sum('total_amount'))['total'] or 0.00) - platform_profit = round(total_income - total_payout, 2) + # 账户总收入/总支出:始终全集团合计(切换俱乐部也不变少) + total_income = float( + DailyIncomeStat.query.aggregate(total=Sum('total_amount'))['total'] or 0.00 + ) + total_payout = float( + DailyPayoutStat.query.aggregate(total=Sum('total_amount'))['total'] or 0.00 + ) + # 俱乐部利润:当前视图范围内累计收入减累计支出 + scoped_total_income = float(income_qs.aggregate(total=Sum('total_amount'))['total'] or 0.00) + scoped_total_payout = float(payout_qs.aggregate(total=Sum('total_amount'))['total'] or 0.00) + platform_profit = round(scoped_total_income - scoped_total_payout, 2) daily_stats = [] since = today - timedelta(days=30) diff --git a/jituan/services/szjilu_accounting.py b/jituan/services/szjilu_accounting.py index 830c2cd..fe5a0ea 100644 --- a/jituan/services/szjilu_accounting.py +++ b/jituan/services/szjilu_accounting.py @@ -738,3 +738,283 @@ def reset_all_daily_szjilu(): szjilu.save(update_fields=['DailyExpense', 'DailyFlow', 'UpdateTime']) updated += 1 return updated + + +# 合法微信支付收入(不含退款 Status=5) +_LEGIT_ORDER_STATUSES = [1, 2, 3, 4, 6, 7, 8] +_LEGIT_CZ_LEIXING = [1, 2, 3, 4, 5] + + +def collect_legitimate_wechat_payments(since_year, until_year): + """ + 从业务单据重建:每笔 biz_ref 全局只出现一次。 + 返回 [(biz_ref, club_id, amount, stat_date, pay_datetime, kind), ...] + """ + from datetime import datetime + + from orders.models import Order + from products.models import Czjilu + + start = datetime(since_year, 1, 1) + end = datetime(until_year + 1, 1, 1) + seen = {} + items = [] + + def _add(ref, club_id, amount, pay_dt, kind): + ref = (ref or '').strip() + if not ref or ref in seen: + return + jine = Decimal(str(amount or 0)) + if jine <= 0: + return + cid = normalize_szjilu_club_id(club_id) + if not pay_dt: + pay_dt = datetime.now() + stat_d = pay_dt.date() if hasattr(pay_dt, 'date') else pay_dt + seen[ref] = True + items.append((ref, cid, jine, stat_d, pay_dt, kind)) + + for od in Order.query.filter( + CreateTime__gte=start, + CreateTime__lt=end, + Status__in=_LEGIT_ORDER_STATUSES, + ): + _add( + f'order:{od.OrderID}', + getattr(od, 'ClubID', None), + od.Amount, + od.CreateTime, + 'order', + ) + + for cz in Czjilu.query.filter( + CreateTime__gte=start, + CreateTime__lt=end, + zhuangtai=3, + leixing__in=_LEGIT_CZ_LEIXING, + ): + _add( + f'cz:{cz.dingdan_id}', + getattr(cz, 'club_id', None), + cz.jine, + cz.CreateTime, + f'cz_{cz.leixing}', + ) + + return items + + +def rebuild_income_stats_from_scratch(since_year=None, until_year=None, dry_run=True): + """ + 终极恢复:清空全部收支脏数据 → 按真实微信支付逐笔重建(每笔唯一)。 + """ + from collections import defaultdict + + from django.db.models import Sum + + until_year = until_year or date.today().year + since_year = since_year or (until_year - 1) + + before = { + 'daily': float(DailyIncomeStat.objects.aggregate(t=Sum('total_amount'))['t'] or 0), + 'logs': 0, + 'log_cnt': 0, + } + try: + before['logs'] = float(PlatformIncomeLog.objects.aggregate(t=Sum('amount'))['t'] or 0) + before['log_cnt'] = PlatformIncomeLog.objects.count() + except _PLATFORM_LOG_ERRORS: + pass + + items = collect_legitimate_wechat_payments(since_year, until_year) + total_amt = sum((x[2] for x in items), _ZERO) + today = date.today() + today_amt = sum((x[2] for x in items if x[3] == today), _ZERO) + today_cnt = sum(1 for x in items if x[3] == today) + + month_amt = sum( + (x[2] for x in items if x[3].year == today.year and x[3].month == today.month), + _ZERO, + ) + + result = { + 'since_year': since_year, + 'until_year': until_year, + 'before_daily_total': before['daily'], + 'before_log_total': before['logs'], + 'before_log_count': before['log_cnt'], + 'after_total': float(total_amt), + 'after_count': len(items), + 'today_amount': float(today_amt), + 'today_count': today_cnt, + 'month_amount': float(month_amt), + 'items': items, + 'dry_run': dry_run, + } + + if dry_run: + return result + + with transaction.atomic(): + try: + PlatformIncomeLog.objects.all().delete() + except _PLATFORM_LOG_ERRORS as exc: + raise RuntimeError(f'platform_income_log 不可用: {exc}') from exc + DailyIncomeStat.objects.all().delete() + + by_day_club = defaultdict(lambda: {'amt': _ZERO, 'cnt': 0}) + for ref, cid, jine, stat_d, pay_dt, _kind in items: + log = PlatformIncomeLog.objects.create(biz_ref=ref, club_id=cid, amount=jine) + if pay_dt: + PlatformIncomeLog.objects.filter(pk=log.pk).update(CreateTime=pay_dt) + key = (stat_d, cid) + by_day_club[key]['amt'] += jine + by_day_club[key]['cnt'] += 1 + + for (stat_d, cid), agg in by_day_club.items(): + DailyIncomeStat.objects.create( + date=stat_d, + club_id=cid, + year=stat_d.year, + month=stat_d.month, + day=stat_d.day, + total_amount=agg['amt'], + total_count=agg['cnt'], + ) + + today_d = date.today() + club_ids = set(by_day_club.keys()) + club_ids |= {(today_d, row.club_id) for row in Szjilu.objects.all()} + + all_clubs = {cid for _, cid in club_ids} + for cid in all_clubs: + total = PlatformIncomeLog.objects.filter(club_id=cid).aggregate( + t=Sum('amount'), + )['t'] or _ZERO + today_flow = PlatformIncomeLog.objects.filter( + club_id=cid, CreateTime__date=today_d, + ).aggregate(t=Sum('amount'))['t'] or _ZERO + szjilu, _ = Szjilu.objects.select_for_update().get_or_create( + club_id=cid, + defaults=dict(_SZJILU_DEFAULTS), + ) + szjilu.TotalIncome = total + szjilu.TotalFlow = total + szjilu.DailyFlow = today_flow + szjilu.save(update_fields=['TotalIncome', 'TotalFlow', 'DailyFlow', 'UpdateTime']) + + result['after_daily_total'] = float( + DailyIncomeStat.objects.aggregate(t=Sum('total_amount'))['t'] or 0 + ) + return result + + +def collect_today_live_wechat_payments(stat_date=None): + """今日实时微信支付(含当日下单或当日完成支付的订单/充值)。""" + from datetime import datetime, timedelta + + from django.db.models import Q + + from orders.models import Order + from products.models import Czjilu + + stat_date = stat_date or date.today() + day_start = datetime.combine(stat_date, datetime.min.time()) + day_end = day_start + timedelta(days=1) + seen = {} + items = [] + + def _add(ref, club_id, amount, kind): + ref = (ref or '').strip() + if not ref or ref in seen: + return + jine = Decimal(str(amount or 0)) + if jine <= 0: + return + cid = normalize_szjilu_club_id(club_id) + seen[ref] = True + items.append((ref, cid, jine, kind)) + + for od in Order.query.filter(Status__in=_LEGIT_ORDER_STATUSES).filter( + Q(CreateTime__gte=day_start, CreateTime__lt=day_end) + | Q(UpdateTime__gte=day_start, UpdateTime__lt=day_end), + ): + _add(f'order:{od.OrderID}', getattr(od, 'ClubID', None), od.Amount, 'order') + + for cz in Czjilu.query.filter(zhuangtai=3, leixing__in=_LEGIT_CZ_LEIXING).filter( + Q(CreateTime__gte=day_start, CreateTime__lt=day_end) + | Q(UpdateTime__gte=day_start, UpdateTime__lt=day_end), + ): + _add(f'cz:{cz.dingdan_id}', getattr(cz, 'club_id', None), cz.jine, f'cz_{cz.leixing}') + + return items + + +def repopulate_today_income_stats(stat_date=None, dry_run=True): + """只重建指定日期的日收入(不删历史其它天),修复今日显示为 0。""" + from collections import defaultdict + from datetime import datetime, time as dt_time + + from django.db.models import Sum + from django.utils import timezone + + stat_date = stat_date or date.today() + before = DailyIncomeStat.objects.filter(date=stat_date).aggregate( + amt=Sum('total_amount'), cnt=Sum('total_count'), + ) + items = collect_today_live_wechat_payments(stat_date) + total = sum((x[2] for x in items), _ZERO) + + result = { + 'date': str(stat_date), + 'before_amount': float(before['amt'] or 0), + 'before_count': int(before['cnt'] or 0), + 'after_amount': float(total), + 'after_count': len(items), + 'items': items, + 'dry_run': dry_run, + } + if dry_run: + return result + + pay_ts = timezone.make_aware(datetime.combine(stat_date, dt_time(12, 0, 0))) + with transaction.atomic(): + DailyIncomeStat.objects.filter(date=stat_date).delete() + + by_club = defaultdict(lambda: {'amt': _ZERO, 'cnt': 0}) + for ref, cid, jine, _kind in items: + if not income_log_exists(ref): + try: + log = PlatformIncomeLog.objects.create(biz_ref=ref, club_id=cid, amount=jine) + PlatformIncomeLog.objects.filter(pk=log.pk).update(CreateTime=pay_ts) + except IntegrityError: + pass + by_club[cid]['amt'] += jine + by_club[cid]['cnt'] += 1 + + for cid, agg in by_club.items(): + DailyIncomeStat.objects.create( + date=stat_date, + club_id=cid, + year=stat_date.year, + month=stat_date.month, + day=stat_date.day, + total_amount=agg['amt'], + total_count=agg['cnt'], + ) + + today_d = date.today() + for cid, agg in by_club.items(): + szjilu, _ = Szjilu.objects.select_for_update().get_or_create( + club_id=cid, defaults=dict(_SZJILU_DEFAULTS), + ) + if stat_date == today_d: + szjilu.DailyFlow = agg['amt'] + total_all = PlatformIncomeLog.objects.filter(club_id=cid).aggregate( + t=Sum('amount'), + )['t'] or _ZERO + szjilu.TotalIncome = total_all + szjilu.TotalFlow = total_all + szjilu.save(update_fields=['TotalIncome', 'TotalFlow', 'DailyFlow', 'UpdateTime']) + + return result diff --git a/orders/utils.py b/orders/utils.py index f966e7e..b2673de 100644 --- a/orders/utils.py +++ b/orders/utils.py @@ -33,14 +33,14 @@ def get_order_user_id(order): return None -def update_daily_income(amount, club_id=None): +def update_daily_income(amount, club_id=None, stat_date=None): """ 原子更新当日收入统计(金额累加,笔数加1)。 用于微信支付成功回调。 """ from jituan.constants import CLUB_ID_DEFAULT cid = club_id or CLUB_ID_DEFAULT - today = date.today() + today = stat_date or date.today() year, month, day = today.year, today.month, today.day with transaction.atomic():