"""Plan only explicit sales -> MPS -> production -> quality -> warehouse links.""" import json,math from pathlib import Path from datetime import datetime,date,time,timedelta from decimal import Decimal P=Path(__file__).parent s=json.loads((P/'snapshot.json').read_text(encoding='utf8'));tables=s['tables'] rows=lambda t:tables.get(t,{}).get('rows',[]) data={t:{r['id']:r for r in v['rows']} for t,v in tables.items()} dates={t:{c['Field']:c['Type'] for c in v['columns'] if c['Type'].startswith(('date','timestamp'))} for t,v in tables.items()} now=datetime.fromisoformat(s['state']['now']) holiday={date(2026,9,d) for d in range(25,28)}|{date(2026,10,d) for d in range(1,8)} workday=lambda d:d.weekday()<5 and d not in holiday def at(d,h,m=0):return datetime.combine(d,time(h,m)) def backwards(end,n): days=[];d=end while len(days)10000 else 8) end=date(2026,10,13) if oid<1059 else date(2026,10,12) if oid==1059 else date(2026,10,14) days=backwards(end,cycle);start=days[0];order_time=at(start,9,20);close=at(end,13);inbound=at(end,13,30);notice_time=at(end,14);outbound=at(end,16,30) assert outbound<=now and start>=date(2026,9,17) mps=[r for r in rows('mes_pro_mps') if r['sale_order_id']==oid];assert mps wo_ids={r['work_order_id'] for r in mps};assert None not in wo_ids event('erp_sale_order',oid,oid,order_time,outbound,order_time=order_time,production_finish_time=close) children('erp_sale_order_items','order_id',oid,oid,order_time) # User explicitly allowed this single upstream exception to maintain chronology. if oid==1062: ct=data['crm_contract'][o['contract_id']];qt=next(r for r in rows('crm_sale_quotation') if r['contract_id']==ct['id']);b=data['crm_business'].get(ct['business_id']) if b: event('crm_business',b['id'],oid,at(start,9),at(start,9)) children('crm_business_product','business_id',b['id'],oid,at(start,9)) event('crm_sale_quotation',qt['id'],oid,at(start,9,5),quotation_time=at(start,9,5));children('crm_sale_quotation_product','quotation_id',qt['id'],oid,at(start,9,5)) event('crm_contract',ct['id'],oid,at(start,9,10),order_date=at(start,9,10),**({'start_time':at(start,9,10)} if ct['start_time'] else {}));children('crm_contract_product','contract_id',ct['id'],oid,at(start,9,10)) for mp in mps:event('mes_pro_mps',mp['id'],oid,at(start,9,30),close,issue_date=at(start,9,40),request_date=close,finish_date=close) chain_tasks=[] for wid in sorted(wo_ids): event('mes_pro_work_order',wid,oid,at(start,9,45),close,request_date=close,finish_date=close) for t in ['mes_pro_work_order_bom','mes_pro_work_order_process']:children(t,'work_order_id',wid,oid,at(start,9,45)) tasks=sorted([r for r in rows('mes_pro_task') if r['work_order_id']==wid],key=lambda x:(x['start_time'] or x['create_time'],x['id']));assert tasks timeline=slots(days);assert len(timeline)>len(tasks)*20 for j,r in enumerate(tasks): a=int(j*len(timeline)/len(tasks));bnd=int((j+.75)*len(timeline)/len(tasks));bnd=min(bnd,len(timeline)-7) begin=timeline[a];finish=timeline[bnd];output=timeline[bnd+5];inspect=timeline[bnd+2];issue_create=timeline[max(0,a-15)] if a>=15 else at(start,13,10);issue=timeline[max(0,a-10)] if a>=10 else at(start,13,20) task_times[r['id']]={'start':begin,'end':finish,'output':output,'inspect':inspect,'issue':issue,'issue_create':issue_create,'oid':oid} chain_tasks.append(r['id']) event('mes_pro_task',r['id'],oid,begin,output,start_time=begin,end_time=finish,finish_date=output,first_feedback_time=finish,last_feedback_time=finish) children('mes_pro_task_assignee','task_id',r['id'],oid,begin) for f in rows('mes_pro_feedback'): if f['task_id']==r['id']:event('mes_pro_feedback',f['id'],oid,finish,feedback_time=finish) for p in rows('mes_pro_work_order_param_record'): if p['task_id']==r['id']: event('mes_pro_work_order_param_record',p['id'],oid,finish);children('mes_pro_work_order_param_record_detail','record_id',p['id'],oid,finish) # Strong production-document links, including legacy headers without task IDs. for t in ['mes_wm_product_issue','mes_wm_item_consume','mes_wm_product_produce','mes_wm_product_receipt']: for r in rows(t): if r.get('work_order_id') not in wo_ids:continue tid=r.get('task_id');tt=task_times.get(tid) if not tt: same=[x for x in chain_tasks if data['mes_pro_task'][x]['work_order_id']==r['work_order_id']] tt=task_times[same[0] if t in ['mes_wm_product_issue','mes_wm_item_consume'] else same[-1]] if t=='mes_wm_product_issue':created=tt['issue_create'];updated=tt['issue'];fields={'issue_date':updated,'required_time':tt['start']};fk='issue_id' elif t=='mes_wm_item_consume':created=updated=tt['start'];fields={'consume_date':created};fk='consume_id' elif t=='mes_wm_product_produce':created=tt['end'];updated=tt['output'];fields={'produce_date':updated};fk='produce_id' else:created=at(end,13,20);updated=inbound;fields={'receipt_date':inbound};fk='receipt_id' event(t,r['id'],oid,created,updated,**fields) doc_events[t,r['id']]=updated for child in [t+'_line',t+'_detail']:children(child,fk,r['id'],oid,updated) for r in rows('mes_pro_task_issue'): if r['task_id'] in chain_tasks:event('mes_pro_task_issue',r['id'],oid,task_times[r['task_id']]['issue']) # Sales documents are scoped to the selected order, not product-name matches. for t in ['mes_wm_sales_notice','mes_wm_product_sales']: for r in rows(t): if r['sale_order_id']!=oid:continue ct=notice_time if t.endswith('notice') else at(end,14,10) event(t,r['id'],oid,ct,outbound,sales_date=notice_time if t.endswith('notice') else outbound,**({'delivery_date':outbound} if t.endswith('notice') and r.get('delivery_date') else {})) fk='notice_id' if t.endswith('notice') else 'sales_id' for child in [t+'_line',t+'_detail']:children(child,fk,r['id'],oid,ct if child.endswith('_line') else outbound) doc_events[t,r['id']]=outbound sales_ids={id for t,id in owner if t=='mes_wm_product_sales' and owner[t,id]==oid} for r in rows('mes_wm_stock_reserve'): if r['biz_type']==1 and r['biz_id'] in sales_ids:event('mes_wm_stock_reserve',r['id'],oid,at(end,16),outbound) # Include incoming documents only for stocks exclusively consumed in this chain. inputs=set() for t in ['mes_wm_product_issue_detail','mes_wm_item_consume_detail']: inputs|={r['material_stock_id'] for r in rows(t) if owner.get((t,r['id']))==oid} for sid in inputs: movements=[r for r in rows('mes_wm_transaction') if r['material_stock_id']==sid] negatives=[r for r in movements if Decimal(r['quantity'])<0] if not negatives or any(owner.get((tx_kind.get(r['biz_type']),r['biz_id']))!=oid for r in negatives):continue for tx in movements: if tx['biz_type']!=110 or Decimal(tx['quantity'])<=0:continue receipt=data['mes_wm_item_receipt'][tx['biz_id']] event('mes_wm_item_receipt',receipt['id'],oid,at(start,10,50),at(start,11),receipt_date=at(start,11));doc_events['mes_wm_item_receipt',receipt['id']]=at(start,11) for child in ['mes_wm_item_receipt_line','mes_wm_item_receipt_detail']: children(child,'receipt_id',receipt['id'],oid,at(start,11)) for r in rows(child): if r['receipt_id']==receipt['id'] and r.get('production_date'):setvals(child,r['id'],oid,{'production_date':at(start,9)}) if receipt['notice_id']: nid=receipt['notice_id'];event('mes_wm_arrival_notice',nid,oid,at(start,10),arrival_date=at(start,10));children('mes_wm_arrival_notice_line','notice_id',nid,oid,at(start,10)) if receipt['iqc_id']:qc_times['mes_qc_iqc',receipt['iqc_id']]=(oid,at(start,10,30),at(start,10),None) # Direct QC links and their result/header/line relationships. for r in rows('mes_qc_ipqc'): if r['work_order_id'] in wo_ids: tt=task_times.get(r['task_id']) or task_times[next(x for x in chain_tasks if data['mes_pro_task'][x]['work_order_id']==r['work_order_id'])] qc_times['mes_qc_ipqc',r['id']]=(oid,tt['inspect'],None,None) oqc_ids={r['oqc_id'] for r in rows('mes_wm_product_sales_line') if owner.get(('mes_wm_product_sales_line',r['id']))==oid and r['oqc_id']} for r in rows('mes_qc_oqc'): if r['id'] in oqc_ids or (r['source_doc_type']==119 and r['source_doc_id'] in sales_ids):qc_times['mes_qc_oqc',r['id']]=(oid,at(end,15),None,outbound) summary.append({'order_id':oid,'order_no':o['no'],'quantity':str(quantity),'cycle_workdays':cycle,'order_time':str(order_time),'production_finish':str(close),'outbound_time':str(outbound),'calendar_days':(end-start).days+1,'workdays':[str(d) for d in days],'mps_ids':[r['id'] for r in mps],'work_order_ids':sorted(wo_ids),'task_ids':chain_tasks}) for (t,id),(oid,inspect,received,out) in qc_times.items(): vals={'inspect_date':inspect} if received:vals['receive_date']=received if out:vals['out_date']=out event(t,id,oid,inspect,**vals) fk={'mes_qc_iqc':'iqc_id','mes_qc_ipqc':'ipqc_id','mes_qc_oqc':'oqc_id'}[t];children(t+'_line',fk,id,oid,inspect) kind={'mes_qc_iqc':1,'mes_qc_ipqc':2,'mes_qc_oqc':3}[t] for r in rows('mes_qc_indicator_result'): if r['qc_id']==id and r['qc_type']==kind: event('mes_qc_indicator_result',r['id'],oid,inspect);children('mes_qc_indicator_result_detail','result_id',r['id'],oid,inspect) # Transactions must occur at their source document's business event, not creation. for r in rows('mes_wm_transaction'): key=(tx_kind.get(r['biz_type']),r['biz_id']) if key in owner: oid=owner[key];dt=doc_events[key];event('mes_wm_transaction',r['id'],oid,dt,transaction_time=dt,**({'erp_time':dt} if r['erp_time'] else {})) # Retimestamp only dedicated stock/batches; shared opening inventory is protected. def get(t,id,field):return changes.get((t,id),{}).get(field,data[t][id][field]) stock_ids={r['material_stock_id'] for r in rows('mes_wm_transaction') if ('mes_wm_transaction',r['id']) in owner} for sid in stock_ids: movements=[r for r in rows('mes_wm_transaction') if r['material_stock_id']==sid] chainset={owner.get(('mes_wm_transaction',r['id'])) for r in movements} if len(chainset)!=1 or None in chainset:continue oid=next(iter(chainset));stock=data['mes_wm_material_stock'][sid] incoming=[datetime.fromisoformat(get('mes_wm_transaction',r['id'],'transaction_time')) for r in movements if Decimal(r['quantity'])>0] if not incoming:continue first=min(incoming);last=max(datetime.fromisoformat(get('mes_wm_transaction',r['id'],'transaction_time')) for r in movements) event('mes_wm_material_stock',sid,oid,first,last,receipt_time=first,**({'erp_time':first} if stock['erp_time'] else {})) for r in movements: if r['receipt_time']:setvals('mes_wm_transaction',r['id'],oid,{'receipt_time':first}) batchid=stock['batch_id'] if not batchid:continue allstocks=[r for r in rows('mes_wm_material_stock') if r['batch_id']==batchid] if not all(owner.get(('mes_wm_material_stock',r['id']))==oid or r['id']==sid for r in allstocks):continue b=data['mes_wm_batch'][batchid];source=[x for x in rows('mes_wm_product_produce_line') if x['batch_id']==batchid and owner.get(('mes_wm_product_produce_line',x['id']))==oid] produced=doc_events.get(('mes_wm_product_produce',source[0]['produce_id'])) if source else at(datetime.fromisoformat(next(x['order_time'] for x in summary if x['order_id']==oid)).date(),9) # Multiple warehouse stock dimensions of the same batch need its earliest receipt. earliest=min([datetime.fromisoformat(get('mes_wm_material_stock',r['id'],'receipt_time')) for r in allstocks if ('mes_wm_material_stock',r['id']) in owner]+[first]) event('mes_wm_batch',batchid,oid,produced,max(produced,earliest),**({'produce_date':produced} if b['produce_date'] else {}),**({'receipt_date':earliest} if b['receipt_date'] else {})) for (t,id),v in changes.items(): for field,val in v.items(): if field in dates[t] and val is not None: d=datetime.fromisoformat(val) if ' ' in val else datetime.combine(date.fromisoformat(val),time.min) assert workday(d.date()) and d<=now,(t,id,field,val) planned={**data[t][id],**v} assert planned['create_time']<=planned['update_time'] plan={'generated_from':s['state'],'quantity_basis':'销售订单总数量','minimum_quote_date':'2026-09-17','holiday_source':'https://www.beijing.gov.cn/cs/gncs/zcwj/202603/t20260327_4568275.html','upstream_exception_order':1062,'chains':summary,'changes':[{'table':t,'id':id,'order_id':owner[t,id],'before':{k:data[t][id][k] for k in v},'after':v} for (t,id),v in changes.items()]} (P/'plan.json').write_text(json.dumps(plan,ensure_ascii=False,indent=2),encoding='utf8') print(json.dumps({'orders':summary,'changed_rows':len(changes),'changed_tables':len({t for t,id in changes})},ensure_ascii=False))