"""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))
|