财务每日收入改从真实支付实时汇总,与统计表解耦

This commit is contained in:
XingQue
2026-06-24 21:45:08 +08:00
parent b1fbc3cd8f
commit 8c271f639a
6 changed files with 454 additions and 100 deletions

View File

@@ -6939,13 +6939,14 @@ class CaiwuView(APIView):
today_start = datetime.combine(today, datetime.min.time())
today_end = today_start + timedelta(days=1)
# 今日收入 / 支出(全平台各俱乐部合计)
today_inc = DailyIncomeStat.query.filter(date=today).aggregate(
total_amount=Sum('total_amount'),
total_count=Sum('total_count'),
from jituan.services.live_income_stats import (
income_daily_stats_last_days,
income_for_date,
income_total_all_time,
)
today_income_amount = float(today_inc['total_amount'] or 0.00)
today_income_count = int(today_inc['total_count'] or 0)
# 今日收入:从真实微信支付汇总,不读 daily_income_stat
today_income_amount, today_income_count = income_for_date(today)
today_pay = DailyPayoutStat.query.filter(date=today).aggregate(
total_amount=Sum('total_amount'),
@@ -7016,23 +7017,20 @@ class CaiwuView(APIView):
all_zuzhang_yue = float(UserZuzhang.query.aggregate(total=Sum('ketixian_jine'))['total'] or 0.00)
all_shangjia_yue = float(UserShangjia.query.aggregate(total=Sum('yue'))['total'] or 0.00)
# 平台总收支及利润
total_income = float(DailyIncomeStat.query.aggregate(total=Sum('total_amount'))['total'] or 0.00)
# 平台总收支及利润(收入侧实时汇总)
total_income, _ = income_total_all_time()
total_payout = float(DailyPayoutStat.query.aggregate(total=Sum('total_amount'))['total'] or 0.00)
platform_profit = round(total_income - total_payout, 2)
# 近30天每日收支明细(按日期合并各俱乐部)
# 近30天每日收支明细
daily_stats = []
since = today - timedelta(days=30)
income_by_date = {
str(row['date']): {
'income': float(row['total_amount'] or 0),
'income_count': int(row['total_count'] or 0),
row['date']: {
'income': row['income'],
'income_count': row['income_count'],
}
for row in DailyIncomeStat.query.filter(date__gte=since).values('date').annotate(
total_amount=Sum('total_amount'),
total_count=Sum('total_count'),
)
for row in income_daily_stats_last_days(30)
}
payout_by_date = {
str(row['date']): float(row['total_amount'] or 0)
@@ -7408,12 +7406,13 @@ class SzxxView(APIView):
else:
return Response({'code': 1, 'msg': '无效的颗粒度'}, status=400)
# ---------- 收入分组查询 ----------
income_qs = filter_queryset_by_club(
DailyIncomeStat.query.filter(**income_filter), request,
).values(group_field) \
.annotate(total_income=Sum('total_amount'), total_count=Sum('total_count')) \
.order_by(group_field)
# ---------- 收入:从真实支付汇总;支出仍读 daily_payout_stat ----------
from jituan.services.live_income_stats import (
income_summary_filter,
income_time_series,
)
income_map = income_time_series(granularity, year, month, request)
# ---------- 支出分组查询 ----------
payout_qs = filter_queryset_by_club(
@@ -7424,13 +7423,12 @@ class SzxxView(APIView):
# ---------- 合并数据 ----------
data_map = {}
for item in income_qs:
key = item[group_field]
for key, vals in income_map.items():
data_map[key] = {
'income': float(item['total_income'] or 0),
'income_count': item['total_count'] or 0,
'income': vals['income'],
'income_count': vals.get('income_count', 0),
'payout': 0.0,
'payout_count': 0
'payout_count': 0,
}
for item in payout_qs:
@@ -7459,17 +7457,25 @@ class SzxxView(APIView):
time_series.sort(key=lambda x: x['date'])
# ---------- 汇总数据 ----------
income_summary = filter_queryset_by_club(
DailyIncomeStat.query.filter(**summary_filter), request,
).aggregate(
total_income=Sum('total_amount'), total_count=Sum('total_count')
)
if granularity == 'day':
summary_day = int(summary_parts[2])
total_income, total_income_count = income_summary_filter(
'day', int(summary_parts[0]), int(summary_parts[1]), summary_day, request,
)
elif granularity == 'month':
total_income, total_income_count = income_summary_filter(
'month', int(summary_parts[0]), int(summary_parts[1]), None, request,
)
else:
total_income, total_income_count = income_summary_filter(
'year', int(summary_parts[0]), None, None, request,
)
payout_summary = filter_queryset_by_club(
DailyPayoutStat.query.filter(**summary_filter), request,
).aggregate(
total_payout=Sum('total_amount'), total_count=Sum('total_count')
)
total_income = float(income_summary['total_income'] or 0)
total_payout = float(payout_summary['total_payout'] or 0)
total_profit = round(total_income - total_payout, 2)
@@ -7480,7 +7486,7 @@ class SzxxView(APIView):
'time_series': time_series,
'summary': {
'total_income': total_income,
'total_income_count': income_summary['total_count'] or 0,
'total_income_count': total_income_count,
'total_payout': total_payout,
'total_payout_count': payout_summary['total_count'] or 0,
'total_profit': total_profit

View File

@@ -0,0 +1,47 @@
"""
从订单/充值表恢复 daily_income_stat不删订单只重算统计表
repair/rebuild 搞乱后用这个,不要用 repair_wechat_income。
预览python manage.py restore_daily_income_from_orders
执行python manage.py restore_daily_income_from_orders --yes
"""
from datetime import date
from django.core.management.base import BaseCommand
from jituan.services.szjilu_accounting import restore_daily_income_from_orders
class Command(BaseCommand):
help = '从订单/充值表恢复日收入统计(订单不动)'
def add_arguments(self, parser):
parser.add_argument('--since-year', type=int, default=0)
parser.add_argument('--until-year', type=int, default=0)
parser.add_argument('--yes', action='store_true')
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.WARNING('【执行】按订单/充值重算日统计...\n'))
r = restore_daily_income_from_orders(since_year, until_year, dry_run=dry_run)
self.stdout.write(f'范围: {r["since_year"]} ~ {r["until_year"]}')
self.stdout.write(f' 支付笔数(订单表): {r["payment_rows"]}')
self.stdout.write(f' 日统计行数: {r["daily_rows"]}')
self.stdout.write(f' 当前 daily 合计: {r["before_daily_total"]:.2f}')
self.stdout.write(self.style.SUCCESS(
f' 恢复后 daily 合计: {r["after_daily_total"]:.2f}'
))
if dry_run:
self.stdout.write(self.style.WARNING(
'\n确认: python manage.py restore_daily_income_from_orders --yes'
))
else:
self.stdout.write(self.style.SUCCESS('\n完成。推 kefu 后刷新财务页。'))

View File

@@ -0,0 +1,12 @@
"""按真实支付重算 szjilu 收支记录表(与每日收入看板无关,可选执行)。"""
from django.core.management.base import BaseCommand
from jituan.services.live_income_stats import sync_szjilu_from_live_payments
class Command(BaseCommand):
help = '按订单/充值重算 szjilu 表 TotalIncome、TotalFlow、DailyFlow不动 daily_income_stat'
def handle(self, *args, **options):
n = sync_szjilu_from_live_payments()
self.stdout.write(self.style.SUCCESS(f'已更新 {n} 条 szjilu 记录'))

View File

@@ -12,8 +12,13 @@ from jituan.services.club_context import (
resolve_club_scope,
)
from jituan.constants import DATA_SCOPE_ALL
from jituan.services.live_income_stats import (
income_daily_stats_last_days,
income_for_date,
income_total_all_time,
)
from config.models import DailyIncomeStat, DailyPayoutStat, Szjilu
from config.models import DailyPayoutStat, Szjilu
from orders.models import Order
from products.models import Czjilu, Huiyuangoumai
from users.models import UserDashou, UserGuanshi, UserShangjia, UserZuzhang
@@ -66,10 +71,6 @@ def _huiyuangoumai_qs(request):
return filter_club_char_field(Huiyuangoumai.query.all(), request, field='club_id')
def _daily_income_qs(request):
return filter_club_char_field(DailyIncomeStat.query.all(), request, field='club_id')
def _daily_payout_qs(request):
return filter_club_char_field(DailyPayoutStat.query.all(), request, field='club_id')
@@ -84,19 +85,11 @@ def build_caiwu_payload(request):
order_qs = _order_qs(request)
cz_qs = _czjilu_qs(request)
hy_qs = _huiyuangoumai_qs(request)
income_qs = _daily_income_qs(request)
payout_qs = _daily_payout_qs(request)
scope = resolve_club_scope(request)
today_income_qs = income_qs.filter(date=today)
today_payout_qs = payout_qs.filter(date=today)
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)
today_income_amount, today_income_count = income_for_date(today, request)
pay_agg = today_payout_qs.aggregate(
total_amount=Sum('total_amount'),
@@ -154,67 +147,43 @@ def build_caiwu_payload(request):
szjilu_payload = _build_szjilu_payload(request)
# 账户总收入/总支出:始终全集团合计(切换俱乐部也不变少)
total_income = float(
DailyIncomeStat.query.aggregate(total=Sum('total_amount'))['total'] or 0.00
)
total_income, _ = income_total_all_time()
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_income, _ = income_total_all_time(request)
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)
if scope == DATA_SCOPE_ALL:
income_by_date = {
str(row['date']): {
'income': float(row['total_amount'] or 0),
'income_count': int(row['total_count'] or 0),
}
for row in income_qs.filter(date__gte=since).values('date').annotate(
total_amount=Sum('total_amount'),
total_count=Sum('total_count'),
)
income_by_date = {
row['date']: {
'income': row['income'],
'income_count': row['income_count'],
}
payout_by_date = {
str(row['date']): float(row['total_amount'] or 0)
for row in payout_qs.filter(date__gte=since).values('date').annotate(
total_amount=Sum('total_amount'),
)
}
all_dates = sorted(
set(income_by_date.keys()) | set(payout_by_date.keys()),
reverse=True,
)[:30]
for d in all_dates:
inc = income_by_date.get(d, {'income': 0.0, 'income_count': 0})
payout_amt = payout_by_date.get(d, 0.00)
daily_stats.append({
'date': d,
'income': inc['income'],
'income_count': inc['income_count'],
'payout': payout_amt,
'profit': round(inc['income'] - payout_amt, 2),
})
else:
income_list = income_qs.filter(date__gte=since).order_by('-date')[:30]
payout_dict = {
str(p.date): float(p.total_amount)
for p in payout_qs.filter(date__gte=since)
}
for inc in income_list:
d = str(inc.date)
payout_amt = payout_dict.get(d, 0.00)
daily_stats.append({
'date': d,
'income': float(inc.total_amount),
'income_count': inc.total_count,
'payout': payout_amt,
'profit': round(float(inc.total_amount) - payout_amt, 2),
})
for row in income_daily_stats_last_days(30, request)
}
payout_by_date = {
str(row['date']): float(row['total_amount'] or 0)
for row in payout_qs.filter(date__gte=since).values('date').annotate(
total_amount=Sum('total_amount'),
)
}
all_dates = sorted(
set(income_by_date.keys()) | set(payout_by_date.keys()),
reverse=True,
)[:30]
for d in all_dates:
inc = income_by_date.get(d, {'income': 0.0, 'income_count': 0})
payout_amt = payout_by_date.get(d, 0.00)
daily_stats.append({
'date': d,
'income': inc['income'],
'income_count': inc['income_count'],
'payout': payout_amt,
'profit': round(inc['income'] - payout_amt, 2),
})
return {
'club_id': club_id,

View File

@@ -0,0 +1,264 @@
"""
每日收入:从真实微信支付订单/充值单实时汇总。
不读 daily_income_stat该表可被 repair 搞乱,与展示解耦)。
每日支出:仍读 daily_payout_statrepair 未动支出)。
收支记录 szjilu独立表本模块不负责展示。
"""
from calendar import monthrange
from datetime import date, datetime, timedelta
from decimal import Decimal
from django.db.models import Count, Q, Sum
from django.db.models.functions import ExtractYear, TruncDate, TruncMonth
from jituan.constants import CLUB_ID_DEFAULT
from jituan.services.club_context import (
filter_club_char_field,
filter_queryset_by_club,
)
_ORDER_PAID = [1, 2, 3, 4, 6, 7, 8]
_CZ_PAID_LEIXING = [1, 2, 3, 4, 5]
_ZERO = Decimal('0.00')
def _orders_qs(request=None):
from orders.models import Order
qs = Order.query.filter(Status__in=_ORDER_PAID)
if request is not None:
qs = filter_queryset_by_club(qs, request, club_field='ClubID')
return qs
def _cz_qs(request=None):
from products.models import Czjilu
qs = Czjilu.query.filter(zhuangtai=3, leixing__in=_CZ_PAID_LEIXING)
if request is not None:
qs = filter_club_char_field(qs, request, field='club_id')
return qs
def _merge_amount_count(order_rows, cz_rows, key_field='d'):
out = {}
for row in order_rows:
k = row[key_field]
if k is None:
continue
if k not in out:
out[k] = {'amount': _ZERO, 'count': 0}
out[k]['amount'] += Decimal(str(row['total'] or 0))
out[k]['count'] += int(row['cnt'] or 0)
for row in cz_rows:
k = row[key_field]
if k is None:
continue
if k not in out:
out[k] = {'amount': _ZERO, 'count': 0}
out[k]['amount'] += Decimal(str(row['total'] or 0))
out[k]['count'] += int(row['cnt'] or 0)
return out
def income_for_date(stat_date, request=None):
"""指定日期的微信收入金额与笔数。"""
day_start = datetime.combine(stat_date, datetime.min.time())
day_end = day_start + timedelta(days=1)
o = _orders_qs(request).filter(
UpdateTime__gte=day_start, UpdateTime__lt=day_end,
).aggregate(total=Sum('Amount'), cnt=Count('OrderID'))
c = _cz_qs(request).filter(
UpdateTime__gte=day_start, UpdateTime__lt=day_end,
).aggregate(total=Sum('jine'), cnt=Count('dingdan_id'))
amt = Decimal(str(o['total'] or 0)) + Decimal(str(c['total'] or 0))
cnt = int(o['cnt'] or 0) + int(c['cnt'] or 0)
return float(amt), cnt
def income_total_all_time(request=None):
"""历史累计微信收入。"""
o = _orders_qs(request).aggregate(total=Sum('Amount'), cnt=Count('OrderID'))
c = _cz_qs(request).aggregate(total=Sum('jine'), cnt=Count('dingdan_id'))
amt = Decimal(str(o['total'] or 0)) + Decimal(str(c['total'] or 0))
cnt = int(o['cnt'] or 0) + int(c['cnt'] or 0)
return float(amt), cnt
def income_daily_stats_last_days(days=30, request=None):
"""近 N 天每日收入(用于财务看板 daily_stats"""
since = date.today() - timedelta(days=days)
since_dt = datetime.combine(since, datetime.min.time())
o_rows = (
_orders_qs(request)
.filter(UpdateTime__gte=since_dt)
.annotate(d=TruncDate('UpdateTime'))
.values('d')
.annotate(total=Sum('Amount'), cnt=Count('OrderID'))
)
c_rows = (
_cz_qs(request)
.filter(UpdateTime__gte=since_dt)
.annotate(d=TruncDate('UpdateTime'))
.values('d')
.annotate(total=Sum('jine'), cnt=Count('dingdan_id'))
)
merged = _merge_amount_count(list(o_rows), list(c_rows), 'd')
result = []
for d, v in sorted(merged.items(), reverse=True)[:days]:
result.append({
'date': str(d),
'income': float(v['amount']),
'income_count': v['count'],
})
return result
def income_time_series(granularity, year, month=None, request=None):
"""
收支详细页时间序列(仅收入侧,支出由调用方合并 daily_payout_stat
granularity: day | month | year
"""
if granularity == 'day':
if not year or not month:
return {}
_, last = monthrange(year, month)
start = datetime(year, month, 1)
end = datetime(year, month, last) + timedelta(days=1)
o_rows = (
_orders_qs(request)
.filter(UpdateTime__gte=start, UpdateTime__lt=end)
.annotate(d=TruncDate('UpdateTime'))
.values('d')
.annotate(total=Sum('Amount'), cnt=Count('OrderID'))
)
c_rows = (
_cz_qs(request)
.filter(UpdateTime__gte=start, UpdateTime__lt=end)
.annotate(d=TruncDate('UpdateTime'))
.values('d')
.annotate(total=Sum('jine'), cnt=Count('dingdan_id'))
)
merged = _merge_amount_count(list(o_rows), list(c_rows), 'd')
out = {}
for d, v in merged.items():
out[d.day] = {'income': float(v['amount']), 'income_count': v['count']}
return out
if granularity == 'month':
start = datetime(year, 1, 1)
end = datetime(year + 1, 1, 1)
o_rows = (
_orders_qs(request)
.filter(UpdateTime__gte=start, UpdateTime__lt=end)
.annotate(m=TruncMonth('UpdateTime'))
.values('m')
.annotate(total=Sum('Amount'), cnt=Count('OrderID'))
)
c_rows = (
_cz_qs(request)
.filter(UpdateTime__gte=start, UpdateTime__lt=end)
.annotate(m=TruncMonth('UpdateTime'))
.values('m')
.annotate(total=Sum('jine'), cnt=Count('dingdan_id'))
)
merged = _merge_amount_count(list(o_rows), list(c_rows), 'm')
out = {}
for m, v in merged.items():
out[m.month] = {'income': float(v['amount']), 'income_count': v['count']}
return out
o_rows = (
_orders_qs(request)
.annotate(y=ExtractYear('UpdateTime'))
.values('y')
.annotate(total=Sum('Amount'), cnt=Count('OrderID'))
)
c_rows = (
_cz_qs(request)
.annotate(y=ExtractYear('UpdateTime'))
.values('y')
.annotate(total=Sum('jine'), cnt=Count('dingdan_id'))
)
merged = _merge_amount_count(list(o_rows), list(c_rows), 'y')
return {
int(k): {'income': float(v['amount']), 'income_count': v['count']}
for k, v in merged.items()
}
def income_summary_filter(granularity, year, month=None, day=None, request=None):
"""详细页顶部汇总区间的收入。"""
if granularity == 'day' and year and month and day:
return income_for_date(date(year, month, day), request)
if granularity == 'month' and year and month:
start = datetime(year, month, 1)
if month == 12:
end = datetime(year + 1, 1, 1)
else:
end = datetime(year, month + 1, 1)
elif granularity == 'year' and year:
start = datetime(year, 1, 1)
end = datetime(year + 1, 1, 1)
else:
return income_total_all_time(request)
o = _orders_qs(request).filter(
UpdateTime__gte=start, UpdateTime__lt=end,
).aggregate(total=Sum('Amount'), cnt=Count('OrderID'))
c = _cz_qs(request).filter(
UpdateTime__gte=start, UpdateTime__lt=end,
).aggregate(total=Sum('jine'), cnt=Count('dingdan_id'))
amt = Decimal(str(o['total'] or 0)) + Decimal(str(c['total'] or 0))
cnt = int(o['cnt'] or 0) + int(c['cnt'] or 0)
return float(amt), cnt
def sync_szjilu_from_live_payments():
"""按真实支付重算 szjilu 收支记录表(与每日收入展示无关,可单独执行)。"""
from config.models import Szjilu
today = date.today()
today_start = datetime.combine(today, datetime.min.time())
today_end = today_start + timedelta(days=1)
clubs = set()
for row in _orders_qs().values('ClubID').distinct():
clubs.add((row.get('ClubID') or '').strip() or CLUB_ID_DEFAULT)
for row in _cz_qs().values('club_id').distinct():
clubs.add((row.get('club_id') or '').strip() or CLUB_ID_DEFAULT)
def _club_orders(cid):
qs = _orders_qs()
if cid == CLUB_ID_DEFAULT:
return qs.filter(Q(ClubID=cid) | Q(ClubID__isnull=True) | Q(ClubID=''))
return qs.filter(ClubID=cid)
updated = 0
for cid in clubs:
o_total = _club_orders(cid).aggregate(t=Sum('Amount'))['t'] or 0
c_total = _cz_qs().filter(club_id=cid).aggregate(t=Sum('jine'))['t'] or 0
total = Decimal(str(o_total)) + Decimal(str(c_total))
o_day = _club_orders(cid).filter(
UpdateTime__gte=today_start, UpdateTime__lt=today_end,
)
o_day_amt = o_day.aggregate(t=Sum('Amount'))['t'] or 0
c_day = _cz_qs().filter(
club_id=cid, UpdateTime__gte=today_start, UpdateTime__lt=today_end,
).aggregate(t=Sum('jine'))['t'] or 0
day_flow = Decimal(str(o_day_amt)) + Decimal(str(c_day))
szjilu, _ = Szjilu.objects.get_or_create(
club_id=cid,
defaults={
'TotalIncome': _ZERO, 'TotalFlow': _ZERO, 'TotalExpense': _ZERO,
'DailyExpense': _ZERO, 'DailyFlow': _ZERO,
},
)
szjilu.TotalIncome = total
szjilu.TotalFlow = total
szjilu.DailyFlow = day_flow
szjilu.save(update_fields=['TotalIncome', 'TotalFlow', 'DailyFlow', 'UpdateTime'])
updated += 1
return updated

View File

@@ -950,6 +950,62 @@ def collect_today_live_wechat_payments(stat_date=None):
return items
def restore_daily_income_from_orders(since_year=None, until_year=None, dry_run=True):
"""
从订单/充值表重算 daily_income_stat订单数据不动只覆盖统计表
"""
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)
items = collect_legitimate_wechat_payments(since_year, until_year)
by_day_club = defaultdict(lambda: {'amt': _ZERO, 'cnt': 0})
for _ref, cid, jine, stat_d, _pay_dt, _kind in items:
key = (stat_d, cid)
by_day_club[key]['amt'] += jine
by_day_club[key]['cnt'] += 1
before = float(DailyIncomeStat.objects.aggregate(t=Sum('total_amount'))['t'] or 0)
total_after = sum((v['amt'] for v in by_day_club.values()), _ZERO)
result = {
'since_year': since_year,
'until_year': until_year,
'payment_rows': len(items),
'daily_rows': len(by_day_club),
'before_daily_total': before,
'after_daily_total': float(total_after),
'dry_run': dry_run,
}
if dry_run:
return result
with transaction.atomic():
DailyIncomeStat.objects.filter(
date__gte=date(since_year, 1, 1),
date__lte=date(until_year, 12, 31),
).delete()
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'],
)
sync_szjilu_income_from_logs()
result['after_daily_total'] = float(
DailyIncomeStat.objects.aggregate(t=Sum('total_amount'))['t'] or 0
)
return result
def repopulate_today_income_stats(stat_date=None, dry_run=True):
"""只重建指定日期的日收入(不删历史其它天),修复今日显示为 0。"""
from collections import defaultdict