"""Name-only fuzzy candidates. Source scheme is retained but never used to score. Random draws are persisted per product so re-running cannot silently remap rows. Candidates are distinct from usable schemes: scope/type/status checks still apply. """ import json,re,csv,collections,secrets,difflib,unicodedata from pathlib import Path P=Path(__file__).parent src=json.loads((P/'source.json').read_text(encoding='utf8')) db=json.loads((P/'remote-snapshot.json').read_text(encoding='utf8')) def norm(v): s=unicodedata.normalize('NFKC',v or '').lower().replace('桑椹','桑葚').replace('黑枸杞','黑果枸杞') s=re.sub(r'stp-qa-\([a-z]+\)\d+|sop-qc-[a-z]+-\d+','',s) s=re.sub(r'\d{4}s|原料质检方案.*$|原料质量标准.*$|质量标准.*$|半成品内控标准.*$','',s) s=s.replace('洞燕窝','燕窝').replace('洞燕盏','燕窝') s=re.sub(r'出货检验|过程检验|来料检验|检验单|检验|即食|罐头|总统牌|小罐|碗装','',s) s=s.replace('燕窝燕窝','燕窝') s=re.sub(r'\((汁|尊享款|怡碗燕|青源堂|造养青年|自热型|代用茶)\)','',s) return re.sub(r'[^\w\u4e00-\u9fffγ]','',s) def score(a,b): if not a or not b:return 0 if a==b:return 1.0 # Preserve meaningful product forms; ingredient containment stays a weak # candidate and cannot establish applicability for a compound product. if b in a or a in b:return .72+.18*min(len(a),len(b))/max(len(a),len(b)) return difflib.SequenceMatcher(None,a,b).ratio() templates=db['mes_qc_template'] by_id={x['id']:x for x in templates} selection_file=P/'random-selections.json' saved=json.loads(selection_file.read_text(encoding='utf8')) if selection_file.exists() else {} summary=[];plan=[];allcandidates=[] items={str(x['code']):x for x in db['mdm_item']} users={x['nickname'].strip():x for x in db['system_users']} for kind,data in src.items(): typ='3' if kind=='oqc' else '2';codecounts=collections.Counter(x['检验单编号'] for x in data['rows'] if x['检验单编号']) for name in dict.fromkeys(x['检验单名称'] for x in data['rows']): core=norm(name) ranked=sorted([(score(core,norm(t['name'])),t) for t in templates],key=lambda x:(-x[0],x[1]['id'])) best=ranked[0][0] if ranked else 0 # Only candidates close to the best name score; don't choose unrelated # products merely to fill a field. An exact name takes precedence. candidates=[t for v,t in ranked if v>=.67 and v>=best-.04] eligible=[t for t in candidates if t['status']==0 and typ in t['types'].split(',')] pool=eligible or candidates key=kind+'|'+name candidate_ids=[t['id'] for t in pool] old=saved.get(key) if old and old['candidate_ids']==candidate_ids and old['selected_id'] in candidate_ids:pick=by_id[old['selected_id']] else: pick=secrets.choice(pool) if pool else None saved[key]={'candidate_ids':candidate_ids,'selected_id':pick['id'] if pick else None,'method':'同名或近似名称候选随机选择一次并留档;原表质检方案未用于匹配'} info={'kind':kind,'name':name,'core':core,'candidate_ids':candidate_ids,'candidate_names':[t['name'] for t in pool],'selected_id':pick['id'] if pick else None,'selected_name':pick['name'] if pick else None,'score':round(best,4),'eligible':bool(eligible),'count':sum(x['检验单名称']==name for x in data['rows'])} summary.append(info) for v,t in ranked[:5]:allcandidates.append({'kind':kind,'name':name,'template_id':t['id'],'template_name':t['name'],'score':round(v,4),'candidate':t['id'] in candidate_ids,'types':t['types'],'status':t['status']}) for row in [x for x in data['rows'] if x['检验单名称']==name]: issues=[];sourcecode=row['检验单编号'] if not sourcecode:issues.append('检验单编号为空:可在导入时生成,尚未分配') elif codecounts[sourcecode]>1:issues.append('原表编号重复:需确认保留/重新编号规则') if pick: if typ not in pick['types'].split(','):issues.append('候选方案类型仅为来料检验,不支持'+('出货检验' if kind=='oqc' else '过程检验')) if pick['status']!=0:issues.append('候选方案停用') if core!=norm(pick['name']):issues.append('候选名称近似但产品形态/配方不同,待确认适用范围') else:issues.append('无达到名称匹配阈值的方案') item=None if kind=='oqc': ic=row['产品物料编码'] if ic is None:issues.append('产品物料编码为空') else: item=items.get(str(ic)) if not item:issues.append('产品物料编码在库中不存在:'+str(ic)) elif item['status']!=0:issues.append('物料停用') if not row['客户名称']:issues.append('客户名称为空;无出货单关联数据可反查') if row['本次出货数量'] is None:issues.append('本次出货数量为空') else: if not row['生产工单号']:issues.append('生产工单号为空;库中无工单记录') if not row['工位名称']:issues.append('工位名称为空;库中无工位记录') if row['合格品数量'] is None:issues.append('合格品数量为空') if row['不合格品数量'] is None:issues.append('不合格品数量为空') if row['检测数量'] is not None and row['合格品数量'] is not None and row['不合格品数量'] is not None and row['检测数量']!=row['合格品数量']+row['不合格品数量']:issues.append('检测数量与合格/不合格数量之和不符') if row['检测人员'].strip() not in users:issues.append('检测人员未匹配') plan.append({'kind':kind,'excel_row':row['excel_row'],'source':row,'mapping':info,'item_id':item['id'] if item else None,'item_name':item['name'] if item else None,'issues':issues,'ready':not issues}) (P/'random-selections.json').write_text(json.dumps(saved,ensure_ascii=False,indent=2),encoding='utf8') (P/'mapping-plan.json').write_text(json.dumps(plan,ensure_ascii=False,indent=2),encoding='utf8') (P/'name-summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2),encoding='utf8') for kind,label in [('oqc','出货检验'),('ipqc','过程检验')]: with (P/(label+'-方案模糊映射及缺项.csv')).open('w',encoding='utf-8-sig',newline='') as f: w=csv.writer(f);w.writerow(['原表行号','检验单编号','检验单名称','原表质检方案(仅留档)','随机候选方案ID','随机候选方案名称','候选数','候选ID列表','名称相似度','方案检验类型','候选方案状态','物料ID','物料名称','导入状态','暂缓原因']) for x in sorted([x for x in plan if x['kind']==kind],key=lambda x:x['excel_row']): r=x['source'];m=x['mapping'];t=by_id.get(m['selected_id']) w.writerow([x['excel_row'],r['检验单编号'],r['检验单名称'],r['质检方案'],m['selected_id'],m['selected_name'],len(m['candidate_ids']),','.join(map(str,m['candidate_ids'])),m['score'],t['types'] if t else '',('启用' if t['status']==0 else '停用') if t else '',x['item_id'],x['item_name'],'可导入' if x['ready'] else '暂缓',';'.join(x['issues'])]) with (P/(label+'-名称候选.csv')).open('w',encoding='utf-8-sig',newline='') as f: w=csv.writer(f);w.writerow(['检验单名称','方案ID','方案名称','相似度','是否随机候选','方案类型','方案状态']) for x in allcandidates: if x['kind']==kind:w.writerow([x['name'],x['template_id'],x['template_name'],x['score'],x['candidate'],x['types'],x['status']]) stats={kind:{'rows':len([x for x in plan if x['kind']==kind]),'ready':sum(x['ready'] for x in plan if x['kind']==kind),'name_candidate_rows':sum(bool(x['mapping']['candidate_ids']) for x in plan if x['kind']==kind),'no_name_candidate_rows':sum(not x['mapping']['candidate_ids'] for x in plan if x['kind']==kind),'multiple_name_candidate_rows':sum(len(x['mapping']['candidate_ids'])>1 for x in plan if x['kind']==kind),'issues':dict(collections.Counter(i for x in plan if x['kind']==kind for i in x['issues']))} for kind in src} (P/'result.json').write_text(json.dumps(stats,ensure_ascii=False,indent=2),encoding='utf8') print(json.dumps(stats,ensure_ascii=False)) for x in summary:print(x['kind'],x['name'],x['selected_name'],x['score'],x['candidate_ids'])