云
昨天 74b0d9c5ab1dc316b29f8525dcffe45225d4c43c
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
"""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'])