"""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()