From 47939042e69c78773e0482de35329aa48aa1a6fa Mon Sep 17 00:00:00 2001 From: XingQue Date: Sat, 25 Jul 2026 11:52:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=BE=99=E5=85=88=E7=94=9F=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E3=80=8C=E5=A8=B1=E4=B9=90=E6=8A=A4=E8=88=AA=E3=80=8D?= =?UTF-8?q?=E4=B8=93=E5=8C=BA=E5=95=86=E5=93=81=E5=A4=8D=E5=88=B6=E5=88=B0?= =?UTF-8?q?=E5=85=B6=E5=AE=83=E5=AE=A1=E6=A0=B8=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅 lxs;只增不减;默认 dry-run,--apply 需 --i-confirm-club=lxs。 Co-authored-by: Cursor --- .../copy_lxs_shenhe_huhang_to_other_types.py | 391 ++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 jituan/management/commands/copy_lxs_shenhe_huhang_to_other_types.py diff --git a/jituan/management/commands/copy_lxs_shenhe_huhang_to_other_types.py b/jituan/management/commands/copy_lxs_shenhe_huhang_to_other_types.py new file mode 100644 index 0000000..4310e19 --- /dev/null +++ b/jituan/management/commands/copy_lxs_shenhe_huhang_to_other_types.py @@ -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。' + ))