gaoluyang
2 天以前 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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
"""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)<n:
  if workday(d):days.append(d)
  d-=timedelta(days=1)
 return list(reversed(days))
changes={};owner={};summary=[];task_times={};qc_times={};doc_events={}
def setvals(t,id,chain,v):
 if (t,id) in owner:assert owner[t,id]==chain,('Shared row',t,id,owner[t,id],chain)
 owner[t,id]=chain
 old=data[t][id]
 for field,value in v.items():
  assert field in old,(t,field)
  if isinstance(value,datetime):
   if dates[t].get(field)=='date':value=value.date()
   value=str(value)
  if old[field]!=value:changes.setdefault((t,id),{})[field]=value
def event(t,id,chain,created,updated=None,**fields):
 assert workday(created.date()) and 9<=created.hour<18 and created<=now,(t,id,created)
 updated=updated or created
 assert created<=updated<=now and workday(updated.date()) and 9<=updated.hour<18
 setvals(t,id,chain,{'create_time':created,'update_time':updated,**fields})
 doc_events[t,id]=created
def children(t,fk,pid,chain,eventtime):
 for r in rows(t):
  if r[fk]==pid:event(t,r['id'],chain,eventtime)
def slots(days):
 out=[]
 for i,d in enumerate(days):
  for h0,m0,h1,m1 in [(9,0,12,0),(13,0,17,30)]:
   start=at(d,h0,m0);end=at(d,h1,m1)
   if i==0:start=max(start,at(d,13,30))
   if i==len(days)-1:end=min(end,at(d,12,30))
   while start<=end:
    if start<end:out.append(start)
    start+=timedelta(minutes=1)
 return out
required=[r for r in rows('erp_sale_order_items') if r['need_production'] in (1,True,"b'\\x01'")]
order_ids={r['order_id'] for r in required};assert len(order_ids)==8
tx_kind={110:'mes_wm_item_receipt',115:'mes_wm_misc_receipt',116:'mes_wm_product_issue',118:'mes_wm_product_receipt',119:'mes_wm_product_sales',123:'mes_wm_item_consume',124:'mes_wm_product_produce'}
for oid in sorted(order_ids):
 o=data['erp_sale_order'][oid];quantity=Decimal(o['total_count']);cycle=2 if quantity<=30 else (4 if oid==1060 else 3) if quantity<=1000 else (10 if quantity>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))