"""Reviewed flowcharts + source paragraphs -> native MES draft route plan.""" import json,re,csv,collections,unicodedata from pathlib import Path P=Path(__file__).parent docs=json.loads((P/'documents.json').read_text(encoding='utf8')) s=json.loads((P/'remote-snapshot.json').read_text(encoding='utf8')) D={d['id']:d for d in docs} unique=[];seen={};duplicates=[] for d in docs: if d['sha256'] in seen:duplicates.append({'duplicate':d['id'],'retained':seen[d['sha256']],'name':d['name']});continue seen[d['sha256']]=d['id'];unique.append(d) assert len(unique)==24 for d in unique: d['document_code']=next(p.strip() for p in d['paragraphs'][:12] if re.fullmatch(r'STP-[A-Z]+-\d+-\d+',p.strip())) d['product']=re.sub(r'\s+','',d['paragraphs'][1]).replace('生产工艺规程','') d['specification_text']=';'.join(p for p in d['paragraphs'][:45] if '规格' in p or 'g(' in p or '克/' in p or 'ml(' in p) ALIASES={ '原料验收':['原料验收','原辅料验收','原料采购'], '领料脱包':['出库解包','出库脱包','脱包','原料的整理','原辅料的整理','原料领料','原辅料领料'], '称量':['称量','称重'], '风选':['风选'], '静电除毛':['静电除毛'], '金属探测':['金属探测'], '人工选别':['人工选别','人工挑选','人工精选','挑选','去杂'], '微波杀菌':['微波杀菌'], '冷却':['静置冷却','冷却'], '内包装':['内包装','内包','包装操作','封口','喷码'], '外包装':['外包装','外包'], '成品检验':['成品检验','成品检测','成品检验员取样'], '成品入库':['成品入库','入库操作'], '温水解冻':['温水解冻'], '鼓泡清洗':['鼓泡清洗'], '淋干':['淋干','沥干'], '破碎':['破碎'], '打浆':['打浆'], '预热保温':['预热','预热保温'], '搅拌乳化剪切':['搅拌、乳化剪切','搅拌乳化剪切'], '真空脱气':['真空脱气','脱气:'], '均质':['均质'], '瞬时灭菌':['瞬时灭菌','列管式灭菌','高温瞬时灭菌'], '热灌装':['热灌装','3.5.5灌装','3.3.5灌装','3.4.5灌装','3.6.5灌装'], '水浴灭菌':['水浴灭菌'], '冷却风干':['风干冷却','冷却风干','风干:','风干('], '贴标':['贴标'], '复水':['复水'], '过滤':['过滤'], '混合':['混合操作','混合30','混合时间'], '浸泡':['浸泡'], '高温杀菌':['高温杀菌'], '粉碎':['粉碎'], '过筛':['过筛'], '熟制':['熟制','炒制'], '提取过滤':['提取过滤'], '配制':['配制:','称量、配制','配制操作','加入配制罐'], '高剪切':['高剪切:'], '混合定容':['混合定容'], '定容':['定容:'], '加热':['加热:'], '煮提':['煮提:'], '浓缩':['浓缩:'], '配制高剪切':['配制、高剪切'], '红枸杞原浆制备':['红枸杞原浆榨取'], '黑果枸杞原浆制备':['黑果枸杞原浆获取']} COMMON=['原料验收','领料脱包','称量'] END=['外包装','成品检验','成品入库'] DRY=COMMON+['风选','静电除毛','金属探测','人工选别','微波杀菌','冷却','内包装']+END FRUIT=['人工选别','鼓泡清洗','淋干','破碎','预热保温','打浆'] BLACK=['风选','静电除毛','金属探测','人工选别','复水','破碎','过滤'] LIQUID=['搅拌乳化剪切','真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'] FORMULA=['配制','定容','加热','真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌','冷却风干'] stars={1:['金属探测','微波杀菌'],2:['金属探测','微波杀菌'],3:[],4:['真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'],5:[],7:[],8:['金属探测','微波杀菌'],9:['真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'],10:[],11:['金属探测','微波杀菌'],12:['瞬时灭菌','热灌装','水浴灭菌'],13:['熟制','粉碎','过筛'],14:[],15:['真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'],16:['真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'],17:['真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'],19:['真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'],20:['真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'],21:['真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'],22:['真空脱气','均质','热灌装','水浴灭菌'],23:['煮提','真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'],24:['搅拌乳化剪切','真空脱气','均质','热灌装','水浴灭菌'],25:['真空脱气','均质','瞬时灭菌','热灌装'],26:['真空脱气','均质','热灌装','水浴灭菌']} sequences={1:DRY,2:DRY,3:COMMON+['人工选别','内包装']+END,4:COMMON+['温水解冻','人工选别','鼓泡清洗','淋干','破碎','打浆','预热保温']+LIQUID, 5:COMMON+BLACK+LIQUID,7:COMMON+['混合','内包装']+END,8:DRY,9:COMMON+FRUIT+LIQUID+['冷却风干']+END, 10:COMMON+['浸泡','淋干','高温杀菌','粉碎','过筛','混合','金属探测','内包装']+END, 11:DRY,12:COMMON+['提取过滤','配制','高剪切','混合定容','预热保温','真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌','冷却风干']+END, 13:COMMON+['人工选别','熟制','粉碎','过筛','内包装']+END,14:COMMON+LIQUID, 15:COMMON+['红枸杞原浆制备','黑果枸杞原浆制备']+LIQUID+['冷却风干']+END, 16:COMMON+FORMULA+END,17:COMMON+FORMULA+END,19:COMMON+FRUIT+LIQUID+['冷却风干']+END, 20:COMMON+BLACK+LIQUID+['冷却风干']+END,21:COMMON+FORMULA+END,22:COMMON+FORMULA+END, 23:COMMON+['浸泡','煮提','过滤','浓缩','配制高剪切','定容','真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌']+END, 24:COMMON+LIQUID+['冷却风干']+END,25:COMMON+['配制','真空脱气','均质','瞬时灭菌','热灌装','水浴灭菌'],26:COMMON+FORMULA+END} # Full-resolution diagrams resolve small star markers missed in contact sheets. stars[24].append('瞬时灭菌') stars[25].append('水浴灭菌') stars[26].append('瞬时灭菌') # The rose paste diagram explicitly filters through a 40-mesh screen after preheating. sequences[12].insert(sequences[12].index('真空脱气'),'过滤') assert set(sequences)=={d['id'] for d in unique} exceptions={ 2:['文件名未写版本号,正文文件编号为 STP-DY-007-00;与 SG-001-09 产品类别及杀菌参数不同,保留为独立路线。'], 3:['正文从已冻干的原料开始包装,不添加文件没有规定的冻干加工工序。人工精选来自流程图。'], 7:['混合时间冲突:流程图及质量监控表为40min,操作正文3.2.2.2为30min,参数保留待核实。'], 10:['流程图含浸泡、沥干、高温杀菌(90~110℃、100~180s)、粉碎、过筛、金属探测;正文仅详细说明混合与包装。前处理范围需核实。'], 15:['红枸杞与黑果枸杞为两条独立原料支线,均完成后在搅拌乳化剪切处合流;系统仅支持单工序链,采用两项复合工序保留支线内容,显示顺序不代表物料依次流经两条支线。'], 21:['文档为γ-氨基丁酸营养素饮品(蜜桃味);系统部分产品称γ氨基丁酸酸枣仁饮品(蜜桃味),不能仅凭味型判定是同一配方。'], 23:['原文流程图及正文未列冷却风干;不从其他饮品路线补加。'], 25:['袋装采用冷却风干,瓶装采用贴标;拆成两条路线。'], 12:['流程图在预热保温后标注过40目筛,正文未单列该步骤;已保留过滤工序,详细条件待核实。']} def evidence(d,name): paras=d['paragraphs']; matches=[] for i,p in enumerate(paras): if any(a in p for a in ALIASES[name]) and not re.match(r'^[46]\.\d+',p): if len(p)<9 or re.search('清场|清除|打扫|岗位编号',p):continue if re.search(r'^3\.|^4\.[12]|^5\.[12]|风干冷却:',p) or name in ['成品入库','成品检验']: matches.append({'paragraph':i,'text':p}) if name=='原料验收': for i,p in enumerate(paras): if p.strip() in ['原料验收','原料采购'] and i<150: matches.append({'paragraph':i,'text':';'.join(paras[i:i+5])});break if name in ['红枸杞原浆制备','黑果枸杞原浆制备']: section='3.2' if name=='红枸杞原浆制备' else '3.4' matches=[{'paragraph':i,'text':p} for i,p in enumerate(paras) if re.search(section.replace('.','\\.')+r'\.\d+[^\d.]',p)] return matches routes=[] for d in unique: did=d['id'];variants=['袋装','瓶装'] if did in [4,5,14,25] else [''] for variant in variants: seq=list(sequences[did]) if variant:seq+=['冷却风干' if variant=='袋装' else '贴标']+END assert len(seq)==len(set(seq)),(did,seq) route={'document_id':did,'source_file':d['file'],'source_hash':d['sha256'],'document_code':d['document_code'], 'product':d['product'],'variant':variant,'code':'RT-'+d['document_code']+({'袋装':'-BAG','瓶装':'-BOT'}.get(variant,'')), 'name':d['product']+'工艺路线'+('('+variant+')' if variant else '')+'('+d['document_code']+')', 'status':1,'issues':exceptions.get(did,[]),'steps':[],'product_candidates':[],'binding_issues':[]} # key_flag has production-count semantics, so retain one final production key. # Original CCPs are separately preserved in starred/check_flag/remark. production_key='内包装' if '内包装' in seq else '热灌装' for j,name in enumerate(seq,1): ev=evidence(d,name) star=name in stars[did] notes=['来源:'+d['document_code'],'*原文关键控制点,需质检' if star else '','非倒冲,手动领料'] if name==production_key:notes.append('系统唯一产出关键工序;用于工单产出数量统计') if not ev:notes.append('依据原文流程图;详细条件需核实') route['steps'].append({'sort':j*10,'name':name,'starred':star,'key_flag':name==production_key, 'check_flag':star or name in ['成品检验','金属探测'], 'backflush_flag':False, 'link_type':3,'prepare_time':None,'wait_time':None,'evidence':ev, 'remark':';'.join(n for n in notes if n)}) if did==15: route['branches']={'红枸杞原浆制备':FRUIT,'黑果枸杞原浆制备':BLACK,'merge_at':'搅拌乳化剪切'} if variant and did!=25:route['issues'].append('按正文袋装/瓶装后处理拆分:袋装冷却风干,瓶装贴标。') routes.append(route) def norm(name): n=unicodedata.normalize('NFKC',name).lower().replace('桑椹','桑葚') return re.sub(r'[\s\-—]','',n) mdm={x['id']:x for x in s['mdm_item']} for r in routes: for item in s['mes_md_item']: if item['status']!=0:continue n=norm(item['name']);prod=norm(r['product']) if n==prod or (prod in n and len(n)-len(prod)<18): r['product_candidates'].append({k:item.get(k) for k in ['id','code','name','specification']}|{'mdm_item_type':mdm.get(item['mdm_item_id'],{}).get('item_type')}) # Do not guess package, formulation, item type or remove existing one-route binding. if r['product_candidates']:r['binding_issues'].append('仅列名称候选;待核实规格、包装与物料类型后绑定产品') else:r['binding_issues'].append('系统未找到名称明确匹配的产品;未新建或随机选择产品') masters=collections.OrderedDict() for r in routes: for st in r['steps']: m=masters.setdefault(st['name'],{'name':st['name'],'aliases':ALIASES[st['name']],'sources':[],'contents':[]}) if r['document_code'] not in m['sources']:m['sources'].append(r['document_code']) for ev in st['evidence']: c={'document_code':r['document_code'],**ev} if c not in m['contents']:m['contents'].append(c) if st['starred']:m.setdefault('starred_sources',[]) if st['starred'] and r['document_code'] not in m['starred_sources']:m['starred_sources'].append(r['document_code']) for i,m in enumerate(masters.values(),1): m['code']=f'PR-STP-{i:03d}';m['status']=0 m['attention']='按路线对应的原始规程版本执行;各产品温度、时间、压力不得混用。带*控制点按路线设置需要质检,所有工序非倒冲、手动领料。' plan={'source_files':26,'unique_documents':24,'duplicate_documents':duplicates,'processes':list(masters.values()),'routes':routes, 'scope':'新增去重工序及未启用工艺路线;不修改现有测试路线,不猜测绑定产品,不生成BOM、不启用、不创建工单。', 'flags':'星号全部check_flag=1;key_flag每条路线一个产出工序(内包装或热灌装),原文CCP完整保存;backflush_flag全部0。'} (P/'route-plan.json').write_text(json.dumps(plan,ensure_ascii=False,indent=2),encoding='utf8') with (P/'去重工序清单.csv').open('w',encoding='utf-8-sig',newline='') as f: w=csv.writer(f);w.writerow(['工序编码','工序名称','原文同义名称','来源文件编号','带星号来源','工艺要求']) for m in masters.values():w.writerow([m['code'],m['name'],'、'.join(m['aliases']),'、'.join(m['sources']),'、'.join(m.get('starred_sources',[])),m['attention']]) with (P/'工艺路线与工序明细.csv').open('w',encoding='utf-8-sig',newline='') as f: w=csv.writer(f);w.writerow(['路线编码','路线名称','来源文件','包装','序号','工序名称','原文星号','系统产出关键工序','需要质检','倒冲','原文工艺条件','备注']) for r in routes: for st in r['steps']:w.writerow([r['code'],r['name'],r['document_code'],r['variant'],st['sort'],st['name'],st['starred'],st['key_flag'],st['check_flag'],False,'\n'.join(e['text'] for e in st['evidence']),st['remark']]) with (P/'产品候选及待核实事项.csv').open('w',encoding='utf-8-sig',newline='') as f: w=csv.writer(f);w.writerow(['路线编码','产品名称','候选产品编码','候选名称','规格','物料类型','待核实事项']) for r in routes: for c in r['product_candidates'] or [{}]:w.writerow([r['code'],r['product'],c.get('code'),c.get('name'),c.get('specification'),c.get('mdm_item_type'),';'.join(r['issues']+r['binding_issues'])]) print(json.dumps({'documents':24,'processes':len(masters),'routes':len(routes),'route_steps':sum(len(r['steps']) for r in routes),'starred_steps':sum(st['starred'] for r in routes for st in r['steps']),'check_steps':sum(st['check_flag'] for r in routes for st in r['steps']),'all_backflush':False},ensure_ascii=False)) for r in routes:print(r['code'],' → '.join(st['name']+('*' if st['starred'] else '') for st in r['steps']))