云
2 天以前 bd9c8a5c560083bcb6d0a9c010f56e1f9cf83df6
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
"""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']))