云
11 小时以前 1f6a660180d4e6fccd1ec83a27b79ea93a121817
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
"""Build a reviewable, source-backed migration without inventing measured values."""
import sys,json,re,csv,collections,copy
from pathlib import Path
from decimal import Decimal as D
sys.stdout.reconfigure(encoding='utf-8')
P=Path(__file__).parent;db=json.loads((P/'audit.json').read_text(encoding='utf8'))
def active(t):return [x for x in db[t]['rows'] if x.get('deleted') not in (1,"b'\\x01'")]
def dump(name,v):(P/name).write_text(json.dumps(v,ensure_ascii=False,indent=2,default=str),encoding='utf8')
inds={x['id']:copy.deepcopy(x) for x in active('mes_qc_indicator')};changes=[];review=[];units=set()
def change(t,row,v):
 v={k:z for k,z in v.items() if row.get(k)!=z}
 if v:changes.append({'table':t,'id':row['id'],'before':{k:row.get(k) for k in v},'after':v});row.update(v)
aliases={285:47,409:47}
allinds={x['id']:x for x in db['mes_qc_indicator']['rows']}
for old,new in aliases.items():assert allinds[old]['name']==inds[new]['name']=='大肠菌群'
specs=copy.deepcopy(active('mes_qc_template_indicator'))
for row in specs:
 if row['indicator_id'] in aliases:
  row['_source_unit']=allinds[row['indicator_id']]['unit_text']
  change('mes_qc_template_indicator',row,{'indicator_id':aliases[row['indicator_id']]})
group_names={1:'感官与性状',2:'理化与物性',3:'营养与活性成分',4:'微生物',5:'污染物与重金属',6:'农兽药残留'}
for iid,name in group_names.items():
 row=inds[iid];assert row['item_type']==2
 change('mes_qc_indicator',row,{'name':name,'sort_order':iid*10,'result_type':None,'result_specification':None,'unit_text':None,'formula_text':'按本组各子项的适用标准分别检验和判定;分组标题不录入实测值。'})
micro=re.compile('菌落|大肠|沙门|葡萄球菌|志贺|霉菌|酵母|致病菌|微生物')
pollution=re.compile('^(?:铅|镉|汞|砷|铜|铬|总砷|总汞|无机砷|甲基汞)|二氧化硫|亚硝酸盐|黄曲霉|赭曲|展青霉|米酵菌|苯并|异丙醇|脱氧雪腐|磁性金属|重金属')
sensory=re.compile('性状|色泽|状态|形态|形状|外观|滋味|气味|香气|香味|感官|鉴别|标签|原料和|适用范围|品质|损伤|瑕疵|级别|容许度')
composition=re.compile('含量|蛋白|多糖|总糖|脂肪|黄酮|花青素|膳食|皂苷|氨基酸|虾青素|唾液酸|咖啡因|黄芪|虫草|腺苷|低聚|菊粉|茶氨酸|酚酸|木犀|橙皮|川陈|橘皮|绿原|丹参|阿魏|浸出|挥发油|DHA|总氮')
byind=collections.defaultdict(list)
for s in specs:byind[s['indicator_id']].append(s)
def requirement(s):
 method=s.get('check_method') or ''
 if method.startswith('要求:'):return method[3:].split(';方法:')[0].strip()
 return (s.get('remark') or '').strip()
numeric_units={'%','mg/kg','g/kg','g/100g','mg/100g','g/100mL','mg/mL','CFU/g','CFU/mL','MPN/g','ug/kg','μg/kg','ppm','g/100粒','粒','泡','目','U/g','IU/mg','mPa·s','(°)·dm²·kg⁻¹'}
num=r'[+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?'
def normalized(text):
 text=text.replace('%','%').replace('−','-').replace('℃','°C')
 text=text.replace('ug/kg','μg/kg')
 text=re.sub(r'('+num+r')\s*[×xX*]\s*10\s*\^\s*[((]?([+-]?\d+)[))]?',lambda m:format(D(m[1])*(D(10)**int(m[2])),'f'),text)
 text=re.sub(r'10\s*\^\s*[((]?([+-]?\d+)[))]?',lambda m:format(D(10)**int(m[1]),'f'),text)
 for a,b in [('不得超过','≤'),('不得过','≤'),('不超过','≤'),('不得大于','≤'),('不大于','≤'),('不得低于','≥'),('不低于','≥'),('不少于','≥'),('不小于','≥'),('>=','≥'),('<=','≤')]:text=text.replace(a,b)
 return text
def limits(req):
 s=normalized(req)
 # Multiple grades, sampling plans, strict inequalities, censoring and procedural text need manual interpretation.
 flags=[]
 if re.search('标准品|标准物质|标准储备|试剂',s):return None,None,None,'试剂或标准品条件不能作为产品验收限值'
 if re.search('特优|特级|甲级|乙级|一级|二级|三级|等级|n\\s*=|n=|c\\s*=|c=|鲜果|干果|不得检出|未检出|检出限|每批|分别|溶液',s) or len(s)>170:return None,None,None,'分级、抽样、检出条件或长文本需人工确认'
 if re.search(r'(?<![≤≥])[<>](?!=)',s):return None,None,None,'严格大于/小于条件保留原文'
 matches=re.findall(r'([≤≥])\s*('+num+')',s)
 if len(matches)==1:
  op,value=matches[0];v=D(value);return str(v),str(v) if op=='≥' else None,str(v) if op=='≤' else None,None
 if len(matches)==2 and {x[0] for x in matches}=={'≤','≥'}:
  low=D(next(x[1] for x in matches if x[0]=='≥'));high=D(next(x[1] for x in matches if x[0]=='≤'))
  if low<=high:return str((low+high)/2),str(low),str(high),'标准值为允许区间中心参考值,不替代原文限值'
 if len(matches)>1:return None,None,None,'多个限值不能合并为单一规格'
 m=re.search('('+num+r')\s*[~~至]\s*('+num+')',s)
 if not m:m=re.fullmatch(r'\s*('+num+r')\s*[-—]\s*('+num+r')\s*',s)
 if m:
  low,high=D(m[1]),D(m[2])
  if low<=high:return str((low+high)/2),str(low),str(high),'标准值为允许区间中心参考值,不替代原文限值'
 m=re.search('('+num+r')\s*[±]\s*('+num+')',s)
 if m:
  base,delta=D(m[1]),D(m[2]);return str(base),str(base-delta),str(base+delta),None
 return None,None,None,'原文无可唯一提取的数值限值'
def unit_for(row,req):
 s=normalized(req)
 if re.search('标准品|标准物质|标准储备|试剂',s):return None
 if len(s)>170 or re.search('溶液|仪器|原理|试剂',s):return row.get('unit_text') or None
 if row['name'].startswith('粒度') and '/50g' in s:return '粒/50g'
 for u in sorted(numeric_units,key=len,reverse=True):
  if u in s:return 'μg/kg' if u=='ug/kg' else u
 old=row.get('unit_text') or ''
 if old:return 'μg/kg' if old=='ug/kg' else old
 if re.search('pH|相对密度|折光指数|剪切性能|相对分子质量',row['name']):return '无量纲'
 return None
for row in inds.values():
 if row['item_type']==2:continue
 name=row['name'];parent=row['parent_id']
 if parent not in (407,):
  parent=4 if micro.search(name) else 6 if re.search('农残|农药|兽药|六六六|滴滴涕|毒死蜱|氯联苯|菊酯',name) else 5 if pollution.search(name) else 1 if sensory.search(name) else 3 if composition.search(name) else 2
 reqs=[requirement(s) for s in byind[row['id']]]
 has_simple=any(limits(r)[0] is not None for r in reqs)
 scalar_hint=bool(re.search('含量|花青素|水解度|透光率|溶解度|干湿比|净含量|硫酸灰分|硬度|总膳食|还原糖|pH|相对密度|折光指数|剪切性能',name))
 numeric=(row.get('unit_text') in numeric_units or has_simple or scalar_hint) and (not sensory.search(name) or row.get('unit_text')=='%') and not re.search('续文|沙门氏菌|金黄色葡萄球菌|志贺氏菌|致病菌|农药残留|农残|兽药残留',name)
 rt=2 if numeric and (micro.search(name) or name in ['泡水次数','粒度','颗粒度']) else 1 if numeric else 3
 if rt==2 and (str(row.get('unit_text') or '').startswith('MPN') or any('MPN' in r for r in reqs) or any(str(s.get('_source_unit') or '').startswith('MPN') for s in byind[row['id']])):rt=1
 if rt==2:
  # Preserve recorded decimal measurements instead of rounding or dropping them during the type migration.
  for detail in active('mes_qc_indicator_result_detail'):
   if aliases.get(detail['indicator_id'],detail['indicator_id'])!=row['id'] or not detail['value']:continue
   m=re.match(r'^\s*('+num+r')(?=\s|$)',detail['value'])
   if m and D(m[1])!=D(m[1]).to_integral_value():rt=1;break
 values={'parent_id':parent,'result_type':rt}
 # Numeric fields have no dictionary/file specification; exact standard text belongs to each scheme.
 if rt!=3:values['result_specification']=None
 meaningful=[r for r in reqs if r and not r.startswith('10月8日至14日业务链')]
 if meaningful and all(re.search('标准品|标准物质|标准储备|试剂',r) for r in meaningful):values['unit_text']=None
 if rt in [1,2] and not row.get('unit_text'):
  found={unit_for(row,r) for r in reqs}-{None}
  if len(found)==1:values['unit_text']=next(iter(found))
 change('mes_qc_indicator',row,values)
for gid in list(group_names)+[407]:
 children=sorted([x for x in inds.values() if x['parent_id']==gid],key=lambda x:(x.get('sort_order') or 999999,x['id']))
 for index,row in enumerate(children,1):change('mes_qc_indicator',row,{'sort_order':index*10})
for s in specs:
 row=inds[s['indicator_id']];req=requirement(s)
 if row['item_type']==2:
  v={'standard_value':None,'threshold_min':None,'threshold_max':None,'unit_measure_id':None};change('mes_qc_template_indicator',s,v);continue
 if row['result_type'] in [1,2]:
  std,low,high,note=limits(req)
  # For clone schemes, refer to the source indicator's original scheme when the clone remark is only provenance.
  if not req or req.startswith('10月8日至14日业务链'):
   candidates=[x for x in byind[row['id']] if x['id']!=s['id'] and requirement(x) and not requirement(x).startswith('10月8日至14日业务链')]
   # Clone IDs 285/286 explicitly reference source template 12; raw 284 has no quantitative acceptance threshold.
   if s['template_id'] in [285,286]:candidates=[x for x in candidates if x['template_id']==12]
   else:candidates=[]
   if len(candidates)==1:req=requirement(candidates[0]);std,low,high,note=limits(req)
  unit=unit_for(row,req) or s.get('_source_unit')
  if re.search('标准品|标准物质|标准储备|试剂',req):
   unit=None
   change('mes_qc_template_indicator',s,{'unit_measure_id':None,'remark':'规格待确认:原资料条目属于试剂或标准品条件,不作为产品验收标准;产品限值和检测单位须按适用产品标准确认。'})
  if unit and '或' in unit:unit=None
  if unit:units.add(unit)
  v={'standard_value':std,'threshold_min':low,'threshold_max':high}
  # Unit IDs are allocated by the transaction; symbolic unit names never enter database ID columns.
  if unit:v['_unit_name']=unit
  clean={k:v for k,v in v.items() if k!='_unit_name'};change('mes_qc_template_indicator',s,clean)
  if unit:s['_unit_name']=unit
  else:change('mes_qc_template_indicator',s,{'unit_measure_id':None})
  review.append({'template_id':s['template_id'],'indicator_id':row['id'],'name':row['name'],'type':row['result_type'],'unit':unit,'standard':std,'lower':low,'upper':high,'requirement':req,'note':note})
 else:
  # Qualitative requirements must not carry a leftover quantitative limit.
  change('mes_qc_template_indicator',s,{'standard_value':None,'threshold_min':None,'threshold_max':None,'unit_measure_id':None})
 # Preserve the original acceptance wording and append interpretation information without changing provenance.
 if row['result_type'] in [1,2] and note and std is None:
  prior=s.get('remark') or '';suffix=';规格核对:'+note
  if suffix not in prior and len(prior)+len(suffix)<=255:change('mes_qc_template_indicator',s,{'remark':prior+suffix})
unit_specs=[{'table':'mes_qc_template_indicator','id':s['id'],'unit_name':s['_unit_name']} for s in specs if s.get('_unit_name')]
specmap={(s['template_id'],s['indicator_id']):s for s in specs}
line_updates=[];line_state={}
for kind in ['iqc','ipqc','oqc','rqc']:
 heads={x['id']:x for x in active('mes_qc_'+kind)};rr=copy.deepcopy(active('mes_qc_'+kind+'_line'))
 for row in rr:
  if row['indicator_id'] in aliases:change('mes_qc_'+kind+'_line',row,{'indicator_id':aliases[row['indicator_id']]})
  head=heads.get(row[kind+'_id']);s=specmap.get((head['template_id'],row['indicator_id'])) if head else None
  if s:
   change('mes_qc_'+kind+'_line',row,{'standard_value':s['standard_value'],'min_threshold':s['threshold_min'],'max_threshold':s['threshold_max'],'check_method':s['check_method'],'remark':s['remark']})
   if s.get('_unit_name'):unit_specs.append({'table':'mes_qc_'+kind+'_line','id':row['id'],'unit_name':s['_unit_name']});row['_unit_name']=s['_unit_name']
   else:change('mes_qc_'+kind+'_line',row,{'unit_measure_id':None})
  line_state[kind]=rr
details=copy.deepcopy(active('mes_qc_indicator_result_detail'));unconvertible=[];converted=[]
result_headers={x['id']:x for x in active('mes_qc_indicator_result')}
qc_headers={i:{x['id']:x for x in active('mes_qc_'+k)} for i,k in enumerate(['iqc','ipqc','oqc','rqc'],1)}
def numeric_value(value,rt,expected_unit):
 if value is None or value.strip()=='':return value
 s=normalized(value.strip()).replace(',','')
 m=re.fullmatch('('+num+r')(?:\s*('+'|'.join(re.escape(u) for u in sorted(units,key=len,reverse=True))+'))?',s)
 if not m:return None
 if m[2] and expected_unit:
  equivalent=lambda u:'%' if u in ['%','g/100g'] else u
  if equivalent(m[2])!=equivalent(expected_unit):return None
 v=D(m[1]);
 if rt==2 and v!=v.to_integral_value():return None
 return format(v,'f').rstrip('0').rstrip('.') if '.' in format(v,'f') else format(v,'f')
for row in details:
 if row['indicator_id'] in aliases:change('mes_qc_indicator_result_detail',row,{'indicator_id':aliases[row['indicator_id']]})
 ind=inds.get(row['indicator_id']);old=row['value']
 if not ind or not old:continue
 if ind['item_type']==2:
  value=None;reason='分组标题不录入实测结果'
 elif ind['result_type'] in [1,2]:
  rh=result_headers[row['result_id']];qh=qc_headers[rh['qc_type']].get(rh['qc_id']);s=specmap.get((qh['template_id'],ind['id'])) if qh else None
  expected_unit=s.get('_unit_name') if s else ind.get('unit_text')
  value=numeric_value(old,ind['result_type'],expected_unit);reason='原值不含可转换的实测数值,待补测'
  if value==old:continue
 else:continue
 if value is None:
  note='原记录值:'+old+';'+reason
  prior=row['remark'] or ''
  if len(prior)+len(note)+1<=255:newremark=(prior+';' if prior else '')+note
  else:newremark=prior # Full originals are also retained in the backup and review list.
  change('mes_qc_indicator_result_detail',row,{'value':None,'remark':newremark});unconvertible.append({'id':row['id'],'indicator_id':ind['id'],'name':ind['name'],'old_value':old,'reason':reason})
 else:change('mes_qc_indicator_result_detail',row,{'value':value});converted.append(row['id'])
plan={'changes':changes,'unit_assignments':unit_specs,'units':sorted(units),'indicators':list(inds.values()),'specs':specs,'line_state':line_state,'details':details,'standards_review':review,'unconvertible':unconvertible,'converted':converted}
dump('plan.json',plan)
with (P/'方案规格核对.csv').open('w',encoding='utf-8-sig',newline='') as f:
 writer=csv.DictWriter(f,fieldnames=list(review[0]));writer.writeheader();writer.writerows(review)
dump('待补测结果.json',unconvertible)
print(json.dumps({'changes':dict(collections.Counter(x['table'] for x in changes)),'result_types':dict(collections.Counter(str(x['result_type']) for x in inds.values())),'units':sorted(units),'unconvertible':len(unconvertible),'converted':len(converted),'specs_review':len(review),'unique_numeric_specs':sum(x['standard'] is not None for x in review)},ensure_ascii=False))