gaoluyang
6 天以前 e40a608ef9bf51db294d8eefada6a8e2e80cebca
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
"""Authorized CRM data cleanup, transaction preview by default."""
import sys,json,re,hashlib,collections
from pathlib import Path
sys.stdout.reconfigure(encoding='utf-8')
sys.path.insert(0,str(Path('scripts/iqc-import-20261012/deps').resolve()))
import pymysql
P=Path(__file__).parent;apply='--apply' in sys.argv
c=pymysql.connect(host='36.213.150.237',port=10000,user='root',password=sys.stdin.readline().strip(),database='mom-hm',charset='utf8mb4',cursorclass=pymysql.cursors.DictCursor,autocommit=False,read_timeout=90)
mutable=['crm_business','crm_business_product','crm_contact_business','crm_follow_up_record','crm_permission','crm_sale_quotation','crm_contract']
def dump(name,v):(P/name).write_text(json.dumps(v,ensure_ascii=False,indent=2,default=str),encoding='utf8')
def fingerprint(v):return hashlib.sha256(json.dumps(v,ensure_ascii=False,sort_keys=True,default=str).encode()).hexdigest()
def is_deleted(v):return v in (1,True,b'\x01')
changes=[];sql_log=[];before={};deleted=[];flow_updates=[]
with c.cursor() as q:
 try:
  q.execute('SELECT GET_LOCK(%s,10)',('crm-business-flow-cleanup',));assert list(q.fetchone().values())==[1]
  q.execute('SELECT * FROM crm_business_status_type WHERE deleted=0');flows=q.fetchall();flowids={x['name']:x['id'] for x in flows};assert {'标准直销流程','电销流程'}<=set(flowids)
  direct=flowids['标准直销流程'];tele=flowids['电销流程']
  q.execute('SELECT * FROM crm_business_status WHERE deleted=0');stages={x['id']:x for x in q.fetchall()}
  ordered={fid:sorted([x for x in stages.values() if x['type_id']==fid],key=lambda x:x['sort']) for fid in [direct,tele]}
  stage_names={fid:{x['name']:x['id'] for x in ordered[fid]} for fid in ordered}
  q.execute('SELECT id,name FROM crm_customer');customer_names={x['id']:x['name'] for x in q.fetchall()}
  for t in mutable:q.execute('SELECT * FROM '+t+' ORDER BY id FOR UPDATE');before[t]=q.fetchall()
  active=[x for x in before['crm_business'] if not x['deleted']]
  deleted=[x['id'] for x in active if '北京同仁堂' in x['name'] or '北京同仁堂' in customer_names.get(x['customer_id'],'')];deleted_set=set(deleted)
  assert len(deleted)==463,('Unexpected cleanup scope; audit again',len(deleted))
  # Preserve all operational tables, customers, prices, products and receivable plans.
  schema=json.loads(Path('scripts/october-chain-20261014/schema-snapshot.json').read_text(encoding='utf8'))
  protected=sorted(set(t for t,s in schema.items() if isinstance(s,dict) and 'columns' in s)-set(mutable)|{'crm_customer','crm_contact','crm_business_status','crm_business_status_type'})
  protected_hashes={}
  for t in protected:q.execute('SELECT * FROM `'+t+'` ORDER BY id');protected_hashes[t]=fingerprint(q.fetchall())
  dump('before-'+('apply' if apply else 'preview')+'.json',{'tables':before,'protected_hashes':protected_hashes,'delete_ids':deleted})
  def update(t,row,v):
   v={k:z for k,z in v.items() if row[k]!=z}
   if not v:return
   sql='UPDATE `'+t+'` SET '+','.join('`'+k+'`=%s' for k in v)+',update_time=update_time WHERE id=%s'
   args=tuple(v.values())+(row['id'],)
   changes.append({'table':t,'id':row['id'],'before':{k:row[k] for k in v},'after':v});sql_log.append(q.mogrify(sql,args)+';')
  # Unbind before deleting. business_id is optional on both document types.
  for t in ['crm_sale_quotation','crm_contract']:
   for row in before[t]:
    if not is_deleted(row['deleted']) and row['business_id'] in deleted_set:update(t,row,{'business_id':None})
  for t in ['crm_business_product','crm_contact_business']:
   for row in before[t]:
    if not row['deleted'] and row['business_id'] in deleted_set:update(t,row,{'deleted':1})
  for row in before['crm_permission']:
   if not row['deleted'] and row['biz_type']==4 and row['biz_id'] in deleted_set:update('crm_permission',row,{'deleted':1})
  for row in before['crm_follow_up_record']:
   if row['deleted']:continue
   v={}
   if row['biz_type']==4 and row['biz_id'] in deleted_set:v['deleted']=1
   value=row['business_ids']
   if value:
    isjson=value.strip().startswith('[');ids=json.loads(value) if isjson else [int(x) for x in value.split(',') if x.strip()]
    kept=[x for x in ids if int(x) not in deleted_set]
    if kept!=ids:v['business_ids']=(json.dumps(kept,separators=(',',':')) if isjson else ','.join(map(str,kept))) if kept else None
   update('crm_follow_up_record',row,v)
  for row in active:
   if row['id'] in deleted_set:update('crm_business',row,{'deleted':1});continue
   text=row['name']+' '+customer_names.get(row['customer_id'],'');target=tele if re.search(r'电商|电子商务|399\s*零号店',text) else direct
   current=stages.get(row['status_id']);stage=row['status_id']
   if not current or current['type_id']!=target:
    if row['end_status']==1:stage=stage_names[target].get('赢单',ordered[target][-1]['id'])
    elif current and current['name'] in stage_names[target]:stage=stage_names[target][current['name']]
    else:
     name=current['name'] if current else ''
     mapping={'初步接洽':'线索初触达','订单报价':'方案递送','赢单':'待签约'} if target==tele else {'线索初触达':'初步接洽','方案递送':'订单报价','待现场洽谈':'订单报价','待签约':'商务谈判'}
     stage=stage_names[target].get(mapping.get(name,''),ordered[target][0]['id'])
   if target!=row['status_type_id'] or stage!=row['status_id']:flow_updates.append({'id':row['id'],'name':row['name'],'flow_id':target,'stage_id':stage,'end_status':row['end_status']})
   update('crm_business',row,{'status_type_id':target,'status_id':stage})
  # Batch identical changes, reducing locks and network round trips for large imported datasets.
  batches=collections.defaultdict(list)
  for change in changes:batches[(change['table'],tuple(change['after'].items()))].append(change['id'])
  for (t,values),ids in batches.items():
   for start in range(0,len(ids),500):
    chunk=ids[start:start+500]
    sql='UPDATE `'+t+'` SET '+','.join('`'+k+'`=%s' for k,v in values)+',update_time=update_time WHERE id IN ('+','.join(['%s']*len(chunk))+')'
    q.execute(sql,tuple(v for k,v in values)+tuple(chunk));assert q.rowcount==len(chunk)
  # Check exact expected rows and unchanged operational tables in the same transaction.
  bykey={(x['table'],x['id']):x['after'] for x in changes}
  for t,rr in before.items():
   q.execute('SELECT * FROM '+t+' ORDER BY id');after=q.fetchall();assert len(after)==len(rr)
   for old,new in zip(rr,after):assert new=={**old,**bykey.get((t,old['id']),{})},(t,old['id'])
  for t,h in protected_hashes.items():q.execute('SELECT * FROM `'+t+'` ORDER BY id');assert fingerprint(q.fetchall())==h,(t,'Unexpected operational change')
  q.execute('SELECT b.*,c.name customer_name,s.type_id FROM crm_business b LEFT JOIN crm_customer c ON c.id=b.customer_id LEFT JOIN crm_business_status s ON s.id=b.status_id AND s.deleted=0 WHERE b.deleted=0');remaining=q.fetchall()
  counts=collections.Counter()
  for row in remaining:
   text=row['name']+' '+(row['customer_name'] or '');assert '北京同仁堂' not in text
   target=tele if re.search(r'电商|电子商务|399\s*零号店',text) else direct
   assert row['status_type_id']==row['type_id']==target;counts[target]+=1
  for t in ['crm_sale_quotation','crm_contract','crm_business_product','crm_contact_business']:
   q.execute('SELECT COUNT(*) n FROM '+t+' x JOIN crm_business b ON b.id=x.business_id WHERE x.deleted=0 AND b.deleted=1 AND b.id IN ('+','.join(['%s']*len(deleted))+')',tuple(deleted));assert q.fetchone()['n']==0,t
  summary={'applied':apply,'deleted_businesses':len(deleted),'remaining_businesses':len(remaining),'tele_businesses':counts[tele],'direct_businesses':counts[direct],'flow_updates':flow_updates,'change_counts':dict(collections.Counter(x['table'] for x in changes)),'changes':changes,'protected_hashes':protected_hashes}
  dump('result-'+('applied' if apply else 'preview')+'.json',summary)
  (P/('applied.sql' if apply else 'preview.sql')).write_text('\n'.join(sql_log),encoding='utf8')
  if apply:c.commit()
  else:c.rollback()
  print(json.dumps({k:v for k,v in summary.items() if k not in ['changes','protected_hashes','flow_updates']},ensure_ascii=False))
 except:
  c.rollback();raise
 finally:q.execute('SELECT RELEASE_LOCK(%s)',('crm-business-flow-cleanup',))
c.close()