"""Source-backed local import. Default is a transactional rollback rehearsal.""" from audit import connect,dump,P import sys,json,re,math,socket from decimal import Decimal,ROUND_CEILING,ROUND_HALF_UP from datetime import datetime,timedelta,date from collections import defaultdict sys.stdout.reconfigure(encoding='utf-8') D=Decimal;APPLY='--apply' in sys.argv source=json.loads((P/'source.json').read_text(encoding='utf-8')) schema=json.loads((P/'schema.json').read_text(encoding='utf-8')) fields={t:{r['Field']:r for r in rows} for t,rows in schema.items()} TODAY=date(2026,10,15);BASE=datetime(2026,10,15,8,0) inserted=defaultdict(list);updates=[];log=[];links=[];sequences={} NOTE='来源:10月15日电商未缴库计划;按表列订单数量及结算单价执行,未另加税额。' def redis(*args): with socket.create_connection(('127.0.0.1',6379),timeout=5) as sock: f=sock.makefile('rb') def command(parts): data=[str(x).encode() for x in parts];sock.sendall(b'*'+str(len(data)).encode()+b'\r\n'+b''.join(b'$'+str(len(x)).encode()+b'\r\n'+x+b'\r\n' for x in data)) line=f.readline();kind=line[:1];v=line[1:-2] if kind==b'-':raise RuntimeError(v.decode()) if kind==b'$':return None if int(v)<0 else f.read(int(v)+2)[:-2].decode() return int(v) if kind==b':' else v.decode() command(['SELECT',5]);return command(args) def number(table,col,prefix,width,rule=None): q.execute('select `'+col+'` value from `'+table+'` where `'+col+'` like %s',(prefix+'%',)) floor=max([int(r['value'][len(prefix):]) for r in q.fetchall() if r['value'][len(prefix):].isdigit()] or [0]) key=('mes:md:auto_code:'+str(rule)+':20261015') if rule else ('erp:seq_no:' if table=='erp_sale_order' else 'crm:seq_no:')+prefix if key not in sequences:sequences[key]=max(floor,int(redis('GET',key) or 0)) if APPLY: n=redis('EVAL',"local x=tonumber(redis.call('GET',KEYS[1]) or '0'); if x4 or dt.date() in holidays:dt=(dt+timedelta(days=1)).replace(hour=9,minute=0,second=0) return dt def add_hours(start,hours): now=start;remaining=hours*60 while remaining: now=next_work(now);end=now.replace(hour=18,minute=0,second=0);available=int((end-now).total_seconds()/60) if available<=0:now=next_work((now+timedelta(days=1)).replace(hour=9,minute=0,second=0));continue take=min(remaining,available);now+=timedelta(minutes=take);remaining-=take if remaining:now=next_work((now+timedelta(days=1)).replace(hour=9,minute=0,second=0)) return now c=connect() try: with c.cursor() as q: q.execute('select get_lock(%s,10) ok',('ecommerce-production-20261015',));assert q.fetchone()['ok']==1 q.execute('select count(*) n from crm_sale_quotation where deleted=0 and remark like %s',('%来源:10月15日电商未缴库计划%',));assert q.fetchone()['n']==0,'Already imported' assert len(source)==13 and sum(money(r['amount']) for r in source)==D('2062353.18') for r in source:assert money(D(str(r['quantity']))*D(str(r['price'])))==money(r['amount']) a={t:fetch(t) for t in ['mdm_item','mes_md_item','mes_pro_route','mes_pro_route_product','mes_pro_route_process','mes_md_product_bom','mes_pd_process_param_template','mes_pd_process_param_template_detail','mes_md_workstation','mes_md_workstation_machine','mes_dv_machinery','mes_cal_holiday','mes_pro_task']} holidays={r['day'].date() for r in a['mes_cal_holiday'] if r['type']==2} assert not any(TODAY<=d<=date(2026,10,31) for d in holidays) q.execute('select id,name from crm_customer where id=1613 and deleted=0');assert q.fetchone()['name']=='总公司' before_counts={} for t in ['crm_receivable','mes_wm_transaction','mes_wm_material_stock','mes_wm_product_receipt','mes_wm_product_sales']: q.execute('select count(*) n from `'+t+'`');before_counts[t]=q.fetchone()['n'] dump('protected-counts-before.json',before_counts) mdm={r['id']:r for r in a['mdm_item']};mes={r['mdm_item_id']:r for r in a['mes_md_item']};by_code={r['code']:r for r in mdm.values()} units={'盒':9,'袋':3,'瓶':8,'套':64} # New source SKUs use their exact source code. Never substitute a differently sized product. for code,base,name,spec,unit in [('12100271',538,'黑果枸杞','90克',8),('UC000017',965,'雪菊','50g',8),('U2100282',501,'桑椹原浆','500ml(50ml×10)',9)]: if code in by_code:continue q.execute('select greatest((select max(id) from mdm_item),(select max(id) from mes_md_item))+1 id');pid=q.fetchone()['id'] parent=mdm[base];mid=ins('mdm_item',{'id':pid,'code':code,'name':name,'specification':spec,'category_id':parent['category_id'],'unit_measure_id':unit,'item_type':3,'status':0,'is_batch_managed':1,'is_serial_managed':0,'high_value':0,'warehouse_id':parent.get('warehouse_id'),'remark':'来源10月15日电商未缴库计划;配方及投料BOM待工艺维护,不套用其他规格配方。'},244) base_mes=mes[base];v={k:base_mes[k] for k in ['category_id','item_type','status'] if k in base_mes} ins('mes_md_item',{'id':pid,'mdm_item_id':pid,'code':code,'name':name,'specification':spec,'unit_measure_id':unit,**v,'remark':'来源10月15日电商未缴库计划'},244) mdm[pid]={**parent,'id':pid,'code':code,'name':name,'specification':spec,'unit_measure_id':unit};mes[pid]={'id':pid,'mdm_item_id':pid,'code':code,'name':name,'specification':spec,'unit_measure_id':unit};by_code[code]=mdm[pid] # Snow chrysanthemum needs a packaging flow, not the unrelated berry processing recipe. flower=next((r for r in a['mes_pro_route'] if r['code']=='RT-FLOWER-BOTTLE-PACK'),None) if flower:flower_id=flower['id'] else: flower_id=ins('mes_pro_route',{'code':'RT-FLOWER-BOTTLE-PACK','name':'花类瓶装分装包装工艺路线','status':0,'remark':'用于50g雪菊分装包装;工序沿用现有基础工序,不新增配方参数。'}) for n,process in enumerate([1,2,3,10,11,12,13]): original=next(r for r in a['mes_pro_route_process'] if r['process_id']==process) data={k:original.get(k) for k in ['param_template_id','yield_rate','color_code']} data.update(route_id=flower_id,process_id=process,sort=(n+1)*10,next_process_id=([1,2,3,10,11,12,13][n+1] if n<6 else None),link_type=3,key_flag=int(process==10),check_flag=int(process==12),backflush_flag=0) rid=ins('mes_pro_route_process',data);a['mes_pro_route_process'].append({'id':rid,**data}) assignments=[('Q2100202',23,20,0),('Q2100201',20,23,0),('U2100335',56,27,0),('U2100336',56,21,0),('Q2100197',57,22,1),('Q2100073',13,21,2),('Q2100314',18,30,1),('Q2100077',13,26,2),('Q2100080',36,19,1),('Q2100078',13,28,2),('12100271',13,29,2),('UC000017',flower_id,30,2),('U2100282',17,16,3)] planned_start_days=[15,20,22,19,20,19,21,22,16,26,27,28,16] groups=['营养保健','原浆供货','枸杞及雪菊','桑椹原浆'] grouped=defaultdict(list) for i,(code,route,day,group) in enumerate(assignments): row=source[i];item=by_code[code];pid=item['id'];mid=mes[pid]['id'];unit=units[row['unit']] # Packaging counts are 1 source finished package; preserve historical document units. if item['unit_measure_id']!=unit: assert item['unit_measure_id']==24 change('mdm_item',pid,{'unit_measure_id':unit});change('mes_md_item',mid,{'unit_measure_id':unit});item['unit_measure_id']=unit;mes[pid]['unit_measure_id']=unit for table,rid in [('mdm_item',pid),('mes_md_item',mid)]: if (table=='mdm_item' and (item.get('name')!=row['name'] or item.get('specification')!=row['spec'])): change(table,rid,{'name':row['name'],'specification':row['spec']}) elif table=='mes_md_item' and (mes[pid].get('name')!=row['name'] or mes[pid].get('specification')!=row['spec']):change(table,rid,{'name':row['name'],'specification':row['spec']}) row={**row,'mdm_id':pid,'mes_id':mid,'route':route,'unit_id':unit,'due_date':datetime(2026,10,day,18),'planned_start':datetime(2026,10,planned_start_days[i],9),'group':group};grouped[group].append(row) if not any(r['item_id']==mid and r['route_id']==route for r in a['mes_pro_route_product']):ins('mes_pro_route_product',{'route_id':route,'item_id':mid,'remark':'按10月15日计划产品配置;配方及投料沿用产品自身BOM,不套用其他规格。'}) group_refs={} for group,rows in grouped.items(): amount=sum(money(r['amount']) for r in rows);count=sum(D(str(r['quantity'])) for r in rows) quote_no=number('crm_sale_quotation','no','QT20261015',6) quote=ins('crm_sale_quotation',{'no':quote_no,'name':'总公司10月'+groups[group]+'供货报价','customer_id':1613,'owner_user_id':291,'status':10,'quotation_time':BASE+timedelta(minutes=5+group),'valid_until':datetime(2026,10,30,18),'total_product_price':amount,'discount_percent':0,'discount_price':0,'total_price':amount,'tax_price':0,'remark':NOTE},291,BASE+timedelta(minutes=5+group)) contract_no=number('crm_contract','no','HT20261015',6) contract=ins('crm_contract',{'no':contract_no,'name':'总公司10月'+groups[group]+'供货合同','customer_id':1613,'owner_user_id':291,'audit_status':20,'order_date':BASE+timedelta(minutes=20+group),'start_time':BASE+timedelta(minutes=20+group),'end_time':datetime(2026,11,30,18),'total_product_price':amount,'total_price':amount,'discount_percent':0,'deposit_price':0,'remark':NOTE+' 月结,2026年11月30日回款;未录实际收款。'},291,BASE+timedelta(minutes=20+group)) sale_no=number('erp_sale_order','no','XSDD20261015',6) order=ins('erp_sale_order',{'no':sale_no,'status':20,'customer_id':1613,'contract_id':contract,'contract_no':contract_no,'sale_user_id':291,'order_time':BASE+timedelta(minutes=40+group),'total_count':count,'total_product_price':amount,'total_tax_price':0,'total_price':amount,'discount_percent':0,'discount_price':0,'deposit_price':0,'out_count':0,'out_status':0,'production_status':0,'remark':NOTE+' 未缴库、未发货。'},291,BASE+timedelta(minutes=40+group)) change('crm_contract',contract,{'order_id':order,'order_no':sale_no});change('crm_sale_quotation',quote,{'contract_id':contract,'contract_no':contract_no}) rp=ins('crm_receivable_plan',{'period':1,'customer_id':1613,'contract_id':contract,'owner_user_id':291,'return_time':datetime(2026,11,30,16),'return_type':8,'price':amount,'remind_days':3,'remind_time':datetime(2026,11,27,9),'remark':'总公司10月供货月结,11月30日一次回款;仅建计划,尚未收款。'},291,BASE+timedelta(minutes=25+group)) for biz,bid in [(9,quote),(5,contract),(8,rp)]:ins('crm_permission',{'biz_type':biz,'biz_id':bid,'user_id':291,'level':1},291) for row in rows: value={'item_id':row['mdm_id'],'item_unit_id':row['unit_id'],'item_price':D(str(row['price'])),'count':row['quantity'],'total_price':money(row['amount'])} ins('crm_sale_quotation_product',{'quotation_id':quote,**value,'quotation_price':D(str(row['price'])),'tax_price':0},291,BASE+timedelta(minutes=10+group)) ins('crm_contract_product',{'contract_id':contract,**value,'contract_price':D(str(row['price']))},291,BASE+timedelta(minutes=20+group)) provenance='原项目号:'+str(row['code'])+';'+(row['note'] or '')+(';原进度:'+str(row['due']) if row['due'] else '') line=ins('erp_sale_order_items',{'order_id':order,'product_id':row['mdm_id'],'product_unit_id':row['unit_id'],'product_price':D(str(row['price'])),'count':row['quantity'],'total_price':money(row['amount']),'tax_price':0,'out_count':0,'need_production':1,'remark':provenance},291,BASE+timedelta(minutes=40+group)) wo_code=number('mes_pro_work_order','code','WO_20261015',3,30) wo=ins('mes_pro_work_order',{'code':wo_code,'name':row['name']+'生产工单','type':1,'order_source_type':1,'order_source_code':sale_no,'product_id':row['mes_id'],'quantity':row['quantity'],'quantity_produced':0,'quantity_scheduled':0,'quantity_changed':0,'client_id':1613,'route_id':row['route'],'request_date':row['due_date'],'parent_id':0,'status':1,'auto_finish_flag':1,'is_feedback_approve':0,'remark':'来源10月15日未缴库计划;计划开工'+row['planned_start'].strftime('%m月%d日')+';'+(row['note'] or '待排产;未提前报工')},244,BASE+timedelta(hours=1,minutes=10)) mps_code=number('mes_pro_mps','code','MPS20261015',3,65) mps=ins('mes_pro_mps',{'code':mps_code,'status':1,'sale_order_id':order,'sale_order_no':sale_no,'sale_order_item_id':line,'product_id':row['mes_id'],'quantity':row['quantity'],'quantity_produced':0,'unit_measure_id':row['unit_id'],'client_id':1613,'request_date':row['due_date'],'issue_date':BASE+timedelta(hours=1,minutes=10),'work_order_id':wo,'work_order_code':wo_code,'merge_flag':0,'remark':'来源:'+row['sheet']+'第'+str(row['row'])+'行;计划开工'+row['planned_start'].strftime('%m月%d日')+';'+provenance},244,BASE+timedelta(hours=1)) change('erp_sale_order_items',line,{'mps_id':mps}) processes=sorted([r for r in a['mes_pro_route_process'] if r['route_id']==row['route']],key=lambda r:r['sort']) # The two specified semi-finished products need only their remaining packaging stages. if row['note'] and '半成品已生产完毕' in row['note']:processes=[r for r in processes if r['process_id'] in (11,12,13)] assert processes for n,p in enumerate(processes): v={k:p.get(k) for k in ['process_id','param_template_id','yield_rate','color_code','key_flag','check_flag','backflush_flag','output_item_id','prepare_time','wait_time']} for k in ['key_flag','check_flag','backflush_flag']:v[k]=truth(v[k]) v.update(work_order_id=wo,sort=(n+1)*10,link_type=3,next_process_id=processes[n+1]['process_id'] if n+10 for m in resources) capacity=sum(D(str(m['production_efficiency']))*D(m.get('conveyor_belt') or 1) for m in resources) amount=targets[p['process_id']];hours=int((amount/capacity).to_integral_value(rounding=ROUND_CEILING));hours=max(hours,1) for t in existing: if t['workstation_id']==station['id'] and t['end_time'] and t['end_time']>start:start=t['end_time'] start=next_work(start);end=add_hours(start,hours);assert end.date()<=date(2026,10,30) code=number('mes_pro_task','code','TK_20261015',3,31) done=D(800) if n==0 else D(0);assert donecurrent['due_date']: current['due_date']=start.replace(hour=18,minute=0,second=0) change('mes_pro_work_order',current['wo_id'],{'request_date':current['due_date']}) change('mes_pro_mps',current['mps_id'],{'request_date':current['due_date']}) current['task_ids']=task_ids assert sum(g['amount'] for g in group_refs.values())==D('2062353.18') for row in links: assert row['due_date'].weekday()<5 and row['planned_start'].weekday()<5 and TODAY<=row['planned_start'].date()<=row['due_date'].date()<=date(2026,10,30) q.execute('select m.quantity,m.unit_measure_id,l.count,l.product_unit_id,l.mps_id from mes_pro_mps m join erp_sale_order_items l on l.id=m.sale_order_item_id where m.id=%s',(row['mps_id'],));v=q.fetchone();assert v['quantity']==v['count']==D(row['quantity']) and v['unit_measure_id']==v['product_unit_id']==row['unit_id'] and v['mps_id']==row['mps_id'] for t,count in before_counts.items():q.execute('select count(*) n from `'+t+'`');assert q.fetchone()['n']==count,(t,'unexpected inventory or receipt change') result={'applied':APPLY,'database':'localhost:3306/mom-hm','total':D('2062353.18'),'groups':group_refs,'counts':{t:len(v) for t,v in inserted.items()},'links':[{k:v for k,v in r.items() if k!='processes'} for r in links],'updates':updates,'sequences':sequences,'protected_counts':before_counts} dump('result-applied.json' if APPLY else 'result-preview.json',result) (P/('applied.sql' if APPLY else 'preview.sql')).write_text('START TRANSACTION;\n'+'\n'.join(log)+'\n'+('COMMIT;' if APPLY else 'ROLLBACK;'),encoding='utf-8') if APPLY:c.commit() else:c.rollback() print(json.dumps({'applied':APPLY,'amount':str(result['total']),'counts':result['counts'],'current_work_order':current['wo_code'],'packaging_quantity':800,'pending_work_orders':12,'missing_bom_products':[r['name'] for r in links if not r['bom_count']]},ensure_ascii=False)) finally: c.rollback() with c.cursor() as q:q.execute('select release_lock(%s)',('ecommerce-production-20261015',)) c.close()