feat: 龙先生审核「娱乐护航」专区商品复制到其它审核类型
仅 lxs;只增不减;默认 dry-run,--apply 需 --i-confirm-club=lxs。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,391 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
将龙先生(lxs)审核类型「娱乐护航」下的专区+商品,复制到其它审核专用商品类型。
|
||||||
|
|
||||||
|
背景:全局通常有 3 个 shenhezhuangtai=2 的商品类型;其中「娱乐护航」已有专区/商品,
|
||||||
|
另外两个往往为空。本命令只增不减,把源类型目录镜像到目标类型。
|
||||||
|
|
||||||
|
范围:
|
||||||
|
- 仅 club_id=lxs(Club.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。'
|
||||||
|
))
|
||||||
Reference in New Issue
Block a user