云
昨天 9dc4057ee86abf7a69e7d7a8720a41efa8dabdcc
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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
"""User-authorized logical deletion of documents, retaining process master data and audit evidence."""
import sys,json,csv,hashlib
from pathlib import Path
P=Path(__file__).parent
sys.path.insert(0,str(P.parent/'iqc-import-20261012/deps'))
import pymysql
encode=lambda r:json.dumps(r,ensure_ascii=False,default=str,sort_keys=True)
snapshot=json.loads((P/'before-sales-production-delete.json').read_text(encoding='utf8'))
all_tables={r['TABLE_NAME'] for r in snapshot['columns']}
sales=['crm_sale_quotation_product','crm_sale_quotation','crm_contract_product','crm_contract','erp_sale_order_items','erp_sale_order']
production=['mes_pro_work_order_param_record_detail','mes_pro_work_order_param_record','mes_pro_task_pause_record',
 'mes_pro_task_issue','mes_pro_task_assignee','mes_pro_feedback','mes_pro_card_process','mes_pro_card',
 'mes_pro_andon_record','mes_pro_batch_worker','mes_pro_batch','mes_pro_bag_code_replace','mes_pro_bag_code',
 'mes_pro_pallet','mes_pro_task','mes_pro_work_order_bom','mes_pro_work_order_process','mes_pro_work_order',
 'mes_pro_mps_merge_item','mes_pro_mps']
warehouse=['mes_wm_product_issue_line','mes_wm_product_issue','mes_wm_return_issue_line','mes_wm_return_issue',
 'mes_wm_item_consume_line','mes_wm_item_consume','mes_wm_product_produce_line','mes_wm_product_produce']
targets=[t for t in sales+production+warehouse if t in all_tables]
protected=[t for t in snapshot['tables'] if t not in targets]
protected += [t for t in all_tables if t.startswith(('mes_qc_','mdm_item','mes_md_item','mes_wm_material_stock'))]
protected += [t for t in ['crm_customer','mes_md_workstation','mes_wm_batch','mes_wm_transaction','system_operate_log'] if t in all_tables]
protected=sorted(set(protected))
args=dict(host='36.213.150.237',port=10000,user='root',password=sys.stdin.readline().strip(),database='mom-hm',charset='utf8mb4',cursorclass=pymysql.cursors.DictCursor,read_timeout=60)
c=pymysql.connect(**args,autocommit=False)
backups={};protected_hashes={};counts={};schemas={};scopes={}
try:
 with c.cursor() as q:
  q.execute('SELECT DATABASE() db,NOW() now');state=q.fetchone();assert state['db']=='mom-hm';now=state['now']
  q.execute("SELECT GET_LOCK('codex-delete-sales-production-documents',0) locked");assert q.fetchone()['locked']==1
  for table in targets:
   q.execute('SHOW CREATE TABLE `'+table+'`');schemas[table]=q.fetchone()
   assert '`deleted`' in schemas[table]['Create Table']
   scope='deleted=0'+(' AND tenant_id=0' if '`tenant_id`' in schemas[table]['Create Table'] else '')
   scopes[table]=(scope,[])
   q.execute('SELECT * FROM `'+table+'` WHERE '+scope+' ORDER BY id FOR UPDATE');backups[table]=q.fetchall()
  # Posted inventory operations are outside this cleanup; there are currently none in this scope.
  assert all(not backups[t] for t in warehouse if t in backups), 'Inventory documents require separate handling'
  for table in ['crm_contract','crm_sale_quotation','mes_pro_work_order','mes_pro_feedback']:
   assert all(not r.get('process_instance_id') for r in backups[table]), 'Workflow instance requires handling: '+table
  # Ensure finance/after-sales/sales warehouse have no live references into the documents being removed.
  external={'crm_receivable':('contract_id','crm_contract'),'crm_receivable_plan':('contract_id','crm_contract'),
    'crm_invoice':('contract_id','crm_contract'),'after_sale_ticket':('contract_id','crm_contract'),
    'after_sale_return':('sale_order_id','erp_sale_order'),'mes_wm_product_sales':('sale_order_id','erp_sale_order'),
    'mes_wm_return_sales':('sale_order_id','erp_sale_order'),'mes_wm_sales_notice':('sale_order_id','erp_sale_order')}
  for table,(field,parent) in external.items():
   ids=[r['id'] for r in backups[parent]]
   if table not in all_tables or not ids:continue
   q.execute('SELECT COUNT(*) n FROM `'+table+'` WHERE deleted=0 AND `'+field+'` IN ('+','.join('%s' for _ in ids)+')',ids)
   assert q.fetchone()['n']==0, 'External business document reference: '+table
  # Remove only permission/follow-up rows owned by these quotation and contract records.
  quote_ids=[r['id'] for r in backups['crm_sale_quotation']];contract_ids=[r['id'] for r in backups['crm_contract']]
  clauses=[];parameters=[]
  for biz_type,ids in [(9,quote_ids),(5,contract_ids)]:
   if ids:
    clauses.append('(biz_type=%s AND biz_id IN ('+','.join('%s' for _ in ids)+'))');parameters += [biz_type]+ids
  if clauses:
   for table in ['crm_permission','crm_follow_up_record']:
    if table not in all_tables:continue
    scope='deleted=0 AND ('+' OR '.join(clauses)+')';scopes[table]=(scope,parameters)
    q.execute('SHOW CREATE TABLE `'+table+'`');schemas[table]=q.fetchone()
    q.execute('SELECT * FROM `'+table+'` WHERE '+scope+' ORDER BY id FOR UPDATE',parameters);backups[table]=q.fetchall()
  # Attachment links are scoped to the deleted documents; shared physical files are preserved.
  for table in ['storage_attachment','system_storage_attachment']:
   if table not in all_tables:continue
   parts=[];params=[]
   for record_type,parent in [('crm_sale_quotation','crm_sale_quotation'),('crm_contract','crm_contract'),('erp_sale_order','erp_sale_order'),('mes_pro_work_order','mes_pro_work_order')]:
    ids=[r['id'] for r in backups[parent]]
    if ids:parts.append('(record_type=%s AND record_id IN ('+','.join('%s' for _ in ids)+'))');params += [record_type]+ids
   if not parts:continue
   scope='deleted=0 AND ('+' OR '.join(parts)+')';scopes[table]=(scope,params)
   q.execute('SHOW CREATE TABLE `'+table+'`');schemas[table]=q.fetchone()
   q.execute('SELECT * FROM `'+table+'` WHERE '+scope+' ORDER BY id FOR UPDATE',params);backups[table]=q.fetchall()
  for table in protected:
   q.execute('SELECT * FROM `'+table+'` ORDER BY id FOR SHARE');rows=q.fetchall()
   protected_hashes[table]={'rows':len(rows),'sha256':hashlib.sha256(encode(rows).encode('utf8')).hexdigest()}
  backup={'database':'mom-hm','logical_delete_only':True,'at':str(now),'schemas':schemas,'rows':backups,'protected':protected_hashes}
  backup_path=P/'sales-production-documents-deletion-backup.json'
  assert not backup_path.exists(),'This cleanup has already been backed up; inspect the prior result before rerunning'
  backup_path.write_text(encode(backup),encoding='utf8')
  updater='sales-pro-doc-clean-20261013'
  for table,rows in backups.items():
   if not rows:counts[table]=0;continue
   fields=set(rows[0]);updates=['deleted=1'];params=[]
   if 'updater' in fields:updates.append('updater=%s');params.append(updater)
   if 'update_time' in fields:updates.append('update_time=%s');params.append(now)
   ids=[r['id'] for r in rows]
   q.execute('UPDATE `'+table+'` SET '+','.join(updates)+' WHERE deleted=0 AND id IN ('+','.join('%s' for _ in ids)+')',params+ids)
   counts[table]=q.rowcount;assert q.rowcount==len(rows)
   q.execute('SELECT * FROM `'+table+'` WHERE id IN ('+','.join('%s' for _ in ids)+') ORDER BY id',ids);after=q.fetchall()
   assert len(after)==len(rows)
   for old,new in zip(rows,after):
    assert new['deleted'] in (1,True,b'\x01')
    assert all(new[k]==v for k,v in old.items() if k not in {'deleted','updater','update_time'})
  for table,(scope,params) in scopes.items():
   q.execute('SELECT COUNT(*) n FROM `'+table+'` WHERE '+scope,params);assert q.fetchone()['n']==0
  for table,h in protected_hashes.items():
   q.execute('SELECT * FROM `'+table+'` ORDER BY id');rows=q.fetchall()
   assert len(rows)==h['rows'] and hashlib.sha256(encode(rows).encode('utf8')).hexdigest()==h['sha256'], 'Protected data changed: '+table
  c.commit()
  result={'logical_delete_only':True,'deleted_by_table':counts,'total_deleted_rows':sum(counts.values()),
   'production_master_data_preserved':True,'protected_tables_verified':len(protected_hashes),
   'qc_ipqc_preserved':protected_hashes.get('mes_qc_ipqc',{}).get('rows'),
   'backup_file':str(backup_path),'executed_at':str(now)}
  (P/'sales-production-documents-deletion-result.json').write_text(encode(result),encoding='utf8')
  with (P/'销售生产单据清理清单.csv').open('w',encoding='utf-8-sig',newline='') as f:
   w=csv.writer(f);w.writerow(['数据表','逻辑删除条数','处理时间'])
   for t,n in counts.items():w.writerow([t,n,str(now)])
finally:c.close()
# Verify committed state with a fresh connection.
c=pymysql.connect(**args)
try:
 with c.cursor() as q:
  for table,(scope,params) in scopes.items():
   q.execute('SELECT COUNT(*) n FROM `'+table+'` WHERE '+scope,params);assert q.fetchone()['n']==0
  for table,h in protected_hashes.items():
   q.execute('SELECT * FROM `'+table+'` ORDER BY id');rows=q.fetchall()
   assert len(rows)==h['rows'] and hashlib.sha256(encode(rows).encode('utf8')).hexdigest()==h['sha256']
  result['verified_after_commit']=True
  (P/'sales-production-documents-deletion-result.json').write_text(encode(result),encoding='utf8')
  print(encode(result))
finally:c.close()