fix: 恢复 repair 重复记账 + 禁用错误补账命令

This commit is contained in:
XingQue
2026-06-24 21:18:57 +08:00
parent a6068f7e2b
commit 600188a99a
4 changed files with 433 additions and 112 deletions

View File

@@ -0,0 +1,85 @@
"""
恢复被 repair_wechat_income 搞乱的收入统计。
repair 会对已入账订单重复加钱200 变 900 就是这个)。
本命令:删掉当日错误日志 → 按真实微信支付重建 → 重算日统计和 szjilu。
先不加 --yes 可预览重建后金额。
"""
from datetime import date, timedelta
from django.core.management.base import BaseCommand
from jituan.services.szjilu_accounting import recover_income_stats_for_date
class Command(BaseCommand):
help = '恢复 repair_wechat_income 造成的重复记账(先预览,再加 --yes 执行)'
def add_arguments(self, parser):
parser.add_argument(
'--date',
type=str,
default='',
help='恢复日期 YYYY-MM-DD默认今天',
)
parser.add_argument(
'--days',
type=int,
default=1,
help='从指定日期起向前恢复几天,默认 1',
)
parser.add_argument(
'--yes',
action='store_true',
help='确认执行(不加则仅预览,不改数据库)',
)
def handle(self, *args, **options):
if options['date']:
end_day = date.fromisoformat(options['date'])
else:
end_day = date.today()
days = max(1, int(options['days'] or 1))
start_day = end_day - timedelta(days=days - 1)
dry_run = not options['yes']
if dry_run:
self.stdout.write(self.style.WARNING(
'【预览模式】未改数据库。确认金额无误后加 --yes 执行。\n'
))
else:
self.stdout.write(self.style.WARNING('【执行模式】正在恢复数据...\n'))
for i in range(days):
d = start_day + timedelta(days=i)
r = recover_income_stats_for_date(d, dry_run=dry_run)
self.stdout.write(f"=== {r['date']} ===")
self.stdout.write(
f" 当前日统计(错乱): {r['before_daily_amount']:.2f} 元 / {r['before_daily_count']}"
)
self.stdout.write(
f" 当前幂等日志: {r['before_log_amount']:.2f} 元 / {r['before_log_count']}"
)
self.stdout.write(self.style.SUCCESS(
f" 重建后(真实支付): {r['after_amount']:.2f} 元 / {r['after_count']}"
))
if r['items'] and dry_run:
self.stdout.write(' 明细前20笔:')
for ref, cid, amt, kind in r['items'][:20]:
self.stdout.write(f' {ref} {amt}元 club={cid} ({kind})')
if len(r['items']) > 20:
self.stdout.write(f' ... 共 {len(r["items"])}')
if dry_run:
self.stdout.write(self.style.WARNING(
'\n预览完成。若重建后金额正确,执行:\n'
' python manage.py recover_wechat_income_stats --yes\n'
'指定日期:\n'
' python manage.py recover_wechat_income_stats --date 2026-06-24 --yes'
))
else:
self.stdout.write(self.style.SUCCESS(
'\n恢复完成。请刷新后台财务页核对今日收入。'
'\n勿再执行 repair_wechat_income已禁用'
))

View File

@@ -1,102 +1,15 @@
"""补记漏掉的微信收入统计(今日或指定日期已支付但未入账的单据)"""
from datetime import date, datetime, timedelta
"""【已废弃】请用 sync_daily_income_from_log本命令曾导致重复记账"""
from django.core.management.base import BaseCommand
from django.db.models import Q
from config.models import PlatformIncomeLog
from jituan.constants import CLUB_ID_DEFAULT
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
from orders.models import Order
from products.models import Czjilu
class Command(BaseCommand):
help = '扫描已支付订单/充值单,补记 daily_income_stat幂等可重复执行'
def add_arguments(self, parser):
parser.add_argument(
'--date',
type=str,
default='',
help='补记日期 YYYY-MM-DD默认今天',
)
parser.add_argument(
'--days',
type=int,
default=1,
help='从指定日期起向前补几天,默认 1仅当天',
)
help = '已废弃:勿使用。请改用 python manage.py sync_daily_income_from_log --reset-day'
def handle(self, *args, **options):
if options['date']:
end_day = date.fromisoformat(options['date'])
else:
end_day = date.today()
days = max(1, int(options['days'] or 1))
start_day = end_day - timedelta(days=days - 1)
repaired = 0
skipped = 0
errors = 0
for day in range(days):
d = start_day + timedelta(days=day)
day_start = datetime.combine(d, datetime.min.time())
day_end = day_start + timedelta(days=1)
self.stdout.write(f'扫描 {d} ...')
orders = Order.query.filter(
CreateTime__gte=day_start,
CreateTime__lt=day_end,
Status__in=[1, 2, 3, 4, 5, 6, 7, 8],
)
for od in orders:
club_id = getattr(od, 'ClubID', None) or CLUB_ID_DEFAULT
ref = f'order:{od.OrderID}'
try:
ok = self._repair_one(od.Amount, club_id, biz_ref=ref)
if ok:
repaired += 1
self.stdout.write(f' + 订单 {od.OrderID} {od.Amount}元 club={club_id}')
else:
skipped += 1
except Exception as exc:
errors += 1
self.stderr.write(f' ! 订单 {od.OrderID} 失败: {exc}')
cz_orders = Czjilu.query.filter(
CreateTime__gte=day_start,
CreateTime__lt=day_end,
zhuangtai=3,
).filter(
Q(leixing__in=[1, 2, 3, 4]) | Q(leixing__isnull=False),
)
for cz in cz_orders:
club_id = getattr(cz, 'club_id', None) or CLUB_ID_DEFAULT
ref = f'cz:{cz.dingdan_id}'
try:
ok = self._repair_one(cz.jine, club_id, biz_ref=ref)
if ok:
repaired += 1
self.stdout.write(f' + 充值 {cz.dingdan_id} {cz.jine}元 leixing={cz.leixing} club={club_id}')
else:
skipped += 1
except Exception as exc:
errors += 1
self.stderr.write(f' ! 充值 {cz.dingdan_id} 失败: {exc}')
self.stdout.write(self.style.SUCCESS(
f'完成:新补记 {repaired} 笔,已存在跳过 {skipped} 笔,失败 {errors}',
self.stderr.write(self.style.ERROR(
'repair_wechat_income 已禁用(会对已入账订单重复加钱)。\n'
'纠正今日虚高请执行:\n'
' python manage.py sync_daily_income_from_log --reset-day\n'
'可选同时重算累计:\n'
' python manage.py sync_daily_income_from_log --reset-day --sync-szjilu'
))
def _repair_one(self, amount, club_id, biz_ref):
"""幂等补记;若仅有孤儿幂等日志无日统计,删日志后重试一次。"""
ref = (biz_ref or '').strip()
ok = repair_wechat_income_if_missing(amount, club_id, biz_ref=ref)
if ok:
return True
if PlatformIncomeLog.objects.filter(biz_ref=ref).exists():
PlatformIncomeLog.objects.filter(biz_ref=ref).delete()
return repair_wechat_income_if_missing(amount, club_id, biz_ref=ref)
return False

View File

@@ -0,0 +1,68 @@
"""
用 platform_income_log 重写 daily_income_stat纠正重复记账后的虚高数字。
权威数据源是 platform_income_log每笔支付一条biz_ref 唯一),不是扫订单重算。
"""
from datetime import date, timedelta
from django.core.management.base import BaseCommand
from jituan.services.szjilu_accounting import (
sync_daily_income_stat_for_date,
sync_szjilu_income_from_logs,
)
class Command(BaseCommand):
help = (
'按 platform_income_log 同步 daily_income_stat纠正今日收入虚高/笔数不准)。'
'不要用 repair_wechat_income那个会重复加钱。'
)
def add_arguments(self, parser):
parser.add_argument(
'--date',
type=str,
default='',
help='同步日期 YYYY-MM-DD默认今天',
)
parser.add_argument(
'--days',
type=int,
default=1,
help='从指定日期起向前同步几天,默认 1',
)
parser.add_argument(
'--reset-day',
action='store_true',
help='同步前先删除该日全部日统计行,再按日志重建(纠正重复记账必选)',
)
parser.add_argument(
'--sync-szjilu',
action='store_true',
help='同时按日志全量重算 szjilu 累计收入/今日流水',
)
def handle(self, *args, **options):
if options['date']:
end_day = date.fromisoformat(options['date'])
else:
end_day = date.today()
days = max(1, int(options['days'] or 1))
start_day = end_day - timedelta(days=days - 1)
reset_day = bool(options['reset_day'])
total = 0
for i in range(days):
d = start_day + timedelta(days=i)
n = sync_daily_income_stat_for_date(d, reset_day=reset_day)
total += n
self.stdout.write(f'{d}: 已同步 {n} 个俱乐部日统计行')
if options['sync_szjilu']:
sz = sync_szjilu_income_from_logs()
self.stdout.write(f'szjilu: 已重算 {sz} 个俱乐部累计收入')
self.stdout.write(self.style.SUCCESS(
f'完成。今日收入/笔数现与 platform_income_log 一致。'
f'若仍有漏记支付,请确认 migrate config 后等新支付自动入账。'
))

View File

@@ -1,11 +1,13 @@
"""各俱乐部全局收支流水szjilu统一记账。"""
import logging
from datetime import date
from decimal import Decimal
from django.db import IntegrityError, transaction
from django.db.models import Count, Sum
from django.db.utils import OperationalError, ProgrammingError
from config.models import PlatformIncomeLog, Szjilu
from config.models import DailyIncomeStat, PlatformIncomeLog, Szjilu
from jituan.constants import CLUB_ID_DEFAULT
logger = logging.getLogger(__name__)
@@ -26,6 +28,16 @@ def normalize_szjilu_club_id(club_id):
return (club_id or '').strip() or CLUB_ID_DEFAULT
def income_log_exists(biz_ref):
ref = (biz_ref or '').strip()
if not ref:
return False
try:
return PlatformIncomeLog.objects.filter(biz_ref=ref).exists()
except _PLATFORM_LOG_ERRORS:
return False
def _get_szjilu_for_update(club_id):
cid = normalize_szjilu_club_id(club_id)
szjilu, _ = Szjilu.objects.select_for_update().get_or_create(
@@ -47,7 +59,7 @@ def apply_szjilu_income(amount, club_id=None):
def _apply_income_stats(amount, club_id):
"""写入 daily_income_stat + szjilu核心统计,必须成功)。"""
"""写入 daily_income_stat + szjilu仅在新 platform_income_log 落库后调用)。"""
from orders.utils import update_daily_income
cid = normalize_szjilu_club_id(club_id)
@@ -58,13 +70,15 @@ def _apply_income_stats(amount, club_id):
def record_wechat_income_once(amount, club_id=None, biz_ref=None):
"""
微信入账:同一 biz_ref 只记一次 daily_income_stat + szjilu
返回 True=本次新入账False=已记过(重复回调)。
幂等日志与日统计在同一事务内提交,避免「已支付但未入账」。
微信入账:同一 biz_ref 只记一次。
返回 True=本次新入账False=已记过(重复回调,绝不重复加钱)。
"""
ref = (biz_ref or '').strip()
if not ref:
raise ValueError('biz_ref 不能为空')
if income_log_exists(ref):
return False
cid = normalize_szjilu_club_id(club_id)
jine = Decimal(str(amount))
@@ -77,34 +91,275 @@ def record_wechat_income_once(amount, club_id=None, biz_ref=None):
)
_apply_income_stats(jine, cid)
except IntegrityError:
logger.info('平台入账已存在,跳过重复记账 biz_ref=%s', ref)
return False
except _PLATFORM_LOG_ERRORS as exc:
logger.warning(
'platform_income_log 不可用(%s)跳过幂等直接记入收入 biz_ref=%s',
'platform_income_log 不可用(%s)无法幂等入账 biz_ref=%s,请 migrate config 后执行 sync_daily_income_from_log',
exc, ref,
)
_apply_income_stats(jine, cid)
return True
return False
except Exception as exc:
logger.error(
'platform_income_log 入账异常,仍尝试记入收入 biz_ref=%s err=%s',
'platform_income_log 入账失败 biz_ref=%s err=%s',
ref, exc, exc_info=True,
)
_apply_income_stats(jine, cid)
return True
return False
logger.info('平台入账记账成功 biz_ref=%s club=%s amount=%s', ref, cid, jine)
return True
def repair_wechat_income_if_missing(amount, club_id=None, biz_ref=None):
"""已支付订单/充值单重复回调时安全补记收支(幂等)"""
"""已支付单重复回调时补记;日志已存在则绝不重复加钱"""
return record_wechat_income_once(amount, club_id, biz_ref)
def sync_daily_income_stat_for_date(stat_date, reset_day=False):
"""
用 platform_income_log 重写指定日期的 daily_income_stat金额/笔数与日志完全一致)。
reset_day=True 时先删掉该日所有日统计行,再按日志重建(纠正重复记账)。
返回更新的俱乐部行数。
"""
if reset_day:
DailyIncomeStat.objects.filter(date=stat_date).delete()
try:
return record_wechat_income_once(amount, club_id, biz_ref)
except Exception as exc:
logger.error('补记收支失败 biz_ref=%s err=%s', biz_ref, exc, exc_info=True)
return False
log_qs = PlatformIncomeLog.objects.filter(CreateTime__date=stat_date)
except _PLATFORM_LOG_ERRORS as exc:
logger.error('platform_income_log 不可用: %s', exc)
return 0
club_ids = list(
log_qs.values_list('club_id', flat=True).distinct()
)
updated = 0
y, m, d = stat_date.year, stat_date.month, stat_date.day
with transaction.atomic():
for cid in club_ids:
agg = log_qs.filter(club_id=cid).aggregate(
total_amount=Sum('amount'),
total_count=Count('id'),
)
amt = agg['total_amount'] or _ZERO
cnt = int(agg['total_count'] or 0)
stat, _ = DailyIncomeStat.objects.select_for_update().get_or_create(
date=stat_date,
club_id=cid,
defaults={
'year': y,
'month': m,
'day': d,
'total_amount': amt,
'total_count': cnt,
},
)
stat.year = y
stat.month = m
stat.day = d
stat.total_amount = amt
stat.total_count = cnt
stat.save(update_fields=['year', 'month', 'day', 'total_amount', 'total_count'])
updated += 1
return updated
def sync_szjilu_income_from_logs():
"""按 platform_income_log 全量重算各俱乐部 szjilu 累计收入/流水(纠正重复累加)。"""
try:
rows = PlatformIncomeLog.objects.values('club_id').annotate(
total=Sum('amount'),
cnt=Count('id'),
)
except _PLATFORM_LOG_ERRORS as exc:
logger.error('platform_income_log 不可用: %s', exc)
return 0
today = date.today()
updated = 0
with transaction.atomic():
for row in rows:
cid = normalize_szjilu_club_id(row['club_id'])
total = row['total'] or _ZERO
today_agg = PlatformIncomeLog.objects.filter(
CreateTime__date=today, club_id=cid,
).aggregate(t=Sum('amount'))
today_flow = today_agg['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'])
updated += 1
return updated
# 订单已付款且非退款repair 错误地把 Status=5 退款单也计入了收入)
_ORDER_INCOME_STATUSES = [1, 2, 3, 4, 6, 7, 8]
# 微信充值类 czjilu会员/押金/积分/商家/罚单/考核
_CZ_WECHAT_LEIXING = [1, 2, 3, 4, 5]
def collect_canonical_wechat_income_for_date(stat_date):
"""
按真实支付时间UpdateTime收集当日微信收入每笔 biz_ref 唯一。
返回 [(biz_ref, club_id, amount, kind), ...]
"""
from datetime import datetime, timedelta
from orders.models import Order
from products.models import Czjilu
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(
UpdateTime__gte=day_start,
UpdateTime__lt=day_end,
Status__in=_ORDER_INCOME_STATUSES,
):
_add(f'order:{od.OrderID}', getattr(od, 'ClubID', None), od.Amount, 'order')
for cz in Czjilu.query.filter(
UpdateTime__gte=day_start,
UpdateTime__lt=day_end,
zhuangtai=3,
leixing__in=_CZ_WECHAT_LEIXING,
):
_add(f'cz:{cz.dingdan_id}', getattr(cz, 'club_id', None), cz.jine, f'cz_{cz.leixing}')
return items
def recover_income_stats_for_date(stat_date, dry_run=True):
"""
撤销 repair_wechat_income 造成的重复记账:
1) 删除该日 platform_income_log
2) 按真实支付重建日志(每笔唯一)
3) 重写 daily_income_stat
4) 重算 szjilu 累计收入
返回 dict 供命令行展示。
"""
from datetime import datetime
from django.db.models import Sum
before_daily = DailyIncomeStat.objects.filter(date=stat_date).aggregate(
amt=Sum('total_amount'),
cnt=Sum('total_count'),
)
before_daily_by_club = {
row.club_id: {
'amt': Decimal(str(row.total_amount or 0)),
'cnt': int(row.total_count or 0),
}
for row in DailyIncomeStat.objects.filter(date=stat_date)
}
before_log_cnt = 0
before_log_amt = _ZERO
try:
before_log_cnt = PlatformIncomeLog.objects.filter(CreateTime__date=stat_date).count()
agg = PlatformIncomeLog.objects.filter(CreateTime__date=stat_date).aggregate(
t=Sum('amount'),
)
before_log_amt = agg['t'] or _ZERO
except _PLATFORM_LOG_ERRORS:
pass
items = collect_canonical_wechat_income_for_date(stat_date)
rebuild_amt = sum((x[2] for x in items), _ZERO)
rebuild_cnt = len(items)
result = {
'date': str(stat_date),
'before_daily_amount': float(before_daily['amt'] or 0),
'before_daily_count': int(before_daily['cnt'] or 0),
'before_log_amount': float(before_log_amt),
'before_log_count': before_log_cnt,
'after_amount': float(rebuild_amt),
'after_count': rebuild_cnt,
'items': items,
'dry_run': dry_run,
}
if dry_run:
return result
from datetime import time as dt_time
from django.utils import timezone
pay_ts = timezone.make_aware(datetime.combine(stat_date, dt_time(12, 0, 0)))
with transaction.atomic():
try:
PlatformIncomeLog.objects.filter(CreateTime__date=stat_date).delete()
except _PLATFORM_LOG_ERRORS as exc:
raise RuntimeError(f'platform_income_log 不可用: {exc}') from exc
DailyIncomeStat.objects.filter(date=stat_date).delete()
for ref, cid, jine, _kind in items:
log = PlatformIncomeLog.objects.create(
biz_ref=ref,
club_id=cid,
amount=jine,
)
PlatformIncomeLog.objects.filter(pk=log.pk).update(CreateTime=pay_ts)
y, m, d = stat_date.year, stat_date.month, stat_date.day
by_club = {}
for ref, cid, jine, _kind in items:
if cid not in by_club:
by_club[cid] = {'amt': _ZERO, 'cnt': 0}
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=y,
month=m,
day=d,
total_amount=agg['amt'],
total_count=agg['cnt'],
)
# 只扣掉 repair 多记的部分,不用日志全量重算 szjilu避免抹掉无日志的历史收入
all_clubs = set(before_daily_by_club.keys()) | set(by_club.keys())
for cid in all_clubs:
old_amt = before_daily_by_club.get(cid, {}).get('amt', _ZERO)
new_amt = by_club.get(cid, {}).get('amt', _ZERO)
excess = old_amt - new_amt
if excess <= 0 and stat_date != date.today():
continue
szjilu = _get_szjilu_for_update(cid)
if excess > 0:
szjilu.TotalIncome -= excess
szjilu.TotalFlow -= excess
if stat_date == date.today():
szjilu.DailyFlow = new_amt
szjilu.save(update_fields=['TotalIncome', 'TotalFlow', 'DailyFlow', 'UpdateTime'])
return result
def apply_szjilu_expense(amount, club_id=None):