feat: 龙先生审核「娱乐护航」专区商品复制到其它审核类型

仅 lxs;只增不减;默认 dry-run,--apply 需 --i-confirm-club=lxs。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-25 11:52:32 +08:00
parent 12d01614e9
commit 47939042e6

View File

@@ -0,0 +1,391 @@
# -*- coding: utf-8 -*-
"""
将龙先生(lxs)审核类型「娱乐护航」下的专区+商品,复制到其它审核专用商品类型。
背景:全局通常有 3 个 shenhezhuangtai=2 的商品类型;其中「娱乐护航」已有专区/商品,
另外两个往往为空。本命令只增不减,把源类型目录镜像到目标类型。
范围:
- 仅 club_id=lxsClub.name 须含「龙先生」)
- 仅 shenhezhuangtai=2审核专用
- 仅公共目录 dianpu IS NULL
- 源类型:名称精确「娱乐护航」(可用 --source-name 覆盖)
- 目标:除源以外的全部审核类型(或 --target-id 指定)
不碰:正常类型/商品、其它俱乐部、店铺商品、源数据、全局类型表本身。
图片:复用源相对路径(同俱乐部审核素材,不另拷 OSS
用法:
python manage.py copy_lxs_shenhe_huhang_to_other_types
python manage.py copy_lxs_shenhe_huhang_to_other_types --list-leixing
python manage.py copy_lxs_shenhe_huhang_to_other_types --apply --i-confirm-club=lxs
"""
from __future__ import annotations
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
ALLOWED_CLUB_ID = 'lxs'
NAME_HINT = '龙先生'
SHENHE = 2
DEFAULT_SOURCE_NAME = '娱乐护航'
class Command(BaseCommand):
help = '龙先生:把审核类型「娱乐护航」的专区/商品复制到其它审核类型(默认 dry-run'
def add_arguments(self, parser):
parser.add_argument('--apply', action='store_true', help='真正写库')
parser.add_argument(
'--i-confirm-club',
type=str,
default='',
help='必须等于 lxs 才允许 --apply',
)
parser.add_argument(
'--club-id',
type=str,
default=ALLOWED_CLUB_ID,
help='仅允许 lxs',
)
parser.add_argument(
'--source-name',
type=str,
default=DEFAULT_SOURCE_NAME,
help=f'源审核类型全名精确匹配,默认「{DEFAULT_SOURCE_NAME}',
)
parser.add_argument(
'--source-id',
type=int,
default=0,
help='可选:直接指定源 ShangpinLeixing.id须为审核专用且名称校验',
)
parser.add_argument(
'--target-id',
type=int,
action='append',
default=[],
help='可选:只复制到指定审核类型 id可多次传入默认=除源外全部审核类型',
)
parser.add_argument(
'--list-leixing',
action='store_true',
help='只列出审核类型及 lxs 下专区/商品数量',
)
def handle(self, *args, **options):
from jituan.models import Club
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
club_id = (options.get('club_id') or '').strip()
apply = bool(options.get('apply'))
confirm = (options.get('i_confirm_club') or '').strip()
source_name = (options.get('source_name') or DEFAULT_SOURCE_NAME).strip()
if club_id != ALLOWED_CLUB_ID:
raise CommandError(
f'安全拦截:只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}'
)
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
if not club:
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
if NAME_HINT not in (club.name or ''):
raise CommandError(
f'安全拦截:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{NAME_HINT}'
)
audit_types = list(
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
)
if not audit_types:
raise CommandError('库中没有任何审核专用商品类型(shenhezhuangtai=2)')
def count_for(leixing_id):
z = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=leixing_id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=leixing_id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).count()
return z, p
if options.get('list_leixing'):
self.stdout.write(self.style.NOTICE(
f'审核类型清单club={ALLOWED_CLUB_ID} 下专区/商品数):'
))
for lx in audit_types:
zc, pc = count_for(lx.id)
mark = ' <<-- 源候选' if (lx.jieshao or '') == source_name else ''
self.stdout.write(
f' id={lx.id} name={lx.jieshao!r} zones={zc} products={pc}{mark}'
)
return
if apply and confirm != ALLOWED_CLUB_ID:
raise CommandError(
'安全拦截:--apply 必须带 --i-confirm-club=lxs\n'
' python manage.py copy_lxs_shenhe_huhang_to_other_types '
'--apply --i-confirm-club=lxs'
)
# 解析源类型
source_id = int(options.get('source_id') or 0)
if source_id:
source = ShangpinLeixing.query.filter(id=source_id).first()
if not source:
raise CommandError(f'找不到类型 id={source_id}')
if source.shenhezhuangtai != SHENHE:
raise CommandError(f'类型 id={source_id} 不是审核专用')
if (source.jieshao or '') != source_name:
raise CommandError(
f'安全拦截id={source_id} 名称是「{source.jieshao}」,'
f'与要求的「{source_name}」不一致'
)
else:
matches = [lx for lx in audit_types if (lx.jieshao or '') == source_name]
if not matches:
# 宽松:名称包含「娱乐护」且含「航」或等于常见别名
soft = [
lx for lx in audit_types
if '娱乐护' in (lx.jieshao or '')
]
hint = '\n'.join(
f' id={lx.id} name={lx.jieshao!r}' for lx in audit_types
)
raise CommandError(
f'找不到审核类型全名「{source_name}」。当前审核类型:\n{hint}\n'
f'可用 --source-id=... 或 --source-name=... 指定。'
+ (f'\n名称含「娱乐护」的有: {[s.jieshao for s in soft]}' if soft else '')
)
if len(matches) > 1:
raise CommandError(
f'{source_name}」匹配到多条 {[m.id for m in matches]},请 --source-id='
)
source = matches[0]
src_zones_n, src_prods_n = count_for(source.id)
if src_zones_n == 0 and src_prods_n == 0:
raise CommandError(
f'源类型 id={source.id}{source.jieshao}」在 {ALLOWED_CLUB_ID}'
f'没有任何审核专区/商品,无需复制'
)
# 目标类型
target_ids = [int(x) for x in (options.get('target_id') or [])]
if target_ids:
targets = []
for tid in target_ids:
lx = ShangpinLeixing.query.filter(id=tid).first()
if not lx:
raise CommandError(f'目标类型 id={tid} 不存在')
if lx.shenhezhuangtai != SHENHE:
raise CommandError(f'目标类型 id={tid} 不是审核专用,拒绝')
if lx.id == source.id:
raise CommandError('目标不能与源相同')
targets.append(lx)
else:
targets = [lx for lx in audit_types if lx.id != source.id]
if not targets:
raise CommandError('没有可复制的目标审核类型(除源外为空)')
src_zones = list(
ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=source.id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).order_by('paixu', 'id')
)
src_products = list(
Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=source.id,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).order_by('paixu', 'id')
)
mode = 'APPLY' if apply else 'DRY-RUN'
self.stdout.write(self.style.NOTICE(
f'\n===== {mode} 复制审核目录 =====\n'
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
f'源类型: id={source.id}{source.jieshao}'
f'专区={len(src_zones)} 商品={len(src_products)}\n'
f'目标类型({len(targets)}): '
+ ', '.join(f'id={t.id}{t.jieshao}' for t in targets)
+ '\n'
))
for t in targets:
zc, pc = count_for(t.id)
self.stdout.write(f' 目标当前存量 id={t.id}{t.jieshao}」 zones={zc} products={pc}')
self.stdout.write('—— 源专区 ——')
for z in src_zones:
self.stdout.write(f' id={z.id} {z.mingzi!r} paixu={z.paixu}')
self.stdout.write('—— 源商品样例 ——')
for p in src_products[:8]:
self.stdout.write(f' id={p.id} {p.biaoqian!r} ¥{p.jiage} zq={p.zhuanqu_id}')
if len(src_products) > 8:
self.stdout.write(f' ... 其余 {len(src_products) - 8} 个省略')
# 预计算每个目标的 create/skip
preview = []
for t in targets:
z_create = z_skip = p_create = p_skip = 0
for z in src_zones:
exist_z = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=t.id,
mingzi=z.mingzi,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_z:
z_skip += 1
else:
z_create += 1
# 商品去重按:目标类型下 同标题+同专区名(专区可能尚未建,用名推断)
for p in src_products:
src_z = next((x for x in src_zones if x.id == p.zhuanqu_id), None)
zname = src_z.mingzi if src_z else None
exist_zq = None
if zname:
exist_zq = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=t.id,
mingzi=zname,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_zq:
exist_p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=t.id,
zhuanqu_id=exist_zq.id,
biaoqian=p.biaoqian,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_p:
p_skip += 1
else:
p_create += 1
else:
p_create += 1
preview.append((t, z_create, z_skip, p_create, p_skip))
self.stdout.write(
f' 计划 →「{t.jieshao}」: zones +{z_create}/skip{z_skip}, '
f'products +{p_create}/skip{p_skip}'
)
if not apply:
self.stdout.write(self.style.WARNING(
'\n以上仅计划,未写库。确认后:\n'
' python manage.py copy_lxs_shenhe_huhang_to_other_types '
'--apply --i-confirm-club=lxs\n'
))
return
stats = {
'targets': 0,
'zones_created': 0,
'zones_skipped': 0,
'products_created': 0,
'products_skipped': 0,
'products_skip_no_zone': 0,
}
with transaction.atomic():
for t in targets:
stats['targets'] += 1
zone_map = {} # src_zone_id -> dst_zone_id
for z in src_zones:
exist_z = ShangpinZhuanqu.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=t.id,
mingzi=z.mingzi,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_z:
zone_map[z.id] = exist_z.id
stats['zones_skipped'] += 1
continue
row = ShangpinZhuanqu.query.create(
mingzi=z.mingzi,
leixing_id=t.id,
club_id=ALLOWED_CLUB_ID,
tupian_url=z.tupian_url or '',
shenhezhuangtai=SHENHE,
paixu=int(z.paixu or 0),
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
dianpu=None,
dianpu_leixing=None,
)
zone_map[z.id] = row.id
stats['zones_created'] += 1
for p in src_products:
dst_zq = zone_map.get(p.zhuanqu_id)
if not dst_zq:
stats['products_skip_no_zone'] += 1
self.stderr.write(
f'跳过无专区映射商品: {p.biaoqian!r} src_zq={p.zhuanqu_id} →「{t.jieshao}'
)
continue
exist_p = Shangpin.query.filter(
club_id=ALLOWED_CLUB_ID,
leixing_id=t.id,
zhuanqu_id=dst_zq,
biaoqian=p.biaoqian,
shenhezhuangtai=SHENHE,
dianpu__isnull=True,
).first()
if exist_p:
stats['products_skipped'] += 1
continue
Shangpin.query.create(
biaoqian=p.biaoqian,
jiage=p.jiage,
kucun=p.kucun,
leixing_id=t.id,
zhuanqu_id=dst_zq,
zhenshi_xiaoliang=0,
duiwai_xiaoliang=0,
jieshao=p.jieshao,
xiadan_xuzhi=p.xiadan_xuzhi,
guize_tupian=p.guize_tupian or '',
tupian_url=p.tupian_url or '',
yaoqiuleixing=p.yaoqiuleixing,
huiyuan_id=p.huiyuan_id,
yongjin=p.yongjin,
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
paixu=int(p.paixu or 0),
shenhezhuangtai=SHENHE,
shangjia_zhuangtai=bool(p.shangjia_zhuangtai),
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', True)),
club_id=ALLOWED_CLUB_ID,
shi_zhuanpan=False,
dianpu=None,
dianpu_leixing=None,
)
stats['products_created'] += 1
self.stdout.write(self.style.SUCCESS(
f'\n完成: {stats}\n'
f'源「{source.jieshao}」未改动;目标均为审核专用且仅 lxs。'
))