trustbase-seller-backend 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
@@ -201,9 +201,10 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
201
201
  <div class="afield"><label for="a_service_phone">客服电话</label><input id="a_service_phone" placeholder="交易记录向买家展示"></div>
202
202
  <div class="afield"><label>结算规则 (按主体自动确定, 不用选)</label>
203
203
  <div class="arule" id="a_settlement_rule">719 · 个体户通用(0.6% T+1)</div></div>
204
- <div class="afield"><label for="a_settlement_id">所属行业 (微信行业名称)</label>
205
- <input id="a_settlement_filter" placeholder="搜索行业 (如 零售 / 餐饮)" style="margin-bottom:6px">
206
- <select id="a_settlement_id"><option value="">请选择所属行业</option></select></div>
204
+ <div class="afield"><label for="a_industry_cat">所属行业·大类</label>
205
+ <select id="a_industry_cat"></select></div>
206
+ <div class="afield"><label for="a_settlement_id">所属行业·子类 (微信行业名称)</label>
207
+ <select id="a_settlement_id"></select></div>
207
208
  </div>
208
209
  </div>
209
210
 
@@ -214,7 +215,9 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
214
215
  <div class="afield"><label for="a_legal_id_number">法人身份证号</label><input id="a_legal_id_number" placeholder="17 位数字 + 数字或 X"></div>
215
216
  <div class="afield afield-full" id="afield_idaddr" style="display:none"><label for="a_legal_id_address">身份证居住地址 <span class="aopt">企业主体必填, 本机加密</span></label><input id="a_legal_id_address" placeholder="与身份证住址一致, 如: 广东省深圳市南山区xx路xx号"></div>
216
217
  <div class="afield afield-full" id="afield_ubo_note" style="display:none">
217
- <div class="ahint" style="margin-top:0">企业主体:若最终受益人只有法定代表人本人,无需填写受益人信息 (微信自动回填);否则暂不支持,请联系服务商协助进件</div>
218
+ <label for="a_beneficiary">最终受益人</label>
219
+ <select id="a_beneficiary"><option value="legal">法定代表人本人 (默认, 微信自动回填受益人信息)</option><option value="other">他人</option></select>
220
+ <div class="ahint" id="a_beneficiary_hint" style="display:none;margin-top:8px">⚠️ 暂不支持受益人为他人的进件, 请选"法定代表人本人"或联系服务商协助</div>
218
221
  </div>
219
222
  <div class="afield"><label for="a_period_begin">身份证有效期开始</label><input id="a_period_begin" placeholder="YYYY-MM-DD"></div>
220
223
  <div class="afield"><label for="a_period_end">有效期结束</label><input id="a_period_end" placeholder="YYYY-MM-DD 或 长期"></div>
@@ -289,6 +292,7 @@ img.auprev{display:none;width:100%;max-height:140px;object-fit:cover;border-radi
289
292
  <div id="a_up_idback"></div>
290
293
  <div id="a_up_s1"></div>
291
294
  <div id="a_up_s2"></div>
295
+ <div id="a_up_qual_wrap" style="display:none;grid-column:1/-1"><div id="a_up_qual"></div></div>
292
296
  </div>
293
297
  </div>
294
298
 
@@ -650,6 +654,7 @@ function applyFormBody(){
650
654
  account_type:aval('a_account_type'),account_name:aval('a_account_name'),account_number:aval('a_account_number'),
651
655
  account_bank:aval('a_account_bank'),bank_name:aval('a_bank_name'),bank_address_code:aval('a_bank_address_code'),
652
656
  legal_id_address:aval('a_legal_id_address'), // 企业主体必填 (F4), 个体户留空
657
+ qual_media_id:_aMedia.qual_media_id||'', // 特殊资质 (需资质行业必传 → 后端组装 qualifications 数组)
653
658
  sales_scene:scene,
654
659
  license_media_id:_aMedia.license_media_id||'',legal_id_front_media_id:_aMedia.legal_id_front_media_id||'',
655
660
  legal_id_back_media_id:_aMedia.legal_id_back_media_id||''
@@ -818,6 +823,8 @@ function restoreApplyDraft(f){
818
823
  var e=document.getElementById(PREFILL_MAP[k]);
819
824
  if(e&&!e.value) e.value=v;
820
825
  }
826
+ // 所属行业 (二级联动定位)
827
+ syncIndustrySelection(f.qualification_type);
821
828
  // 照片 media_id 复用 → 槽位已传双态
822
829
  PREFILL_MEDIA.forEach(function(k){
823
830
  if(f[k]&&_aSlots[k]) _aSlots[k].prefill(f[k]);
@@ -829,6 +836,18 @@ function restoreApplyDraft(f){
829
836
  // 草稿恢复了编辑态 → 展开表单
830
837
  document.getElementById('apply-form').style.display='block';
831
838
  }
839
+ // 所属行业二级联动选中 (预填共用): 合法行业名称 → 定位大类 + 选中子类; 非法/旧 MCC → 保持默认重选
840
+ function syncIndustrySelection(ind){
841
+ if(!ind||String(ind).indexOf('***')===0||/^\d+$/.test(String(ind))||ind==='无') return;
842
+ if(typeof industrySubsFor!=='function') return; // 增强脚本未就绪 (理论不到这)
843
+ var type2=(document.getElementById('a_subject_type')||{}).value||'SUBJECT_TYPE_INDIVIDUAL';
844
+ var hit=industrySubsFor(type2).filter(function(s){return s.n===ind;})[0];
845
+ if(!hit) return;
846
+ var ce=document.getElementById('a_industry_cat');
847
+ if(ce){ ce.value=hit.cat; if(typeof renderIndustrySubs==='function') renderIndustrySubs(false); }
848
+ var se=document.getElementById('a_settlement_id');
849
+ if(se) se.value=ind;
850
+ }
832
851
  // 任何字段 input/change → 防抖自动保存 (含场景切换时的字段集; file 选择也走 change)
833
852
  (function(){
834
853
  var form=document.getElementById('apply-form');
@@ -845,11 +864,10 @@ var PREFILL_MAP={subject_type:'a_subject_type',merchant_shortname:'a_shortname',
845
864
  legal_id_period_begin:'a_period_begin',legal_id_period_end:'a_period_end',
846
865
  account_type:'a_account_type',account_name:'a_account_name',account_number:'a_account_number',
847
866
  account_bank:'a_account_bank',bank_name:'a_bank_name',bank_address_code:'a_bank_address_code',
848
- qualification_type:'a_settlement_id', // 行业类目 MCC (draft 路径用; snapshot 路径走 mcc 兼容段)
849
867
  store_name:'a_store_name',store_address_code:'a_store_address_code',store_address:'a_store_address',
850
868
  website_url:'a_website_url',appid:'a_appid',appid_type:'a_appid_type'};
851
869
  var PREFILL_MEDIA=['license_media_id','legal_id_front_media_id','legal_id_back_media_id',
852
- 'store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id'];
870
+ 'store_entrance_media_id','store_indoor_media_id','web_home_media_id','web_product_media_id','mp_media_id','qual_media_id'];
853
871
  var _prefilled=false;
854
872
  function prefillApplyForm(snap){
855
873
  if(_prefilled||!snap||typeof snap!=='object') return;
@@ -870,14 +888,9 @@ function prefillApplyForm(snap){
870
888
  var e=document.getElementById(PREFILL_MAP[k]);
871
889
  if(e&&!e.value) e.value=v;
872
890
  }
873
- // 所属行业预填 (第三次字段修正口径): qualification_type=中文行业名称 (封闭集合);
874
- // 旧快照若是 MCC 数字 (5722 等) 或 "无"/"零售" → 识别为非法, 清空让商户重选 (不报错);
875
- // 合法行业名称则回填 (不在当前主体选项集时 select 置空, 商户重选)
876
- var ind=snap.qualification_type;
877
- if(ind && String(ind).indexOf('***')!==0 && !/^\d+$/.test(String(ind)) && ind!=='无' && ind!=='零售'){
878
- var se=document.getElementById('a_settlement_id');
879
- if(se) se.value=ind; // renderIndustries 已按主体建好选项集; 不在集合内 value 置空即"未选"
880
- }
891
+ // 所属行业预填 (二级联动): 合法行业名称 → 定位所属大类并选中子类;
892
+ // 旧快照 MCC 数字/非法值 → 保持默认让商户重选 (静默不报错)
893
+ syncIndustrySelection(snap.qualification_type);
881
894
  // 照片复用: media_id 还在微信侧有效 → 槽位走"已上传"双态 (占位图+替换/删除, 无第二个空框)
882
895
  var reused=0;
883
896
  PREFILL_MEDIA.forEach(function(k){
@@ -1000,51 +1013,117 @@ async function restoreIdentity(){
1000
1013
  ① 编码字段下拉+联动 ② 状态卡片着色 + 签约二维码占位。
1001
1014
  (证件缩略图预览已于二重框修复时并入主脚本 mountApplyUpload 槽位双态) ===== */
1002
1015
  (function(){
1003
- // ⓪ 所属行业下拉 (2026-10-03 第三次字段修正, 微信文档实锤):
1004
- // qualification_type【所属行业】=《费率结算规则对照表》"行业"列的**中文行业名称** (封闭集合),
1005
- // 不是 MCC 数字编码 (填 5722 被微信拒 "所属行业不存在")。行业子项按主体联动 (716/719/727 各自的表)。
1006
- var INDUSTRIES={
1007
- SUBJECT_TYPE_ENTERPRISE:['餐饮','食品生鲜','电信运营商/宽带收费','私立/民营医院/诊所','保健器械/医疗器械/非处方药品',
1008
- '游艺厅/KTV/网吧','机票/机票代理','宠物医院','旅行社','宗教组织','房地产/房产中介','共享服务',
1009
- '文物经营/文物复制品销售','拍卖典当','教育/培训/考试缴费/学费','零售批发/生活娱乐/网上商城/其他'],
1010
- SUBJECT_TYPE_INDIVIDUAL:['餐饮','食品生鲜','私立/民营医院/诊所','保健器械/医疗器械/非处方药品',
1011
- '游艺厅/KTV/网吧','机票/机票代理','宠物医院','教育/培训/考试缴费/学费','零售批发/生活娱乐/其他'],
1012
- SUBJECT_TYPE_OTHERS:['宗教组织','机票/机票代理','私立/民营医院/诊所','咨询/娱乐票务/其他']
1013
- };
1014
- var INDUSTRY_DEFAULT={SUBJECT_TYPE_ENTERPRISE:'零售批发/生活娱乐/网上商城/其他',
1015
- SUBJECT_TYPE_INDIVIDUAL:'零售批发/生活娱乐/其他',SUBJECT_TYPE_OTHERS:'咨询/娱乐票务/其他'};
1016
- // 需要许可证的特殊行业 (选项后加 ⚠️需许可证 提示; value 仍是行业名称原文)
1017
- var INDUSTRY_LICENSE=['餐饮','食品生鲜','私立/民营医院/诊所','保健器械/医疗器械/非处方药品',
1018
- '游艺厅/KTV/网吧','教育/培训/考试缴费/学费','宠物医院','旅行社','拍卖典当','文物经营/文物复制品销售',
1019
- '房地产/房产中介','电信运营商/宽带收费','共享服务'];
1016
+ // ⓪ 所属行业二级联动 (2026-10-03 微信官方网页版实证校准):
1017
+ // qualification_type = 二级子类的名称文本 (如 "零售"), 大类只做导航不上送;
1018
+ // 行业结构 大类>子类, 按主体过滤 (企业全套, 个体户/其他组织子集 — 对照 716/719/727 行业子项);
1019
+ // "所属行业不存在"30+ 次失败的真根因是"服务商不能给自己进件"(业务规则), 行业值 "零售" 一直合法。
1020
+ var INDUSTRY_CATS=[
1021
+ {cat:'餐饮/零售', subs:[
1022
+ {n:'餐饮',lic:1},{n:'零售'},{n:'电商平台',ent:1},{n:'食品生鲜',lic:1},{n:'服饰鞋包'},
1023
+ {n:'文物经营/文物复制品销售',lic:1,ent:1}]},
1024
+ {cat:'交通/加油', subs:[
1025
+ {n:'机票/机票代理',lic:1},{n:'旅行社',lic:1,ent:1},{n:'景区酒店',ent:1}]},
1026
+ {cat:'生活/娱乐', subs:[
1027
+ {n:'宠物医院',lic:1},{n:'共享服务',lic:1,ent:1},{n:'休闲娱乐旅游服务',ent:1},
1028
+ {n:'游艺厅/KTV/网吧',lic:1},{n:'院线影城',lic:1,ent:1},{n:'演出赛事',lic:1,ent:1},
1029
+ {n:'居民生活服务',ent:1},{n:'保健美容SPA',lic:1},{n:'美容理发店'}]},
1030
+ {cat:'互联网服务', subs:[
1031
+ {n:'门户论坛/网络广告及推广/软件开发/其他互联网服务'},{n:'电信运营商/宽带收费',lic:1,ent:1}]},
1032
+ {cat:'房产/金融', subs:[
1033
+ {n:'房地产/房产中介',lic:1,ent:1}]},
1034
+ {cat:'医疗/教育', subs:[
1035
+ {n:'私立/民营医院/诊所',lic:1},{n:'保健器械/医疗器械/非处方药品',lic:1},{n:'教育/培训/考试缴费/学费',lic:1}]},
1036
+ {cat:'其他', subs:[
1037
+ {n:'宗教组织'},{n:'拍卖典当',lic:1,ent:1},{n:'咨询/娱乐票务/其他'}]}
1038
+ ];
1039
+ // 其他组织 (727) 子集 (对照表口径)
1040
+ var INDUSTRY_OTHERS_ALLOW=['宗教组织','机票/机票代理','私立/民营医院/诊所','咨询/娱乐票务/其他'];
1041
+ var INDUSTRY_DEFAULT_SUB={SUBJECT_TYPE_ENTERPRISE:'零售',SUBJECT_TYPE_INDIVIDUAL:'零售',SUBJECT_TYPE_OTHERS:'咨询/娱乐票务/其他'};
1020
1042
  var subj=document.getElementById('a_subject_type');
1021
- var stl=document.getElementById('a_settlement_id');
1022
- function renderIndustries(){
1023
- if(!stl) return;
1024
- var type=(subj&&INDUSTRIES[subj.value])?subj.value:'SUBJECT_TYPE_INDIVIDUAL';
1025
- var list=INDUSTRIES[type];
1026
- var cur=stl.value; // 尽量保留已选 (主体没换集合内同名行业时)
1027
- stl.innerHTML='<option value="">请选择所属行业</option>';
1028
- list.forEach(function(name){
1029
- var o=document.createElement('option');
1030
- o.value=name; // 提交直接带行业名称原文 (含斜杠)
1031
- o.textContent=name+(INDUSTRY_LICENSE.indexOf(name)>=0?' ⚠️需许可证':'');
1032
- stl.appendChild(o);
1043
+ var stl=document.getElementById('a_settlement_id'); // 子类下拉 (提交 id 稳定: value=子类名称 → qualification_type)
1044
+ var catEl=document.getElementById('a_industry_cat');
1045
+ function industrySubsFor(type){
1046
+ var out=[];
1047
+ INDUSTRY_CATS.forEach(function(c){
1048
+ c.subs.forEach(function(s){
1049
+ if(type==='SUBJECT_TYPE_ENTERPRISE'){ out.push({cat:c.cat,n:s.n,lic:s.lic}); }
1050
+ else if(type==='SUBJECT_TYPE_OTHERS'){ if(INDUSTRY_OTHERS_ALLOW.indexOf(s.n)>=0) out.push({cat:c.cat,n:s.n,lic:s.lic}); }
1051
+ else if(!s.ent){ out.push({cat:c.cat,n:s.n,lic:s.lic}); } // 个体户: 非"仅企业"子项
1052
+ });
1033
1053
  });
1034
- stl.value=(list.indexOf(cur)>=0&&cur)?cur:(INDUSTRY_DEFAULT[type]||list[0]);
1054
+ return out;
1035
1055
  }
1036
- if(stl){
1037
- renderIndustries();
1038
- // 搜索过滤 (行业多, 按名称子串过滤)
1039
- var filt=document.getElementById('a_settlement_filter');
1040
- if(filt) filt.addEventListener('input',function(){
1041
- var q=filt.value.trim().toLowerCase();
1042
- Array.prototype.forEach.call(stl.options,function(o){
1043
- if(o.value==='') return;
1044
- o.hidden = !!(q && o.textContent.toLowerCase().indexOf(q)<0);
1045
- });
1056
+ function renderIndustryCats(keepSub){
1057
+ if(!catEl||!stl) return;
1058
+ var type=(subj&&subj.value)||'SUBJECT_TYPE_INDIVIDUAL';
1059
+ var subs=industrySubsFor(type);
1060
+ var cats=[]; subs.forEach(function(s){ if(cats.indexOf(s.cat)<0) cats.push(s.cat); });
1061
+ catEl.innerHTML='';
1062
+ cats.forEach(function(c){ var o=document.createElement('option'); o.value=c; o.textContent=c; catEl.appendChild(o); });
1063
+ catEl.value=cats.indexOf('餐饮/零售')>=0?'餐饮/零售':cats[0];
1064
+ renderIndustrySubs(keepSub);
1065
+ }
1066
+ function renderIndustrySubs(keepSub){
1067
+ var type=(subj&&subj.value)||'SUBJECT_TYPE_INDIVIDUAL';
1068
+ var cat=catEl.value;
1069
+ var subs=industrySubsFor(type).filter(function(s){ return s.cat===cat; });
1070
+ var cur=keepSub?stl.value:'';
1071
+ stl.innerHTML='';
1072
+ subs.forEach(function(s){
1073
+ var o=document.createElement('option');
1074
+ o.value=s.n; // 提交直接带子类名称文本 (qualification_type)
1075
+ o.textContent=s.n+(s.lic?' ⚠️需许可证':'');
1076
+ stl.appendChild(o);
1046
1077
  });
1078
+ var def=INDUSTRY_DEFAULT_SUB[type];
1079
+ stl.value=(subs.some(function(s){return s.n===cur;})&&cur)?cur
1080
+ :(subs.some(function(s){return s.n===def;})?def:(subs[0]?subs[0].n:''));
1081
+ paintQualSlot(); // 行业变化 → 资质槽显隐
1047
1082
  }
1083
+ // 行业 → 特殊资质映射 (微信 qualifications: 对照表"特殊资质"列有值的行业, 选需资质行业必传许可证)
1084
+ var QUAL_REQUIRED={
1085
+ '餐饮':'食品经营许可证 / 餐饮服务许可证 (三选一, 或门头+店内+收银台三张照片)',
1086
+ '食品生鲜':'食品经营许可证',
1087
+ '私立/民营医院/诊所':'医疗机构执业许可证',
1088
+ '保健器械/医疗器械/非处方药品':'药品经营许可证 / 医疗器械经营企业许可证',
1089
+ '教育/培训/考试缴费/学费':'办学许可证',
1090
+ '游艺厅/KTV/网吧':'娱乐场所经营许可证 / 网络文化经营许可证 (网吧)',
1091
+ '院线影城':'电影放映经营许可证',
1092
+ '演出赛事':'营业性演出许可证',
1093
+ '机票/机票代理':'航空公司营业执照 或 代理资格证',
1094
+ '宠物医院':'动物诊疗许可证',
1095
+ '旅行社':'旅行社业务经营许可证',
1096
+ '房地产/房产中介':'房地产五证 (建设用地规划/建设工程规划/施工/预售/产权)',
1097
+ '文物经营/文物复制品销售':'文物经营许可证',
1098
+ '拍卖典当':'拍卖经营批准证书 / 典当经营许可证',
1099
+ '电信运营商/宽带收费':'基础电信业务经营许可证',
1100
+ '共享服务':'对应行业经营许可/备案',
1101
+ '保健美容SPA':'卫生许可证'
1102
+ };
1103
+ var _qualIndustry='';
1104
+ // 选中需资质行业 → 第⑤卡动态出现"特殊资质 (必传)"上传槽 (复用上传槽双态机);
1105
+ // 切回不需资质行业 → 槽隐藏且清空 (不留 media_id 污染)
1106
+ function paintQualSlot(){
1107
+ var wrap=document.getElementById('a_up_qual_wrap');
1108
+ if(!wrap||!stl) return;
1109
+ var ind=stl.value;
1110
+ var doc=QUAL_REQUIRED[ind]||'';
1111
+ if(!doc){
1112
+ wrap.style.display='none';
1113
+ if(_qualIndustry&&_aSlots.qual_media_id) _aSlots.qual_media_id.clear();
1114
+ delete _aMedia.qual_media_id;
1115
+ _qualIndustry='';
1116
+ return;
1117
+ }
1118
+ wrap.style.display='';
1119
+ if(_qualIndustry!==ind){
1120
+ delete _aMedia.qual_media_id; // 换行业=换资质, 清旧 media
1121
+ mountApplyUpload('a_up_qual','qual_media_id','特殊资质 (必传): '+doc);
1122
+ _qualIndustry=ind;
1123
+ }
1124
+ }
1125
+ if(stl){ stl.addEventListener('change',paintQualSlot); }
1126
+ if(catEl){ catEl.addEventListener('change',function(){ renderIndustrySubs(false); }); }
1048
1127
  // 结算规则只读展示 (settlement_id 按主体推导: 企业 716 / 个体户 719 / 其他组织 727; 商户不用选)
1049
1128
  var SETTLE_RULES={SUBJECT_TYPE_INDIVIDUAL:'719 · 个体户通用(0.6% T+1)',
1050
1129
  SUBJECT_TYPE_ENTERPRISE:'716 · 企业通用(0.6% T+1)',SUBJECT_TYPE_OTHERS:'727 · 其他组织通用(0.6% T+1)'};
@@ -1055,14 +1134,44 @@ async function restoreIdentity(){
1055
1134
  paintSettleRule();
1056
1135
  if(subj&&stl){
1057
1136
  subj.addEventListener('change',function(){
1058
- renderIndustries(); // 主体切换 → 行业选项集联动刷新 + 重选默认
1137
+ renderIndustryCats(false); // 主体切换 → 大类/子类选项集联动刷新 + 重选默认
1059
1138
  paintSettleRule();
1139
+ paintAccountLock(); // 修正2: 企业强制对公
1060
1140
  // F4: 企业主体显示身份证居住地址(必填) + UBO 说明; 个体户隐藏
1061
1141
  var ent = subj.value==='SUBJECT_TYPE_ENTERPRISE';
1062
1142
  var ia=document.getElementById('afield_idaddr'); if(ia) ia.style.display=ent?'':'none';
1063
1143
  var un=document.getElementById('afield_ubo_note'); if(un) un.style.display=ent?'':'none';
1064
1144
  });
1065
1145
  }
1146
+ renderIndustryCats(false); // 初始: 大类 餐饮/零售 > 子类 零售
1147
+
1148
+ // 修正2 (微信网页版实证): 企业主体强制对公账户 — 账户类型锁死对公, 户名=商户全称 (自动带出不可改)
1149
+ function paintAccountLock(){
1150
+ var ent = subj && subj.value==='SUBJECT_TYPE_ENTERPRISE';
1151
+ var at=document.getElementById('a_account_type');
1152
+ var an=document.getElementById('a_account_name');
1153
+ if(at){
1154
+ at.value = ent ? 'BANK_ACCOUNT_TYPE_CORPORATE' : at.value;
1155
+ at.disabled = !!ent;
1156
+ }
1157
+ if(an){
1158
+ if(ent){ an.value=document.getElementById('a_merchant_name').value; an.readOnly=true; }
1159
+ else an.readOnly=false;
1160
+ }
1161
+ }
1162
+ paintAccountLock();
1163
+ // 修正3 受益人 (网页版同款): 默认"法定代表人本人"(平台自动回填, 页面不重复填); 选"他人"只给提示
1164
+ var bf=document.getElementById('a_beneficiary');
1165
+ if(bf) bf.addEventListener('change',function(){
1166
+ var h=document.getElementById('a_beneficiary_hint');
1167
+ if(h) h.style.display=bf.value==='other'?'':'none';
1168
+ });
1169
+ var mnEl=document.getElementById('a_merchant_name');
1170
+ if(mnEl) mnEl.addEventListener('input',function(){
1171
+ if(subj&&subj.value==='SUBJECT_TYPE_ENTERPRISE'){
1172
+ var an=document.getElementById('a_account_name'); if(an) an.value=mnEl.value;
1173
+ }
1174
+ });
1066
1175
  // 省市编码选项 (国标区划码, 两处下拉共用; 微信官方对照表口径)
1067
1176
  var CITY_CODES=[['110000','北京市'],['120000','天津市'],['310000','上海市'],['500000','重庆市'],
1068
1177
  ['130100','石家庄'],['140100','太原'],['150100','呼和浩特'],['210100','沈阳'],['210200','大连'],
package/routes/onboard.js CHANGED
@@ -624,7 +624,18 @@ function createOnboardRouter(deps) {
624
624
  SALES_SCENES_WEB: { fields: ["website_url"], media: [] },
625
625
  SALES_SCENES_MP: { fields: ["appid"], media: ["mp_media_id"] },
626
626
  };
627
- const APPLY_MEDIA_COMMON = ["license_media_id", "legal_id_front_media_id", "legal_id_back_media_id"];
627
+ const APPLY_MEDIA_COMMON = ["license_media_id", "legal_id_front_media_id", "legal_id_back_media_id"];
628
+ // 行业 → 特殊资质映射 (微信 qualifications: 需资质行业缺证必被拒; 与页面 QUAL_REQUIRED 同口径)
629
+ const APPLY_QUAL_REQUIRED = {
630
+ "餐饮": "食品经营许可证/餐饮服务许可证", "食品生鲜": "食品经营许可证",
631
+ "私立/民营医院/诊所": "医疗机构执业许可证", "保健器械/医疗器械/非处方药品": "药品经营许可证/医疗器械经营企业许可证",
632
+ "教育/培训/考试缴费/学费": "办学许可证", "游艺厅/KTV/网吧": "娱乐场所经营许可证/网络文化经营许可证",
633
+ "院线影城": "电影放映经营许可证", "演出赛事": "营业性演出许可证",
634
+ "机票/机票代理": "航空公司营业执照或代理资格证", "宠物医院": "动物诊疗许可证",
635
+ "旅行社": "旅行社业务经营许可证", "房地产/房产中介": "房地产五证",
636
+ "文物经营/文物复制品销售": "文物经营许可证", "拍卖典当": "拍卖经营批准证书/典当经营许可证",
637
+ "电信运营商/宽带收费": "基础电信业务经营许可证", "共享服务": "对应行业经营许可/备案", "保健美容SPA": "卫生许可证",
638
+ };
628
639
 
629
640
  /** SP 网关配置 (懒读 env, 测试可注入 deps.fetcher) */
630
641
  function spGatewayUrl() {
@@ -796,6 +807,12 @@ CREATE TABLE IF NOT EXISTS onboard_draft (
796
807
  return res.status(400).json({ ok: false, error: "invalid_param", field: k, message: `缺少必填字段: ${label} (${k})` });
797
808
  }
798
809
  }
810
+ // 需资质行业 → 特殊资质图片必传 (微信 qualifications, 缺证必被拒)
811
+ const qualNeed = APPLY_QUAL_REQUIRED[String(b.qualification_type || "")];
812
+ if (qualNeed && !b.qual_media_id) {
813
+ return res.status(400).json({ ok: false, error: "media_missing", field: "qual_media_id",
814
+ message: `请上传: ${b.qualification_type} 所需资质 (${qualNeed})` });
815
+ }
799
816
  for (const k of APPLY_MEDIA_COMMON.concat(sceneCfg.media)) {
800
817
  if (!b[k]) {
801
818
  const label = APPLY_LABELS[k] || k;
@@ -814,11 +831,21 @@ CREATE TABLE IF NOT EXISTS onboard_draft (
814
831
  for (const k of Object.keys(b)) {
815
832
  if (typeof b[k] === "string" && b[k].trim()) payload[k] = b[k].trim();
816
833
  }
817
- payload.sales_scene = scene; // 场景必带 (SP 侧按它组装 biz_store_info/web_info/mp_info)
834
+ payload.sales_scene = scene; // 场景必带 (SP 侧按它组装 biz_store_info/web_info/mp_info)
835
+ // 特殊资质: qual_media_id → qualifications 数组 (没有则不传该字段)
836
+ if (payload.qual_media_id) {
837
+ payload.qualifications = [payload.qual_media_id];
838
+ delete payload.qual_media_id;
839
+ }
818
840
  // 通用类目资质留空 → 默认 "无" (微信通用场景接受, 避免留空被 SP 拒)
819
841
  // settlement_id = 结算规则 ID, 按主体推导 (商户不用选; 前端不传也兜底)
820
842
  payload.settlement_id = payload.subject_type === "SUBJECT_TYPE_ENTERPRISE" ? "716"
821
843
  : (payload.subject_type === "SUBJECT_TYPE_OTHERS" ? "727" : "719");
844
+ // 修正2 (微信网页版实证): 企业主体强制对公账户 — account_type 锁死对公, 户名=商户全称
845
+ if (payload.subject_type === "SUBJECT_TYPE_ENTERPRISE") {
846
+ payload.account_type = "BANK_ACCOUNT_TYPE_CORPORATE";
847
+ payload.account_name = payload.merchant_name;
848
+ }
822
849
  // M2 断点 c: tb_address 贯通 — SP 台账落列, FINISHED 后 oracle 引导金才知道发给谁
823
850
  const identity = await ensureSellerIdentity(basePaths.baseDir());
824
851
  payload.tb_address = identity.address;
@@ -260,6 +260,11 @@ function buildApplymentBody(form, enc, opts) {
260
260
  if (f.bank_name) body.bank_account_info.bank_name = String(f.bank_name);
261
261
  if (f.bank_branch_id) body.bank_account_info.bank_branch_id = String(f.bank_branch_id);
262
262
  if (f.bank_address_code) body.bank_account_info.bank_address_code = String(f.bank_address_code);
263
+ // 特殊资质图片数组 (微信 qualifications: 需资质行业必传, 最多 5 张; already_encrypted 直通)
264
+ if (Array.isArray(f.qualifications) && f.qualifications.length) {
265
+ body.qualifications = f.qualifications.map((x) => String(x)).filter(Boolean).slice(0, 5);
266
+ if (!body.qualifications.length) delete body.qualifications;
267
+ }
263
268
  return body;
264
269
  }
265
270
 
@@ -111,7 +111,7 @@ async function fillForm(base) {
111
111
  account_type: "BANK_ACCOUNT_TYPE_PERSONAL",
112
112
  account_name: "张三", account_number: PLAINTEXT.card, account_bank: "招商银行",
113
113
  store_name: "张三餐饮店南山店", store_address_code: "440300", store_address: "南山区xx路1号",
114
- service_phone: "0755-12345678", qualification_type: "零售批发/生活娱乐/其他", // 所属行业=中文行业名称 (settlement_id 结算规则由后端按主体推导)
114
+ service_phone: "0755-12345678", qualification_type: "零售", // 所属行业=二级子类名称文本 (settlement_id 结算规则由后端按主体推导)
115
115
  ...media,
116
116
  };
117
117
  }
@@ -287,14 +287,16 @@ test("onboard 页面含进件表单 (btn-apply-submit) 且第③步状态机元
287
287
  assert.ok(!html.includes("slot.insertBefore(img"), "旧增强脚本预览补丁已删 (二重框根因)");
288
288
  assert.ok(html.includes("prefill:function(mediaId)"), "预填走双态 (无第二个空框)");
289
289
  // 2026-10-03 MCC 修正: 经营类目下拉是真实 MCC 编码 (716/719/727 是 V2 结算规则 ID, 被微信拒)
290
- // 2026-10-03 第三次字段修正: qualification_type = 中文行业名称 (封闭集合, 非 MCC)
291
- assert.ok(html.includes("零售批发/生活娱乐/网上商城/其他"), "企业行业子项");
292
- assert.ok(html.includes("零售批发/生活娱乐/其他") && html.includes("咨询/娱乐票务/其他"), "个体户/其他组织行业子项");
290
+ // 2026-10-03 网页版实证校准: 行业二级联动 (大类导航 + 子类名称文本提交)
291
+ assert.ok(html.includes("a_industry_cat"), "行业大类下拉");
292
+ assert.ok(html.includes("INDUSTRY_CATS") && html.includes("renderIndustrySubs"), "二级联动数据+渲染");
293
+ assert.ok(html.includes("{n:'零售'}"), "默认子类 零售");
294
+ assert.ok(html.includes("餐饮/零售") && html.includes("门户论坛/网络广告及推广/软件开发/其他互联网服务"), "对照表子项");
293
295
  assert.ok(html.includes("⚠️需许可证"), "特殊行业许可证标记");
294
- assert.ok(html.includes("INDUSTRIES"), "主体联动选项集");
295
- assert.ok(html.includes("a_settlement_filter"), "行业搜索过滤");
296
- assert.ok(!html.includes("a_settlement_manual"), "行业手输框已删 (封闭集合不允许手输; 省市编码的 __MANUAL__ 保留)");
297
- assert.ok(!/['"]5311['"]/.test(html) && !/['"]5722['"]/.test(html), "MCC 数字编码已清除");
296
+ assert.ok(!html.includes("a_settlement_manual"), "行业手输框不存在 (封闭集合)");
297
+ // 修正2 企业强制对公 + 修正3 受益人
298
+ assert.ok(html.includes("paintAccountLock"), "企业对公锁逻辑");
299
+ assert.ok(html.includes("a_beneficiary"), "受益人选择");
298
300
  // 双字段 (结算规则只读展示 + 所属行业下拉)
299
301
  assert.ok(html.includes("a_settlement_rule"), "结算规则只读展示框");
300
302
  assert.ok(html.includes("结算规则") && html.includes("所属行业"), "双字段标签");
@@ -364,14 +366,14 @@ test("字段语义最终修正: settlement_id=结算规则按主体推导 + qual
364
366
  // 个体户 → settlement_id=719 (前端没传, 后端推导) + qualification_type=5331 透传
365
367
  const before = spCapture.submits.length;
366
368
  // 补回行业名称再提交
367
- form.qualification_type = "零售批发/生活娱乐/其他";
369
+ form.qualification_type = "零售";
368
370
  r = await fetch(base + "/api/onboard/applyment", {
369
371
  method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
370
372
  });
371
373
  j = await r.json();
372
374
  assert.strictEqual(r.status, 200, JSON.stringify(j));
373
375
  assert.strictEqual(spCapture.submits[before].settlement_id, "719", "个体户 → 结算规则 719 (后端推导)");
374
- assert.strictEqual(spCapture.submits[before].qualification_type, "零售批发/生活娱乐/其他", "所属行业=中文行业名称透传");
376
+ assert.strictEqual(spCapture.submits[before].qualification_type, "零售", "所属行业=二级子类名称文本透传");
375
377
  } finally { srv.close(); }
376
378
  });
377
379
 
@@ -380,7 +382,7 @@ test("字段语义: 企业主体 → settlement_id=716 + qualification_type=MCC
380
382
  try {
381
383
  const form = await fillForm(base);
382
384
  form.subject_type = "SUBJECT_TYPE_ENTERPRISE";
383
- form.qualification_type = "零售批发/生活娱乐/网上商城/其他";
385
+ form.qualification_type = "零售";
384
386
  form.legal_id_address = "广东省深圳市南山区xx路1号101室"; // 企业必填 (F4)
385
387
  const before = spCapture.submits.length;
386
388
  const r = await fetch(base + "/api/onboard/applyment", {
@@ -390,11 +392,19 @@ test("字段语义: 企业主体 → settlement_id=716 + qualification_type=MCC
390
392
  assert.strictEqual(r.status, 200, JSON.stringify(j));
391
393
  const fwd = spCapture.submits[before];
392
394
  assert.strictEqual(fwd.settlement_id, "716", "企业 → 结算规则 716");
393
- assert.strictEqual(fwd.qualification_type, "零售批发/生活娱乐/网上商城/其他", "所属行业=中文行业名称");
395
+ assert.strictEqual(fwd.qualification_type, "零售", "所属行业=二级子类名称文本");
396
+ // 修正2 (网页版实证): 企业强制对公 — account_type 锁死 + 户名=公司名 (密文可还原验证)
397
+ assert.strictEqual(fwd.account_type, "BANK_ACCOUNT_TYPE_CORPORATE", "企业强制对公");
398
+ const decName = crypto.privateDecrypt(
399
+ { key: wxPub.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha1" }, // 加密侧已锁 OAEP-SHA1 (2026-09-26 微信实测)
400
+ Buffer.from(fwd.account_name, "base64")
401
+ ).toString("utf8");
402
+ assert.strictEqual(decName, form.merchant_name, "对公户名=商户全称 (自动带出)");
394
403
  // SP 侧组装: settlement_info 两字段正确分工
395
404
  const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
396
405
  assert.strictEqual(wxBody.settlement_info.settlement_id, "716");
397
- assert.strictEqual(wxBody.settlement_info.qualification_type, "零售批发/生活娱乐/网上商城/其他");
406
+ assert.strictEqual(wxBody.settlement_info.qualification_type, "零售");
407
+ assert.strictEqual(wxBody.bank_account_info.bank_account_type, "BANK_ACCOUNT_TYPE_CORPORATE");
398
408
  } finally { srv.close(); }
399
409
  });
400
410
 
@@ -675,3 +685,52 @@ test("草稿页面逻辑: 自动保存/恢复优先级/FINISHED 清除/提交保
675
685
  assert.ok(html.indexOf("applyment/draft") < html.indexOf("form_snapshot") || html.includes("恢复优先级"), "草稿优先于快照");
676
686
  } finally { srv.close(); }
677
687
  });
688
+
689
+ test("特殊资质: 餐饮无许可证 → 400 中文提示; 有证 → 转发体 qualifications 数组", async () => {
690
+ const { srv, base } = await startSeller();
691
+ try {
692
+ const form = await fillForm(base);
693
+ form.qualification_type = "餐饮"; // 需资质行业
694
+ // 无资质 → 400 中文提示含资质名
695
+ let r = await fetch(base + "/api/onboard/applyment", {
696
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
697
+ });
698
+ let j = await r.json();
699
+ assert.strictEqual(r.status, 400);
700
+ assert.strictEqual(j.error, "media_missing");
701
+ assert.strictEqual(j.field, "qual_media_id");
702
+ assert.ok(j.message.includes("餐饮") && j.message.includes("食品经营"), j.message);
703
+ // 上传资质 → 转发体 qualifications=["media_id"], SP 侧顶层直通
704
+ const up = await fetch(base + "/api/onboard/applyment/upload", {
705
+ method: "POST", headers: { "Content-Type": "application/json" },
706
+ body: JSON.stringify({ filename: "qual.jpg", content_b64: Buffer.from("fake-qual").toString("base64") }),
707
+ });
708
+ form.qual_media_id = (await up.json()).media_id;
709
+ const before = spCapture.submits.length;
710
+ r = await fetch(base + "/api/onboard/applyment", {
711
+ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form),
712
+ });
713
+ j = await r.json();
714
+ assert.strictEqual(r.status, 200, JSON.stringify(j));
715
+ const fwd = spCapture.submits[before];
716
+ assert.deepStrictEqual(fwd.qualifications, [form.qual_media_id], "qualifications 数组");
717
+ assert.ok(!("qual_media_id" in fwd), "中间字段已转换");
718
+ const wxBody = spApplymentLib.buildApplymentBody({ ...fwd, business_code: "TBTEST" }, (s) => s, { alreadyEncrypted: true });
719
+ assert.deepStrictEqual(wxBody.qualifications, [form.qual_media_id], "SP 顶层 qualifications 直通");
720
+ } finally { srv.close(); }
721
+ });
722
+
723
+ test("特殊资质页面逻辑: QUAL_REQUIRED 映射 + 动态槽位 + 双态复用", async () => {
724
+ const { srv, base } = await startSeller();
725
+ try {
726
+ const html = await (await fetch(base + "/onboard/")).text();
727
+ assert.ok(html.includes("QUAL_REQUIRED"), "行业-资质映射表");
728
+ assert.ok(html.includes("a_up_qual"), "资质槽位容器");
729
+ assert.ok(html.includes("paintQualSlot"), "槽位显隐逻辑");
730
+ assert.ok(html.includes("食品经营许可证"), "餐饮资质名");
731
+ assert.ok(html.includes("医疗机构执业许可证"), "医疗资质名");
732
+ assert.ok(html.includes("办学许可证"), "教育资质名");
733
+ assert.ok(html.includes("qual_media_id"), "资质 media_id 字段");
734
+ assert.ok(html.includes("特殊资质 (必传)"), "槽位必传标注");
735
+ } finally { srv.close(); }
736
+ });