feat(huifu): 进件补采线下经营的三张门店材料(门头照/内景照/收银台)

线下报备下通道把「门店材料」当成一组三张,并在**进件这一步**由商户材料校验服务按
F-code 逐张核对,缺一张整笔驳回。2026-09-29 的真实驳回原文:

调用商户材料校验服务返回,校验失败 缺失图片:F22-店铺门头照图片;F24-店铺内景图片;F105-店铺收银台

我方三处对不上:企业(含个体工商户)主体一张都不采集(表单没有入口,extended_material_list
的下发条件写成「仅个人」);个人主体只有两张;**F105 收银台全仓没有任何字段与入口**。

- 商户端表单:三张图改成两类主体共用的一组,补「店铺收银台照片」;校验、草稿、提交载荷同步。
- 服务端:请求加 storeCashierUrl;三张图不分主体都必填;落库 store_cashier_url
(迁移 V156 + MerchantOnboardingSchemaInitializer);两条进件路径发
store_header_pic / store_indoor_pic / store_cashier_desk_pic(字段名取自通道报错原文);
业务开通的 extended_material_list 改成两类主体都发 F22/F24/F105。
- 重提补齐(supplyMissingOnboardingMaterials)纳入收银台,且不再只对个人主体生效。
- 后台进件详情补「店铺收银台」一列。
- 台账:02/06/08、CONTEXT 术语、商户端 18-商户进件;issue 03(线下企业要不要门店材料)用本次
实测结掉。

待确认(已记进 issue 03):F105 在仓库内的官方原件里没有条目,只有运行时驳回;企业主体在
extended_material_list 里交这一组是否被通道承认,需一次真实进件验收或汇付书面回复。
1个文件已添加
21个文件已修改
433 ■■■■■ 已修改文件
.scratch/offline-wechat-scene-switch/issues/03-enterprise-offline-store-materials-unknown.md 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/offline-wechat-scene-switch/spec.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CONTEXT.md 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/huifu/02-进件与收款能力.md 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/huifu/06-踩坑清单.md 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/huifu/08-上线与环境.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/docs/pages/18-商户进件.md 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-admin/src/views/MerchantOnboardingDetailView.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingCommand.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingGateway.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingCommand.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingGateway.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/main/resources/db/migration/V156__merchant_onboarding_store_cashier.sql 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenRequiredFieldsTests.java 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingRequiredFieldsTests.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingMerchantLevelTests.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.scratch/offline-wechat-scene-switch/issues/03-enterprise-offline-store-materials-unknown.md
@@ -1,32 +1,60 @@
# 线下报备下企业主体是否也要门头照/内景照(需汇付确认)
# 线下报备下企业主体是否也要门店材料(已有实测答案)
Status: needs-info
Status: ready-for-agent | 2026-09-29 用真实驳回回答,同日实现
## 现象
## 结论
2026-09-24 把进件场景切成 `scene_type=OFFLINE` 之后,**个人(小微)主体**的门店材料要求是清楚的
(F22 门头照 + F24 内景照,语义从「店铺首页截图」改为「实体门头照」),但**企业(含个体工商户)主体**
要不要同样交这两张图,目前**没有任何依据**:
**要,而且是一组三张**:门头照(F22)、内景照(F24)、**收银台(F105)**;**企业与个人主体都要交**。
通道把这一组当「线下经营的门店材料」,在**进件这一步**(不是业务开通、也不是实名)就由
「商户材料校验服务」按 F-code 逐张核对,缺一张整笔驳回。
- 线上口径下企业从来不需要(代码里 `individualMerchant` 为真才下发 `extended_material_list`,
  商户端也只有 `merchantType == 'individual'` 才要求上传),这一条是既有实现,不是这次新加的。
- 线上官方口径只写了「企业使用 fee_rule_id 代替 mcc」,没有提经营场所材料;
  线下场景按「有实体经营场所」报备,**很可能会索要门店材料**,但仓库里没有官方原文可依。
依据是通道自己的驳回原文(2026-09-29,商户端进件被驳后停在待审页的那句话):
## 影响
```
调用商户材料校验服务返回,校验失败 缺失图片:F22-店铺门头照图片;F24-店铺内景图片;F105-店铺收银台
```
如果通道对线下企业主体索要 F22/F24,那么企业商户的进件会被驳,而我们的进件表单根本没采集这两张图 →
商户必须重新提交一遍(这正是「提交时校验比通道驳回后再补」要避免的那种返工)。
两条辅助证据:
## 需要什么
- 更早一次(2026-09-09,个人主体、两张图一张都没收)的驳回用的是**字段名**版本:
  `【store_header_pic-线下经营-门头照】文件缺失;【store_indoor_pic-线下经营-内景照】文件缺失;【store_cashier_desk_pic-线下经营-收银台】文件缺失;`
  —— `store_header_pic` 正是我方个人进件请求里已在用的那个字段名,所以另两个字段名就是
  `store_indoor_pic` / `store_cashier_desk_pic`(本次实现用的就是这一对)。
- 官方《商户业务开通》文档里,`micro_biz_type` 的说明只点了前两张
  (「需要提供 F22:店铺门头照图片;F24:店铺内景图片,补充资料填写在 extended_material_list 对象中」),
  **F105 在仓库内的官方原件里查不到**——它只出现在通道的运行时报错里。这是本次唯一一条
  「无法用官方原件对齐、只能按实测报错实现」的地方。
向汇付确认(书面最好):**线下场景(1/2/3)下企业主体进件需要哪些经营场所材料**?
- 若需要 F22/F24:企业路径要补采集(后端校验 + 商户端表单 + 落库 + 进件请求下发),
  与个人路径对齐;`extended_material_list` 的下发条件从「仅个人」改为「个人 + 企业」。
- 若不需要:在 `05` 里记一条「线下企业不需门店材料(依据:汇付 ×× 回复)」,免得下一个人再问一遍。
驳回信息的**落库位置**(判它来自哪一步的判据):`reject_reason` 里是我们原样存的通道文案、**没有前缀**。
`applyOnboardingResult()`(进件递交)写的是无前缀文案;`advanceBusinessOpen` / `advanceWechatConfig`
写的是「商户业务开通失败:…」/「微信商户配置失败:…」。所以这条驳回来自**进件递交**,不是业务开通。
## 因此改动(本仓,同日)
- 商户端表单:三类主体(企业/个体工商户/个人)在第一步都采集三张图,缺任一张在本地就拦住;
  个人主体原先只有两张,**收银台是新加的入口**;企业主体原先一张都没有。
- 服务端:请求加 `storeCashierUrl`;校验改成三张、不分主体;落库 `store_cashier_url`(迁移 `V156`
  + `MerchantOnboardingSchemaInitializer`);
  进件请求发 `store_header_pic` / `store_indoor_pic` / `store_cashier_desk_pic`(企业与个人两条网关都发);
  业务开通的 `extended_material_list` 改成**两类主体都发** F22/F24/F105。
- 重提补齐(`supplyMissingOnboardingMaterials`)把收银台一并纳入,并且不再只对个人主体生效——
  已提交但停在某个阶段的存量的进件走这条路补图。
- 后台进件详情的「门店照片」区补一列「店铺收银台」。
## 还需要向汇付确认的(不影响本次上线,但别让它再咬一次)
1. **F105 的官方定义与适用主体**:仓库内没有 F105 的官方原件条目,目前全靠这一条实测报错。
   要书面确认「线下经营的门店材料就是这三张、且企业主体同样适用」,否则下次通道调整我们只能再猜一遍。
2. **企业主体在** **`extended_material_list`** **里交这一组是否被通道承认**:官方把 F22/F24 的要求写在
   `micro_biz_type`(个人/小微)下面,企业主体那里我们按对称补齐(同一组 F-code)。若通道对企业主体
   另有字段(官方企业在进件 `file_list` 里用的是 F49 内景 / F50 门头),要按回复改。
3. 若通道对线下企业主体其实**不要**这一组,应把校验与下发退回「仅个人」,并在 `05` 记一条依据。
## 关联
- 代码位置:`HuifuMerchantBusinessOpenGateway`(`extended_material_list` 的下发条件)、
  `MerchantSettlementService`(个人主体的两张图校验)、`merchant-onboarding.uvue`(表单)。
- 置信度约定见 `docs/huifu/README.md`:拿到回复前,这条只能标【待确认】。
- 代码:`MerchantSettlementService`(三张图校验、落库、`advanceBusinessOpen` 的材料闸门)、
  `HuifuIndividualOnboardingGateway` / `HuifuEnterpriseOnboardingGateway`(进件 `store_*_pic`)、
  `HuifuMerchantBusinessOpenGateway`(`extended_material_list`)、`merchant-onboarding.uvue`(表单)。
- 台账:`docs/huifu/02-进件与收款能力.md`(门店材料一段)、`docs/huifu/06-踩坑清单.md` 第 4b 条、
  `docs/huifu/08-上线与环境.md` §4A 验收项。
- 置信度约定见 `docs/huifu/README.md`:本条属【实测】(真实回执原文),F105 的官方口径仍为【待确认】。
.scratch/offline-wechat-scene-switch/spec.md
@@ -30,5 +30,7 @@
- `01` 微信侧拒绝真因(`bank_message`)被丢弃 —— 可观测性缺陷,本次排查的第二个障碍。
- `02` 口径的五个值散在 6 个文件里,收成一个值对象 + 去掉重复的 AppID 扫描。
- `03` 线下报备下**企业**主体要不要门头照/内景照(需汇付确认)。
- `03` 线下报备下**企业**主体要不要门店材料 —— 2026-09-29 由真实驳回回答:要,且是一组**三张**
  (门头照 F22 / 内景照 F24 / 收银台 F105),企业与个人主体都交、在进件这一步就核对;同日实现。
  F105 的官方口径仍待汇付书面确认(见该文件末尾)。
- `04` 走基本户的两条退款路径没有人工出口(到店消费退款、撤销核销余额不足)。
CONTEXT.md
@@ -74,8 +74,9 @@
**商户端**:商户办理进件、扫描人才码创建到店消费订单,以及扫描预购订单核销码完成核销的小程序;付款由人才用户在人才端完成。
**商户进件**:商户提交主体、经营和结算资料,申请成为汇付商户的过程。主体分企业(含个体工商户)
与个人(小微)两种:企业交营业执照与法人资料,个人交身份证、**经营详细地址**与**店铺照片**
(线下报备口径下是实体门头照)、**店内环境照**——个人主体的微信业务开通与微信实名认证都要这两张图。
与个人(小微)两种:企业交营业执照与法人资料,个人交身份证、**经营详细地址**。
**门店材料**是一组三张——**店铺门头照**(线下报备口径下是实体门头照)、**店内环境照**、**店铺收银台**,
**企业与个人主体都要交**:通道在进件这一步就按 F-code 逐张核对,缺一张整笔驳回。
基本资料审核通过后,微信支付仍可能处于开通中或开通失败。
**渠道商**:汇付里位于我们平台之上、**直属**管辖我们名下商户的那一层主体。平台的每一次通道调用都以它(或它的下级身份)
docs/huifu/02-进件与收款能力.md
@@ -33,7 +33,16 @@
进件侧随之多两项要求:个人(小微)主体的**经营详细地址**成为必填(进件请求的 `detail_addr`,落
`merchant_onboarding_application.business_address`,**与证件地址 `legal_addr` 分开**——以前两者共用同一个值,
线下报备下会被通道驳回或留下错误信息,迁移 `V155`);F22 店铺照片的含义从「店铺首页截图」变为「实体门头照」。
线下报备下会被通道驳回或留下错误信息,迁移 `V155`);**门店材料从两张变三张**——门头照(F22)、内景照(F24)、
**收银台(F105)**,且**企业与个人主体都要交**(证明见下)。F22 的含义也从「店铺首页截图」变为「实体门头照」。
**门店材料是一组三张,且通道在进件这一步就核对【实测】**(2026-09-29):进件请求被通道的「商户材料校验服务」
按 F-code 逐张核对,缺一张整笔驳回——报错原文
`调用商户材料校验服务返回,校验失败 缺失图片:F22-店铺门头照图片;F24-店铺内景图片;F105-店铺收银台`。
所以:商户端表单三类主体都收这三张(`storeHeaderUrl` / `storeIndoorUrl` / `storeCashierUrl`,迁移 `V156`),
进件请求发 `store_header_pic` / `store_indoor_pic` / `store_cashier_desk_pic`(字段名取自通道自己的报错),
业务开通再以 `extended_material_list` 的 F22/F24/F105 补一遍(`putMaterial` 跳过空值,某张确实没有时不下发残缺条目)。
**企业主体原先一张都不发**(`extended_material_list` 的下发条件写成「仅个人」),线下口径下就是在进件这一步被驳。
## 1. 进件时序:企业/个体双路径 + 通道回调 + 分阶段开通
@@ -47,9 +56,9 @@
    M->>S: POST /api/merchant/onboarding (X-Idempotency-Key)
    S->>S: 建进件(pending) + 银行卡(pending);已有 active 进件则只补门店照
    alt 企业主体(含个体工商户)
        S->>H: v2.merchant.basicdata.ent(先传 F07/F02/F03/F13… 取 fileId)
        S->>H: v2.merchant.basicdata.ent(先传 F07/F02/F03/F13/F22/F24/F105… 取 fileId)
    else 个人(小微)主体
        S->>H: v2.merchant.basicdata.indv(先传 F40/F41/F13/F22)
        S->>H: v2.merchant.basicdata.indv(先传 F40/F41/F13/F22/F24/F105)
    end
    H-->>S: huifu_id + apply_no(同步响应即可推进下一步,不等 token_no)
    S->>H: v2.merchant.busi.open(wx_conf_list 场景 1/2/3(线下)、delay_flag=Y、forced_delay_flag=N)
docs/huifu/06-踩坑清单.md
@@ -39,6 +39,7 @@
| 2 | 进件放行了,但商户用同一张卡重提仍被拒 | 收口只把进件标 rejected,**漏了它绑定的 `merchant_bank_card`** | 【实测】 |
| 3 | 第一次提交进件就 500,报 `Unknown column 'license_validity_type'` | 代码 INSERT 写了这些列,V80/V99/V127/V128 都没建;此前测试用 JDBC 直接造进件记录,**绕过了真实入口** | 【实测】 |
| 4 | 个人(小微)走完进件也拿不到微信收款能力 | 门店照片(F22 门头照 / F24 内景照)**一个字段都没收**,而官方在业务开通与实名两处都点名要 | 【实测】 |
| 4b | 线下报备后进件在**第一步**就被整笔驳回:`缺失图片:F22-店铺门头照图片;F24-店铺内景图片;F105-店铺收银台` | 通道把「线下经营的门店材料」定成**一组三张**、并在进件这一步由材料校验服务逐张核对;我方只收了两张、且只对个人主体收,**第三张(F105 收银台)全仓没有任何采集口**,企业主体更是一张都不发(`extended_material_list` 的下发条件写成「仅个人」)。商户端的「重新提交」也没有那两张图的入口 | 【实测】(2026-09-29 真实驳回原文) |
| 5 | 已拿到真实汇付号的存量个人进件一次业务开通都没做过,也没有补图入口 | 分阶段模型下重复提交不新建进件,商户没有别的补图入口(开发库实测 10 条全部 `business_open_status=not_started`) | 【实测】 |
| 6 | 商户端换账号登录,进件页把**上一个账号**的身份证号、银行卡号预填进来 | 草稿存在固定全局 key,uni storage 不分账号,`restoreDraft()` 不校验归属 | 【实测】 |
| 7 | 进件能过、商户建得出来,但平台对它调 config/open/query 一律 `20002 数据权限认证失败` | 20002 与层级无关;改 `head_office_flag`+`head_huifu_id` 建成平台下级**实测无效**,还额外被索要 F725/F726/F727 | 【实测】 |
docs/huifu/08-上线与环境.md
@@ -159,7 +159,7 @@
### A. 进件能走通(8 项)
- [ ] 企业主体与个人(小微)主体各提交一条,均不再报 `Unknown column ...`(`merchant_onboarding_application` 有行、无 500)
- [ ] 个人主体能提交门店照片(F22 门头照 / F24 内景照),且资料真的下发到通道
- [ ] 企业主体与个人主体各自都能提交门店材料(门头照 F22 / 内景照 F24 / 收银台 F105),且**三张都真的下发到通道**(进件请求的 `store_*_pic` 与业务开通的 `extended_material_list`)
- [ ] 进件审核回调能到达并落库(`payment_callback_event` 出现 `merchant-onboarding-notify` 的请求流水号)
- [ ] 商户业务开通被受理时**不会**被记成失败(`90000000` 是「受理/审核中」,「微信支付」阶段不落在 `failed`)
- [ ] 「微信小程序配置」排在「商户业务开通」之后执行,不被驳「根据渠道号或微信子商户号未查到入驻记录」
rencaika_merchant/docs/pages/18-商户进件.md
@@ -7,7 +7,7 @@
| 路由 | `/pages/merchant-onboarding/merchant-onboarding` |
| 页面目标 | 首次提现前提交企业或个人主体资料,进入结算主体审核流程。 |
| 进入方式 | 商户提现页点击“提现”,且进件状态为未提交或需重新提交。 |
| 负责人/更新时间 | 待指定 / 2026-07-28 |
| 负责人/更新时间 | 待指定 / 2026-09-29 |
## 功能点
@@ -19,6 +19,7 @@
| MO-004 | 联系人信息 | 企业填写管理员姓名和手机号,个人填写手机号。 | 已完成首版 |
| MO-005 | 表单校验 | 校验信用代码、身份证号、手机号、必填项和日期先后关系。 | 已完成 |
| MO-006 | 提交审核 | 二次确认后携带幂等键提交,后台确认成功才进入待审核结果页。 | 已接入 |
| MO-007 | 门店材料 | 店铺门头照、店内环境照、店铺收银台三张,**企业与个人主体都要传**。线下报备下通道把它们当成一组材料,在进件这一步就按 F-code 逐张核对,缺一张整笔驳回,所以三张都在第一步采集与校验。 | 已完成 |
## 安全约定
@@ -33,5 +34,6 @@
- [x] 开始日期不得晚于结束日期。
- [x] 错误字段有红色状态及具体提示,用户输入不会因校验失败丢失。
- [x] 提交后进入待审核页面。
- [ ] 门店材料三张在两类主体下都出现、都参与校验;缺任一张在第一步就被拦住并给出具体提示(**待 HBuilderX 编译与真机走查**)
- [x] HBuilderX 5.07 微信小程序编译通过。
- [x] 进件状态、资料提交、驳回原因和重新提交已完成项目后端接入。
rencaika_merchant/pages/merchant-onboarding/merchant-onboarding.uvue
@@ -25,9 +25,12 @@
                    <view class="field-block"><text class="field-label required">行业类型</text><picker class="date-picker" mode="selector" :range="industryTypeOptions" :value="industryTypeIndex" @change="onIndustryTypeChange"><view class="select-shell" :class="invalidField == 'industryType' ? 'field-invalid' : ''"><text :class="industryType.length == 0 ? 'select-placeholder' : ''">{{industryType.length > 0 ? industryType : '请选择'}}</text><image class="down-icon" src="/static/icons/lucide/chevron-down.svg" mode="aspectFit"></image></view></picker><text v-if="invalidField == 'industryType'" class="field-error">{{errorMessage}}</text></view>
                    <view class="field-block"><text class="field-label required">经营地区(省、市、区)</text><picker class="region-picker" mode="region" @change="onRegisteredRegionChange"><view class="select-shell" :class="invalidField == 'registeredRegion' ? 'field-invalid' : ''"><text class="ellipsis-text" :class="registeredRegion.length == 0 ? 'select-placeholder' : ''">{{registeredRegion.length > 0 ? registeredRegion : '请选择'}}</text><image class="down-icon" src="/static/icons/lucide/chevron-down.svg" mode="aspectFit"></image></view></picker><text v-if="invalidField == 'registeredRegion'" class="field-error">{{errorMessage}}</text></view>
                    <view class="field-block"><text class="field-label required">经营详细地址</text><view class="input-shell" :class="invalidField == 'businessAddress' ? 'field-invalid' : ''"><input class="text-input" v-model="businessAddress" maxlength="255" placeholder="请输入" placeholder-class="input-placeholder" @input="clearFieldError('businessAddress')" /></view><text class="field-hint">与门头照上的地址一致(线下报备需要)</text><text v-if="invalidField == 'businessAddress'" class="field-error">{{errorMessage}}</text></view>
                    <view class="field-block"><text class="field-label required">店铺照片</text><view class="upload-box" :class="invalidField == 'storeHeaderUrl' ? 'field-invalid' : ''" @tap="chooseDocument('storeHeader')"><image v-if="storeHeaderUrl.length > 0" class="document-image" :src="storeHeaderUrl" mode="aspectFill"></image><text v-else>{{uploadingField == 'storeHeader' ? '上传中...' : '添加图片'}}</text></view><text class="field-hint">传实体门头照(招牌清晰可见);流动经营、上门服务传经营现场照</text><text v-if="invalidField == 'storeHeaderUrl'" class="field-error">{{errorMessage}}</text></view>
                    <view class="field-block"><text class="field-label required">店内环境照</text><view class="upload-box" :class="invalidField == 'storeIndoorUrl' ? 'field-invalid' : ''" @tap="chooseDocument('storeIndoor')"><image v-if="storeIndoorUrl.length > 0" class="document-image" :src="storeIndoorUrl" mode="aspectFill"></image><text v-else>{{uploadingField == 'storeIndoor' ? '上传中...' : '添加图片'}}</text></view><text class="field-hint">店内环境或工作区域照片,需再传一张,不能与上一张相同</text><text v-if="invalidField == 'storeIndoorUrl'" class="field-error">{{errorMessage}}</text></view>
                </view>
                <!-- 门店材料是线下报备的一组三张:门头照(F22)、内景照(F24)、收银台(F105)。通道在
                     进件这一步就按 F-code 逐张核对,缺一张整笔驳回,所以企业与个人主体都要交。 -->
                <view class="field-block"><text class="field-label required">店铺门头照</text><view class="upload-box" :class="invalidField == 'storeHeaderUrl' ? 'field-invalid' : ''" @tap="chooseDocument('storeHeader')"><image v-if="storeHeaderUrl.length > 0" class="document-image" :src="storeHeaderUrl" mode="aspectFill"></image><text v-else>{{uploadingField == 'storeHeader' ? '上传中...' : '添加图片'}}</text></view><text class="field-hint">实体门头照(招牌清晰可见);流动经营、上门服务传经营现场照</text><text v-if="invalidField == 'storeHeaderUrl'" class="field-error">{{errorMessage}}</text></view>
                <view class="field-block"><text class="field-label required">店内环境照</text><view class="upload-box" :class="invalidField == 'storeIndoorUrl' ? 'field-invalid' : ''" @tap="chooseDocument('storeIndoor')"><image v-if="storeIndoorUrl.length > 0" class="document-image" :src="storeIndoorUrl" mode="aspectFill"></image><text v-else>{{uploadingField == 'storeIndoor' ? '上传中...' : '添加图片'}}</text></view><text class="field-hint">店内环境或工作区域照片,不能与门头照相同</text><text v-if="invalidField == 'storeIndoorUrl'" class="field-error">{{errorMessage}}</text></view>
                <view class="field-block"><text class="field-label required">店铺收银台照片</text><view class="upload-box" :class="invalidField == 'storeCashierUrl' ? 'field-invalid' : ''" @tap="chooseDocument('storeCashier')"><image v-if="storeCashierUrl.length > 0" class="document-image" :src="storeCashierUrl" mode="aspectFill"></image><text v-else>{{uploadingField == 'storeCashier' ? '上传中...' : '添加图片'}}</text></view><text class="field-hint">收银台或吧台实拍,能看到收款设备/收款码</text><text v-if="invalidField == 'storeCashierUrl'" class="field-error">{{errorMessage}}</text></view>
            </view>
            <view v-if="currentStep == 2">
@@ -84,7 +87,7 @@
    const draftNotice = ref(false)
    const submitError = ref('')
    const submitting = ref(false); const uploadingField = ref(''); const merchantType = ref('enterprise'); const merchantName = ref(''); const merchantShortName = ref(''); const companyType = ref(''); const companyTypeIndex = ref(-1); const companyTypeOptions = ['政府机构', '国营企业', '私营企业', '外资企业', '个体工商户', '其它组织', '事业单位', '业主委员会']; const industryType = ref(''); const industryTypeIndex = ref(0); const industryTypeOptions = ['餐饮服务', '酒店服务', '景区服务', '加油服务', '停车服务', '其它']; const businessLicenseUrl = ref(''); const creditCode = ref(''); const businessValidity = ref('固定期限'); const businessStartDate = ref(''); const businessEndDate = ref(''); const establishedDate = ref(''); const registeredRegion = ref(''); const registeredProvinceCode = ref(''); const registeredCityCode = ref(''); const registeredDistrictCode = ref(''); const registeredAddress = ref(''); const businessAddress = ref(''); const registeredCapital = ref(''); const subjectName = ref(''); const legalIdFrontUrl = ref(''); const legalIdBackUrl = ref(''); const subjectIdNumber = ref(''); const subjectValidity = ref('10年'); const subjectStartDate = ref(''); const subjectEndDate = ref(''); const legalCertificateAddress = ref(''); const legalMobile = ref(''); const contactName = ref(''); const contactMobile = ref(''); const administratorEmail = ref(''); const bankCardNumber = ref(''); const bankProvince = ref(''); const bankProvinceCode = ref(''); const bankProvinceIndex = ref(0); const bankCity = ref(''); const bankCityCode = ref(''); const bankCityNames = ref([] as string[]); const bankCityCodes = ref([] as string[]); const bankCityIndex = ref(0); const bankBranch = ref(''); const bankBranchCode = ref(''); const bankName = ref(''); const settlementProofUrl = ref(''); const storeHeaderUrl = ref(''); const storeIndoorUrl = ref(''); const invalidField = ref(''); const errorMessage = ref('')
    const submitting = ref(false); const uploadingField = ref(''); const merchantType = ref('enterprise'); const merchantName = ref(''); const merchantShortName = ref(''); const companyType = ref(''); const companyTypeIndex = ref(-1); const companyTypeOptions = ['政府机构', '国营企业', '私营企业', '外资企业', '个体工商户', '其它组织', '事业单位', '业主委员会']; const industryType = ref(''); const industryTypeIndex = ref(0); const industryTypeOptions = ['餐饮服务', '酒店服务', '景区服务', '加油服务', '停车服务', '其它']; const businessLicenseUrl = ref(''); const creditCode = ref(''); const businessValidity = ref('固定期限'); const businessStartDate = ref(''); const businessEndDate = ref(''); const establishedDate = ref(''); const registeredRegion = ref(''); const registeredProvinceCode = ref(''); const registeredCityCode = ref(''); const registeredDistrictCode = ref(''); const registeredAddress = ref(''); const businessAddress = ref(''); const registeredCapital = ref(''); const subjectName = ref(''); const legalIdFrontUrl = ref(''); const legalIdBackUrl = ref(''); const subjectIdNumber = ref(''); const subjectValidity = ref('10年'); const subjectStartDate = ref(''); const subjectEndDate = ref(''); const legalCertificateAddress = ref(''); const legalMobile = ref(''); const contactName = ref(''); const contactMobile = ref(''); const administratorEmail = ref(''); const bankCardNumber = ref(''); const bankProvince = ref(''); const bankProvinceCode = ref(''); const bankProvinceIndex = ref(0); const bankCity = ref(''); const bankCityCode = ref(''); const bankCityNames = ref([] as string[]); const bankCityCodes = ref([] as string[]); const bankCityIndex = ref(0); const bankBranch = ref(''); const bankBranchCode = ref(''); const bankName = ref(''); const settlementProofUrl = ref(''); const storeHeaderUrl = ref(''); const storeIndoorUrl = ref(''); const storeCashierUrl = ref(''); const invalidField = ref(''); const errorMessage = ref('')
    const merchantTypeOptions = ['企业', '个人']
    const merchantTypeIndex = ref(0)
    const validityOptions = ['固定期限', '长期']
@@ -128,11 +131,13 @@
                if (registeredRegion.value.length == 0) return 'registeredRegion|请选择经营地区'
                // 经营详细地址:线下报备(entity 有实体经营场所)下它是通道必填项,是进件的 detail_addr。
                if (businessAddress.value.trim().length == 0) return 'businessAddress|请输入经营详细地址'
                // 店铺照片是个人(小微)商户进件的必填项,微信业务开通与微信实名认证都要用它。
                // 在这里拦住,好过进件提交上去以后在开通或实名那一步被驳回——那时得重走整份进件。
                if (storeHeaderUrl.value.length == 0) return 'storeHeaderUrl|请上传店铺照片(线下报备为实体门头照)'
                if (storeIndoorUrl.value.length == 0) return 'storeIndoorUrl|请上传店内环境照'
            }
            // 门店材料是线下报备的一组三张(门头照 F22、内景照 F24、收银台 F105),**不分主体类型**:
            // 通道在进件这一步就按 F-code 逐张核对,缺一张整笔驳回。在这里拦住,好过提交上去以后被驳回
            // ——那时商户得整份进件重走一遍。
            if (storeHeaderUrl.value.length == 0) return 'storeHeaderUrl|请上传店铺门头照'
            if (storeIndoorUrl.value.length == 0) return 'storeIndoorUrl|请上传店内环境照'
            if (storeCashierUrl.value.length == 0) return 'storeCashierUrl|请上传店铺收银台照片'
        }
        if (step == 2) {
            if (subjectName.value.trim().length == 0) return 'subjectName|' + (merchantType.value == 'enterprise' ? '请输入法人姓名' : '请输入个人姓名')
@@ -184,7 +189,7 @@
    }
    const goPrevStep = () => { saveDraft(); if (currentStep.value > 1) currentStep.value = currentStep.value - 1; resetScroll() }
    const draftPayload = (): any => ({ ownerOperatorId: currentOperatorId(), merchantType: merchantType.value, merchantName: merchantName.value, merchantShortName: merchantShortName.value, companyType: companyType.value, companyTypeIndex: companyTypeIndex.value, industryType: industryType.value, industryTypeIndex: industryTypeIndex.value, businessLicenseUrl: businessLicenseUrl.value, creditCode: creditCode.value, businessValidity: businessValidity.value, businessStartDate: businessStartDate.value, businessEndDate: businessEndDate.value, establishedDate: establishedDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: registeredAddress.value, businessAddress: businessAddress.value, registeredCapital: registeredCapital.value, subjectName: subjectName.value, legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, subjectIdNumber: subjectIdNumber.value, subjectValidity: subjectValidity.value, subjectStartDate: subjectStartDate.value, subjectEndDate: subjectEndDate.value, legalCertificateAddress: legalCertificateAddress.value, legalMobile: legalMobile.value, contactName: contactName.value, contactMobile: contactMobile.value, administratorEmail: administratorEmail.value, bankCardNumber: bankCardNumber.value, bankProvince: bankProvince.value, bankProvinceCode: bankProvinceCode.value, bankProvinceIndex: bankProvinceIndex.value, bankCity: bankCity.value, bankCityCode: bankCityCode.value, bankCityIndex: bankCityIndex.value, bankBranch: bankBranch.value, bankBranchCode: bankBranchCode.value, bankName: bankName.value, settlementProofUrl: settlementProofUrl.value, storeHeaderUrl: storeHeaderUrl.value, storeIndoorUrl: storeIndoorUrl.value, currentStep: currentStep.value })
    const draftPayload = (): any => ({ ownerOperatorId: currentOperatorId(), merchantType: merchantType.value, merchantName: merchantName.value, merchantShortName: merchantShortName.value, companyType: companyType.value, companyTypeIndex: companyTypeIndex.value, industryType: industryType.value, industryTypeIndex: industryTypeIndex.value, businessLicenseUrl: businessLicenseUrl.value, creditCode: creditCode.value, businessValidity: businessValidity.value, businessStartDate: businessStartDate.value, businessEndDate: businessEndDate.value, establishedDate: establishedDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: registeredAddress.value, businessAddress: businessAddress.value, registeredCapital: registeredCapital.value, subjectName: subjectName.value, legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, subjectIdNumber: subjectIdNumber.value, subjectValidity: subjectValidity.value, subjectStartDate: subjectStartDate.value, subjectEndDate: subjectEndDate.value, legalCertificateAddress: legalCertificateAddress.value, legalMobile: legalMobile.value, contactName: contactName.value, contactMobile: contactMobile.value, administratorEmail: administratorEmail.value, bankCardNumber: bankCardNumber.value, bankProvince: bankProvince.value, bankProvinceCode: bankProvinceCode.value, bankProvinceIndex: bankProvinceIndex.value, bankCity: bankCity.value, bankCityCode: bankCityCode.value, bankCityIndex: bankCityIndex.value, bankBranch: bankBranch.value, bankBranchCode: bankBranchCode.value, bankName: bankName.value, settlementProofUrl: settlementProofUrl.value, storeHeaderUrl: storeHeaderUrl.value, storeIndoorUrl: storeIndoorUrl.value, storeCashierUrl: storeCashierUrl.value, currentStep: currentStep.value })
    const saveDraft = () => { uni.setStorageSync(draftStorageKey(), JSON.stringify(draftPayload())) }
    const clearDraft = () => { uni.removeStorageSync(draftStorageKey()) }
    const restoreDraft = () => {
@@ -242,6 +247,7 @@
            if (draft.settlementProofUrl != null) settlementProofUrl.value = draft.settlementProofUrl as string
            if (draft.storeHeaderUrl != null) storeHeaderUrl.value = draft.storeHeaderUrl as string
            if (draft.storeIndoorUrl != null) storeIndoorUrl.value = draft.storeIndoorUrl as string
            if (draft.storeCashierUrl != null) storeCashierUrl.value = draft.storeCashierUrl as string
            if (bankProvince.value.length > 0 && bankProvinceIndex.value >= 0 && bankProvinceIndex.value < areaCityNames.length) { bankCityNames.value = areaCityNames[bankProvinceIndex.value]; bankCityCodes.value = areaCityCodes[bankProvinceIndex.value] }
            merchantTypeIndex.value = merchantType.value == 'individual' ? 1 : 0
            const restoredBusinessValidity = validityOptions.indexOf(businessValidity.value); if (restoredBusinessValidity >= 0) businessValidityIndex.value = restoredBusinessValidity
@@ -265,7 +271,7 @@
    const onIndustryTypeChange = (event: any) => { const index = parseInt(event.detail.value as string); if (index < 0 || index >= industryTypeOptions.length) return; industryTypeIndex.value = index; industryType.value = industryTypeOptions[index]; clearFieldError('industryType') }
    const onBusinessStartChange = (e: any) => { businessStartDate.value = e.detail.value as string; clearFieldError('businessStartDate') }; const onBusinessEndChange = (e: any) => { businessEndDate.value = e.detail.value as string; clearFieldError('businessEndDate') }; const onEstablishedDateChange = (e: any) => { establishedDate.value = e.detail.value as string; clearFieldError('establishedDate') }; const onSubjectStartChange = (e: any) => { subjectStartDate.value = e.detail.value as string; clearFieldError('subjectStartDate') }; const onSubjectEndChange = (e: any) => { subjectEndDate.value = e.detail.value as string; clearFieldError('subjectEndDate') }
    const onRegisteredRegionChange = (e: any) => { const values = e.detail.value as Array<string>; const codes = e.detail.code as Array<string>; if (values.length != 3 || codes.length != 3) return; registeredRegion.value = values[0] + ' ' + values[1] + ' ' + values[2]; registeredProvinceCode.value = codes[0]; registeredCityCode.value = codes[1]; registeredDistrictCode.value = codes[2]; clearFieldError('registeredRegion') }
    const chooseDocument = (kind: string) => { if (uploadingField.value.length > 0) return; uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (r: any) => { const files = r.tempFiles as Array<any>; if (files.length == 0) return; if ((files[0].size as number) > 2097152) { uni.showToast({ title: '图片不能超过2MB', icon: 'none' }); return }; uploadingField.value = kind; uploadMerchantProductImage(files[0].path as string, (url: string) => { uploadingField.value = ''; if (kind == 'businessLicense') { businessLicenseUrl.value = url; clearFieldError('businessLicenseUrl') } else if (kind == 'legalIdFront') { legalIdFrontUrl.value = url; clearFieldError('legalIdFrontUrl') } else if (kind == 'legalIdBack') { legalIdBackUrl.value = url; clearFieldError('legalIdBackUrl') } else if (kind == 'storeHeader') { storeHeaderUrl.value = url; clearFieldError('storeHeaderUrl') } else if (kind == 'storeIndoor') { storeIndoorUrl.value = url; clearFieldError('storeIndoorUrl') } else { settlementProofUrl.value = url; clearFieldError('settlementProofUrl') } }, (error: any) => { uploadingField.value = ''; uni.showToast({ title: error.message != null ? error.message : '图片上传失败,请重试', icon: 'none' }) }) } }) }
    const chooseDocument = (kind: string) => { if (uploadingField.value.length > 0) return; uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: (r: any) => { const files = r.tempFiles as Array<any>; if (files.length == 0) return; if ((files[0].size as number) > 2097152) { uni.showToast({ title: '图片不能超过2MB', icon: 'none' }); return }; uploadingField.value = kind; uploadMerchantProductImage(files[0].path as string, (url: string) => { uploadingField.value = ''; if (kind == 'businessLicense') { businessLicenseUrl.value = url; clearFieldError('businessLicenseUrl') } else if (kind == 'legalIdFront') { legalIdFrontUrl.value = url; clearFieldError('legalIdFrontUrl') } else if (kind == 'legalIdBack') { legalIdBackUrl.value = url; clearFieldError('legalIdBackUrl') } else if (kind == 'storeHeader') { storeHeaderUrl.value = url; clearFieldError('storeHeaderUrl') } else if (kind == 'storeIndoor') { storeIndoorUrl.value = url; clearFieldError('storeIndoorUrl') } else if (kind == 'storeCashier') { storeCashierUrl.value = url; clearFieldError('storeCashierUrl') } else { settlementProofUrl.value = url; clearFieldError('settlementProofUrl') } }, (error: any) => { uploadingField.value = ''; uni.showToast({ title: error.message != null ? error.message : '图片上传失败,请重试', icon: 'none' }) }) } }) }
    const onBankProvinceChange = (event: any) => { const index = parseInt(event.detail.value as string); if (index < 0 || index >= areaProvinceNames.length) return; bankProvinceIndex.value = index; bankProvince.value = areaProvinceNames[index]; bankProvinceCode.value = areaProvinceCodes[index]; bankCityNames.value = areaCityNames[index]; bankCityCodes.value = areaCityCodes[index]; bankCityIndex.value = 0; bankCity.value = ''; bankCityCode.value = ''; bankBranch.value = ''; bankBranchCode.value = ''; bankName.value = '' }
    const onBankCityChange = (event: any) => { const index = parseInt(event.detail.value as string); if (index < 0 || index >= bankCityNames.value.length) return; bankCityIndex.value = index; bankCity.value = bankCityNames.value[index]; bankCityCode.value = bankCityCodes.value[index]; bankBranch.value = ''; bankBranchCode.value = ''; bankName.value = '' }
    const chooseBankBranch = () => { if (bankCityCode.value.length == 0) { uni.showToast({ title: '请先选择开户城市', icon: 'none' }); return }; uni.navigateTo({ url: '/pages/bank-branch-select/bank-branch-select?city=' + encodeURIComponent(bankProvince.value + ' ' + bankCity.value) + '&cityCode=' + encodeURIComponent(bankCityCode.value) }) }
@@ -281,7 +287,7 @@
            }
        }
        submitting.value = true
        submitOnboardingRemote({ subjectType: merchantType.value, subjectName: subjectName.value.trim(), creditCode: creditCode.value, subjectCertificateValidityType: subjectValidity.value == '长期有效' ? 'long_term' : 'fixed', subjectCertificateBeginDate: subjectStartDate.value, subjectCertificateEndDate: subjectEndDate.value, subjectIdNumber: subjectIdNumber.value, licenseValidityType: businessValidity.value == '长期' ? 'long_term' : 'fixed', licenseBeginDate: businessStartDate.value, licenseEndDate: businessEndDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: merchantType.value == 'enterprise' ? registeredAddress.value.trim() : legalCertificateAddress.value.trim(), merchantName: merchantName.value.trim(), merchantShortName: merchantShortName.value.trim(), companyType: merchantType.value == 'enterprise' ? companyType.value : industryType.value, businessLicenseUrl: businessLicenseUrl.value, establishedDate: establishedDate.value, registeredCapital: registeredCapital.value.trim(), legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, legalCertificateAddress: legalCertificateAddress.value.trim(), legalMobile: legalMobile.value.trim(), administratorEmail: administratorEmail.value.trim(), contactName: merchantType.value == 'enterprise' ? contactName.value.trim() : subjectName.value.trim(), contactMobile: contactMobile.value, bankCardNumber: bankCardNumber.value, bankProvinceCode: bankProvinceCode.value, bankCityCode: bankCityCode.value, bankBranchCode: bankBranchCode.value, settlementCardFrontUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? '' : settlementProofUrl.value, registrationAccountUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? settlementProofUrl.value : '', storeHeaderUrl: merchantType.value == 'individual' ? storeHeaderUrl.value : '', storeIndoorUrl: merchantType.value == 'individual' ? storeIndoorUrl.value : '', businessAddress: merchantType.value == 'individual' ? businessAddress.value.trim() : '' }, 'onboarding-' + Date.now(), (result: SettlementOnboarding) => { submitting.value = false; if (result.status == 'approved') clearDraft(); uni.redirectTo({ url: '/pages/merchant-onboarding-pending/merchant-onboarding-pending' }) }, (error: any) => { submitting.value = false; submitError.value = error.message != null ? error.message : '提交失败,请重试'; uni.showToast({ title: submitError.value, icon: 'none' }) })
        submitOnboardingRemote({ subjectType: merchantType.value, subjectName: subjectName.value.trim(), creditCode: creditCode.value, subjectCertificateValidityType: subjectValidity.value == '长期有效' ? 'long_term' : 'fixed', subjectCertificateBeginDate: subjectStartDate.value, subjectCertificateEndDate: subjectEndDate.value, subjectIdNumber: subjectIdNumber.value, licenseValidityType: businessValidity.value == '长期' ? 'long_term' : 'fixed', licenseBeginDate: businessStartDate.value, licenseEndDate: businessEndDate.value, registeredRegion: registeredRegion.value, registeredProvinceCode: registeredProvinceCode.value, registeredCityCode: registeredCityCode.value, registeredDistrictCode: registeredDistrictCode.value, registeredAddress: merchantType.value == 'enterprise' ? registeredAddress.value.trim() : legalCertificateAddress.value.trim(), merchantName: merchantName.value.trim(), merchantShortName: merchantShortName.value.trim(), companyType: merchantType.value == 'enterprise' ? companyType.value : industryType.value, businessLicenseUrl: businessLicenseUrl.value, establishedDate: establishedDate.value, registeredCapital: registeredCapital.value.trim(), legalIdFrontUrl: legalIdFrontUrl.value, legalIdBackUrl: legalIdBackUrl.value, legalCertificateAddress: legalCertificateAddress.value.trim(), legalMobile: legalMobile.value.trim(), administratorEmail: administratorEmail.value.trim(), contactName: merchantType.value == 'enterprise' ? contactName.value.trim() : subjectName.value.trim(), contactMobile: contactMobile.value, bankCardNumber: bankCardNumber.value, bankProvinceCode: bankProvinceCode.value, bankCityCode: bankCityCode.value, bankBranchCode: bankBranchCode.value, settlementCardFrontUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? '' : settlementProofUrl.value, registrationAccountUrl: merchantType.value == 'enterprise' && companyType.value != '个体工商户' ? settlementProofUrl.value : '', storeHeaderUrl: storeHeaderUrl.value, storeIndoorUrl: storeIndoorUrl.value, storeCashierUrl: storeCashierUrl.value, businessAddress: merchantType.value == 'individual' ? businessAddress.value.trim() : '' }, 'onboarding-' + Date.now(), (result: SettlementOnboarding) => { submitting.value = false; if (result.status == 'approved') clearDraft(); uni.redirectTo({ url: '/pages/merchant-onboarding-pending/merchant-onboarding-pending' }) }, (error: any) => { submitting.value = false; submitError.value = error.message != null ? error.message : '提交失败,请重试'; uni.showToast({ title: submitError.value, icon: 'none' }) })
    }
    onShow(() => {
talent-card-admin/src/views/MerchantOnboardingDetailView.vue
@@ -222,8 +222,9 @@
      <h3 class="onboarding-heading">门店照片</h3>
      <div class="detail-grid">
        <div class="detail-field"><span>店铺照片</span><strong><a v-if="detail.storeHeaderUrl" :href="detail.storeHeaderUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div class="detail-field"><span>店铺门头照</span><strong><a v-if="detail.storeHeaderUrl" :href="detail.storeHeaderUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div class="detail-field"><span>店内环境照</span><strong><a v-if="detail.storeIndoorUrl" :href="detail.storeIndoorUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
        <div class="detail-field"><span>店铺收银台</span><strong><a v-if="detail.storeCashierUrl" :href="detail.storeCashierUrl" target="_blank">查看图片</a><template v-else>-</template></strong></div>
      </div>
    </section>
  </div>
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingRequest.java
@@ -42,12 +42,14 @@
        @NotBlank @Pattern(regexp = "\\d{12}") String bankBranchCode,
        @Size(max = 512) String settlementCardFrontUrl,
        @Size(max = 512) String registrationAccountUrl,
        // 门店照片:个人(小微)商户拿到微信收款能力必须交的两张图。线下门店场所口径下是实体门头照;
        // 线上商品服务是店铺首页截图。企业主体不填这两项。
        // 门店照片:**线下经营的材料是一组三张**(门头照 F22、内景照 F24、收银台 F105),
        // 通道在进件这一步就按 F-code 逐张核对,缺一张整笔驳回;企业与个人主体都要交。
        @Size(max = 512) String storeHeaderUrl,
        @Size(max = 512) String storeIndoorUrl,
        // 经营详细地址(进件的 detail_addr):线下口径下个人(小微)商户必填。它**不是**证件地址——
        // registeredAddress 走通道的 legal_addr,两个字段在通道侧语义不同,不要互相顶替。
        // 放在记录末尾是为了不动既有构造点的参数位置(见 MerchantExistingOnboardingTests 的工厂方法)。
        @Size(max = 255) String businessAddress
        @Size(max = 255) String businessAddress,
        // 店铺收银台照片(F105):线下经营门店材料三张里的第三张。同样放末尾,不动既有构造点。
        @Size(max = 512) String storeCashierUrl
) {}
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantOnboardingSchemaInitializer.java
@@ -124,10 +124,11 @@
            columns.put("realname_apply_no", "varchar(32) default null");
            columns.put("realname_response_code", "varchar(32) default null");
            columns.put("realname_response_message", "varchar(512) default null");
            // 门店照片:个人(小微)商户的微信业务开通与微信实名都要这两张图,
            // 企业主体用不到。见 V146 的说明。
            // 门店照片:线下经营的门店材料是**一组三张**(门头照 F22、内景照 F24、收银台 F105),
            // 通道在进件这一步就按 F-code 逐张核对,企业与个人主体都要交。见 V146 与 V156 的说明。
            columns.put("store_header_url", "varchar(512) default null");
            columns.put("store_indoor_url", "varchar(512) default null");
            columns.put("store_cashier_url", "varchar(512) default null");
            // 经营详细地址:线下进件的 detail_addr 是通道必填项,与证件地址分开存。见 V155 的说明。
            columns.put("business_address", "varchar(255) default null");
            for (Map.Entry<String, String> column : columns.entrySet()) {
talent-card-server/src/main/java/com/talentcard/server/business/merchant/MerchantSettlementService.java
@@ -154,6 +154,12 @@
        requireCertificatePeriod(request.subjectCertificateValidityType(), request.subjectCertificateBeginDate(), request.subjectCertificateEndDate(), "Identity document");
        if (blank(request.legalCertificateAddress())) throw new BusinessException(ErrorCode.BAD_REQUEST, enterprise ? "请输入法人证件地址" : "请输入负责人证件地址");
        if (blank(request.administratorEmail()) || !request.administratorEmail().trim().matches("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")) throw new BusinessException(ErrorCode.BAD_REQUEST, "请输入正确的联系人邮箱");
        // 线下报备的经营场所材料是一组**三张**:门头照(F22)、内景照(F24)、收银台(F105)。
        // 通道的「商户材料校验服务」在进件这一步就按 F-code 逐张核对,缺一张整笔驳回——不是等到
        // 业务开通或实名才要,所以必须在这里拦住。**企业(含个体工商户)主体同样要交**:线下按
        // 「有实体经营场所」报备,通道不区分主体类型向门店索要这三张(2026-09-29 实测驳回原文:
        // 「缺失图片:F22-店铺门头照图片;F24-店铺内景图片;F105-店铺收银台」)。
        if (blank(request.storeHeaderUrl()) || blank(request.storeIndoorUrl()) || blank(request.storeCashierUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传店铺门头照、店内环境照与收银台照片,线下报备需要这三张");
        if (enterprise) {
            requireCertificatePeriod(request.licenseValidityType(), request.licenseBeginDate(), request.licenseEndDate(), "Business licence");
            if (blank(request.registeredProvinceCode()) || blank(request.registeredCityCode()) || blank(request.registeredDistrictCode())) throw new BusinessException(ErrorCode.BAD_REQUEST, "Enterprise registration address codes are required");
@@ -161,10 +167,6 @@
            normalizeRegisteredCapital(request.companyType(), request.registeredCapital());
        } else {
            if (blank(request.registeredDistrictCode()) || blank(request.registeredAddress()) || blank(request.legalIdFrontUrl()) || blank(request.legalIdBackUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请补全个人商户进件资料");
            // 汇付对个人(小微)商户的微信业务开通与微信实名认证都要门店照片(门头照 F22 + 内景照 F24)。
            // 放在提交时校验:这两张图是拿到微信收款能力的硬条件,缺了也能把进件提上去,
            // 但后面微信开通和实名一定被通道驳回,那时商户得把整份进件重走一遍。
            if (blank(request.storeHeaderUrl()) || blank(request.storeIndoorUrl())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请上传店铺照片与店内环境照,微信支付开通与实名认证都需要");
            // 经营详细地址(进件的 detail_addr)。线下按「有实体经营场所」口径报备后它是通道必填项,
            // 而证件地址(legal_addr)语义不同、不能顶替——两者原先共用同一个值,这里必须分开采集。
            if (blank(request.businessAddress())) throw new BusinessException(ErrorCode.BAD_REQUEST, "请填写经营详细地址");
@@ -187,7 +189,7 @@
        String no = BusinessIdGenerator.next("MO");
        String certificateValidityType = certificateValidityType(request.subjectCertificateValidityType());
        String certificateBeginDate = certificateBeginDate(request.subjectCertificateBeginDate());
        jdbc.update("insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, credit_code, license_validity_type, license_begin_date, license_end_date, subject_id_no_masked, subject_id_no_encrypted, subject_cert_validity_type, subject_cert_begin_date, subject_cert_end_date, registered_region, registered_province_code, registered_city_code, registered_district_code, registered_address, merchant_name, merchant_short_name, company_type, business_license_url, established_date, registered_capital, legal_id_front_url, legal_id_back_url, settlement_card_front_url, legal_certificate_address, legal_mobile, administrator_email, contact_name, contact_mobile, store_header_url, store_indoor_url, business_address, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), certificateValidityType(request.licenseValidityType()), certificateBeginDate(request.licenseBeginDate()), certificateEndDate(request.licenseEndDate()), maskId(request.subjectIdNumber()), sensitiveDataCrypto.encrypt(clean(request.subjectIdNumber()).toUpperCase()), certificateValidityType, certificateBeginDate, certificateEndDate(request.subjectCertificateEndDate()), clean(request.registeredRegion()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), clean(request.registeredAddress()), clean(request.merchantName()), clean(request.merchantShortName()), clean(request.companyType()), clean(request.businessLicenseUrl()), clean(request.establishedDate()), clean(request.registeredCapital()), clean(request.legalIdFrontUrl()), clean(request.legalIdBackUrl()), clean(settlementProofUrl), clean(request.legalCertificateAddress()), clean(request.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), clean(request.storeHeaderUrl()), clean(request.storeIndoorUrl()), clean(request.businessAddress()), key.trim());
        jdbc.update("insert into merchant_onboarding_application(merchant_id, store_id, application_no, subject_type, subject_name, credit_code, license_validity_type, license_begin_date, license_end_date, subject_id_no_masked, subject_id_no_encrypted, subject_cert_validity_type, subject_cert_begin_date, subject_cert_end_date, registered_region, registered_province_code, registered_city_code, registered_district_code, registered_address, merchant_name, merchant_short_name, company_type, business_license_url, established_date, registered_capital, legal_id_front_url, legal_id_back_url, settlement_card_front_url, legal_certificate_address, legal_mobile, administrator_email, contact_name, contact_mobile, store_header_url, store_indoor_url, store_cashier_url, business_address, idempotency_key) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", principal.merchantId(), principal.storeId(), no, request.subjectType(), request.subjectName().trim(), clean(request.creditCode()), certificateValidityType(request.licenseValidityType()), certificateBeginDate(request.licenseBeginDate()), certificateEndDate(request.licenseEndDate()), maskId(request.subjectIdNumber()), sensitiveDataCrypto.encrypt(clean(request.subjectIdNumber()).toUpperCase()), certificateValidityType, certificateBeginDate, certificateEndDate(request.subjectCertificateEndDate()), clean(request.registeredRegion()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), clean(request.registeredAddress()), clean(request.merchantName()), clean(request.merchantShortName()), clean(request.companyType()), clean(request.businessLicenseUrl()), clean(request.establishedDate()), clean(request.registeredCapital()), clean(request.legalIdFrontUrl()), clean(request.legalIdBackUrl()), clean(settlementProofUrl), clean(request.legalCertificateAddress()), clean(request.legalMobile()), clean(request.administratorEmail()), request.contactName().trim(), request.contactMobile().trim(), clean(request.storeHeaderUrl()), clean(request.storeIndoorUrl()), clean(request.storeCashierUrl()), clean(request.businessAddress()), key.trim());
        String cardNumber = request.bankCardNumber().trim();
        String cardKey = key.trim() + ":initial";
        if (cardKey.length() > 128) cardKey = cardKey.substring(0, 128);
@@ -215,11 +217,12 @@
    }
    /**
     * 存量个人进件缺门店照片、或缺结算银行卡卡号面时,允许商户在重新提交里补上。
     * 存量进件缺门店照片、或缺结算银行卡卡号面时,允许商户在重新提交里补上。
     *
     * <p>门店照片是个人(小微)主体开通微信支付与发起微信实名的硬材料,而分阶段模型下重复提交
     * <p>门店照片是开通微信支付与发起微信实名的硬材料,而分阶段模型下重复提交
     * **不会新建进件**(也就不会在通道上多出一家商户),商户端因此没有别的补图入口——真实开发库里
     * 已经有拿到汇付商户号的个人进件,就是这个形态。
     * 已经有拿到汇付商户号的进件,就是这个形态。三张图(门头照/内景照/收银台)**不分主体类型**都补:
     * 线下报备下企业主体同样被通道索要这三张。
     *
     * <p>结算卡卡号面在这一列补进 INSERT 之前**从来没落过库**(2026-09-18 工单),历史记录全是空值,
     * 库里也没有别的地方能还原它。所以这里一并补上——这是已提交进件唯一的补救入口。
@@ -229,14 +232,14 @@
    private void supplyMissingOnboardingMaterials(Map<String, Object> application, MerchantOnboardingRequest request) {
        if (request == null) return;
        Object id = application.get("id");
        // 门店照片只有个人主体才有,企业主体不提交这两项,沿用原来的判定。
        if ("individual".equalsIgnoreCase(clean(application.get("subjectType")))) {
            if (clean(application.get("storeHeaderUrl")).isBlank() && !blank(request.storeHeaderUrl())) {
                jdbc.update("update merchant_onboarding_application set store_header_url=? where id=?", request.storeHeaderUrl().trim(), id);
            }
            if (clean(application.get("storeIndoorUrl")).isBlank() && !blank(request.storeIndoorUrl())) {
                jdbc.update("update merchant_onboarding_application set store_indoor_url=? where id=?", request.storeIndoorUrl().trim(), id);
            }
        if (clean(application.get("storeHeaderUrl")).isBlank() && !blank(request.storeHeaderUrl())) {
            jdbc.update("update merchant_onboarding_application set store_header_url=? where id=?", request.storeHeaderUrl().trim(), id);
        }
        if (clean(application.get("storeIndoorUrl")).isBlank() && !blank(request.storeIndoorUrl())) {
            jdbc.update("update merchant_onboarding_application set store_indoor_url=? where id=?", request.storeIndoorUrl().trim(), id);
        }
        if (clean(application.get("storeCashierUrl")).isBlank() && !blank(request.storeCashierUrl())) {
            jdbc.update("update merchant_onboarding_application set store_cashier_url=? where id=?", request.storeCashierUrl().trim(), id);
        }
        // 结算凭证图一列两义,补齐时同样要按主体挑对那一张:对私取卡号面、对公取开户许可证。
        // 主体按**库里那条进件**判,不按本次请求——进件已经定性了,不能因为重填了表单就换一张图。
@@ -256,6 +259,11 @@
            String settlementCardPictureId = privateSettlement ? pictureUploadGateway.uploadUrl(request.settlementCardFrontUrl(), "F13") : "";
            String settlementCertFrontPictureId = privateSettlement ? pictureUploadGateway.uploadUrl(request.legalIdFrontUrl(), "F55") : "";
            String settlementCertBackPictureId = privateSettlement ? pictureUploadGateway.uploadUrl(request.legalIdBackUrl(), "F56") : "";
            // 线下经营的门店材料三张(门头照 F22、内景照 F24、收银台 F105):进件这一步就要带上,
            // 通道的材料校验服务逐张按 F-code 核对,缺一张整笔驳回。企业主体原先一张都不发。
            String storeHeaderPictureId = pictureUploadGateway.uploadUrl(request.storeHeaderUrl(), "F22");
            String storeIndoorPictureId = pictureUploadGateway.uploadUrl(request.storeIndoorUrl(), "F24");
            String storeCashierPictureId = pictureUploadGateway.uploadUrl(request.storeCashierUrl(), "F105");
            String shortName = blank(request.merchantShortName()) ? request.merchantName().trim() : request.merchantShortName().trim();
            HuifuEnterpriseOnboardingGateway.Result result = enterpriseOnboardingGateway.create(new HuifuEnterpriseOnboardingCommand(
                    applicationNo, clean(request.merchantName()), shortName, request.creditCode().trim(),
@@ -267,7 +275,8 @@
                    privateSettlement ? "1" : "0", privateSettlement ? request.subjectName().trim() : request.merchantName().trim(), request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), clean(branch.get("clearingCode")), clean(branch.get("branchName")),
                    privateSettlement ? request.subjectIdNumber().trim().toUpperCase() : "", privateSettlement ? certificateValidityType(request.subjectCertificateValidityType()) : "", privateSettlement ? certificateBeginDate(request.subjectCertificateBeginDate()) : "", privateSettlement ? certificateEndDate(request.subjectCertificateEndDate()) : "", privateSettlement ? request.contactMobile().trim() : "",
                    registrationAccountPictureId, settlementCardPictureId, settlementCertFrontPictureId, settlementCertBackPictureId,
                    certificateBeginDate(request.establishedDate()), normalizeRegisteredCapital(request.companyType(), request.registeredCapital())));
                    certificateBeginDate(request.establishedDate()), normalizeRegisteredCapital(request.companyType(), request.registeredCapital()),
                    storeHeaderPictureId, storeIndoorPictureId, storeCashierPictureId));
            applyOnboardingResult(applicationNo, bankCardId, result.accepted(), result.huifuId(), result.applyNo(), result.tokenNo(), result.code(), result.message());
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("update merchant_onboarding_application set reject_reason=? where application_no=? and status='pending'", "Huifu submission outcome is pending confirmation", applicationNo);
@@ -281,10 +290,14 @@
            String settlementCardPictureId = pictureUploadGateway.uploadUrl(request.settlementCardFrontUrl(), "F13");
            // 店铺照片:个人(小微)商户进件的必填项,三种经营类型都要传,文件类型固定 F22。
            String storeHeaderPictureId = pictureUploadGateway.uploadUrl(request.storeHeaderUrl(), "F22");
            // 线下经营的门店材料是**一组三张**,与门头照一起在进件这一步交上去(见企业路径的注释)。
            String storeIndoorPictureId = pictureUploadGateway.uploadUrl(request.storeIndoorUrl(), "F24");
            String storeCashierPictureId = pictureUploadGateway.uploadUrl(request.storeCashierUrl(), "F105");
            HuifuIndividualOnboardingGateway.Result result = individualOnboardingGateway.create(new HuifuIndividualOnboardingCommand(
                    applicationNo, request.subjectName().trim(), request.subjectIdNumber().trim().toUpperCase(), certificateValidityType(request.subjectCertificateValidityType()), certificateBeginDate(request.subjectCertificateBeginDate()), certificateEndDate(request.subjectCertificateEndDate()), request.contactMobile().trim(), clean(request.registeredAddress()), request.administratorEmail().trim(),
                    mcc(principal.merchantId(), "individual", request.companyType()), clean(request.registeredProvinceCode()), clean(request.registeredCityCode()), clean(request.registeredDistrictCode()), legalFrontPictureId, legalBackPictureId,
                    request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), settlementCardPictureId, storeHeaderPictureId, clean(request.businessAddress())));
                    request.bankCardNumber().trim(), request.bankProvinceCode().trim(), request.bankCityCode().trim(), clean(branch.get("headBankCode")), settlementCardPictureId, storeHeaderPictureId, clean(request.businessAddress()),
                    storeIndoorPictureId, storeCashierPictureId));
            applyOnboardingResult(applicationNo, bankCardId, result.accepted(), result.huifuId(), result.applyNo(), result.tokenNo(), result.code(), result.message());
        } catch (HuifuPaymentGatewayException exception) {
            jdbc.update("update merchant_onboarding_application set reject_reason=? where application_no=? and status='pending'", "Huifu submission outcome is pending confirmation", applicationNo);
@@ -628,29 +641,31 @@
            // 个体工商户走的是企业进件接口,所以不是 individual,但同样要带 mcc。
            // 注意:个人主体的 company_type 存的是进件时选的经营行业,不会等于「个体工商户」。
            boolean mccRequired = individual || "个体工商户".equals(clean(application.get("companyType")));
            // 个人(小微)商户的门店照片必须随业务开通一起交上去。缺图就直接收口成失败、
            // 不发这次请求:发出去也只会被通道驳回,还会在通道侧留下一条缺材料的申请。
            // 线下经营的门店材料三张(门头照 F22、内景照 F24、收银台 F105)必须随业务开通一起交上去,
            // **与主体类型无关**:线下按「有实体经营场所」报备,通道对企业和个人都索要这一组。
            // 缺图就直接收口成失败、不发这次请求:发出去也只会被通道驳回,还会在通道侧留下一条缺材料的申请。
            // 商户在进件页补上照片重新提交即可(见 supplyMissingOnboardingMaterials),下次刷新会带材料重发。
            String storeHeaderFileId = "";
            String storeIndoorFileId = "";
            if (individual) {
                String storeHeaderUrl = clean(application.get("storeHeaderUrl"));
                String storeIndoorUrl = clean(application.get("storeIndoorUrl"));
                if (storeHeaderUrl.isBlank() || storeIndoorUrl.isBlank()) {
                    jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "商户业务开通失败:个人商户开通微信支付需要店铺照片与店内环境照,请在进件页补上这两张图后重新提交", application.get("id"));
                    return;
                }
                try {
                    storeHeaderFileId = pictureUploadGateway.uploadUrl(storeHeaderUrl, "F22");
                    storeIndoorFileId = pictureUploadGateway.uploadUrl(storeIndoorUrl, "F24");
                } catch (BusinessException | HuifuPaymentGatewayException exception) {
                    jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "门店照片上传失败:" + exception.getMessage(), application.get("id"));
                    return;
                }
            String storeHeaderFileId;
            String storeIndoorFileId;
            String storeCashierFileId;
            String storeHeaderUrl = clean(application.get("storeHeaderUrl"));
            String storeIndoorUrl = clean(application.get("storeIndoorUrl"));
            String storeCashierUrl = clean(application.get("storeCashierUrl"));
            if (storeHeaderUrl.isBlank() || storeIndoorUrl.isBlank() || storeCashierUrl.isBlank()) {
                jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "商户业务开通失败:线下报备需要店铺门头照、店内环境照与收银台照片,请在进件页补上这三张图后重新提交", application.get("id"));
                return;
            }
            try {
                storeHeaderFileId = pictureUploadGateway.uploadUrl(storeHeaderUrl, "F22");
                storeIndoorFileId = pictureUploadGateway.uploadUrl(storeIndoorUrl, "F24");
                storeCashierFileId = pictureUploadGateway.uploadUrl(storeCashierUrl, "F105");
            } catch (BusinessException | HuifuPaymentGatewayException exception) {
                jdbc.update("update merchant_onboarding_application set business_open_status='failed', reject_reason=? where id=?", "门店照片上传失败:" + exception.getMessage(), application.get("id"));
                return;
            }
            jdbc.update("update merchant_onboarding_application set status='pending', business_open_status='processing', business_open_request_no=?, reject_reason=null, reviewed_at=null where id=?", requestNo, application.get("id"));
            try {
                HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId, subjectType, clean(application.get("companyType"))), individual, mccRequired, storeHeaderFileId, storeIndoorFileId);
                HuifuMerchantBusinessOpenGateway.Result result = merchantBusinessOpenGateway.open(requestNo, huifuId, shortName, mcc(merchantId, subjectType, clean(application.get("companyType"))), individual, mccRequired, storeHeaderFileId, storeIndoorFileId, storeCashierFileId);
                if (!result.accepted()) {
                    jdbc.update("update merchant_onboarding_application set business_open_status='failed', business_open_apply_no=nullif(?,''), business_open_response_code=?, business_open_response_message=?, reject_reason=? where id=?", clean(result.applyNo()), result.code(), result.message(), "商户业务开通失败:" + providerReason(result.code(), result.message()), application.get("id"));
                    return;
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingCommand.java
@@ -44,5 +44,10 @@
        String settlementCertificateFrontPictureId,
        String settlementCertificateBackPictureId,
        String foundDate,
        String registeredCapital
        String registeredCapital,
        // 线下经营的门店材料(门头照 F22、内景照 F24、收银台 F105):通道在进件这一步按
        // F-code 逐张核对,三张缺一不可。
        String storeHeaderPictureId,
        String storeIndoorPictureId,
        String storeCashierPictureId
) {}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuEnterpriseOnboardingGateway.java
@@ -120,6 +120,12 @@
        putIfPresent(request, "settle_card_front_pic", command.settlementCardFrontPictureId());
        putIfPresent(request, "settle_cert_front_pic", command.settlementCertificateFrontPictureId());
        putIfPresent(request, "settle_cert_back_pic", command.settlementCertificateBackPictureId());
        // 线下经营的门店材料:门头照(F22)、内景照(F24)、收银台(F105)。通道的「商户材料校验
        // 服务」在进件这一步按 F-code 逐张核对,缺一张整笔驳回。企业主体原先一张都不发,
        // 线下口径下进件直接被驳回(报错原文与字段名见 HuifuIndividualOnboardingGateway)。
        putIfPresent(request, "store_header_pic", command.storeHeaderPictureId());
        putIfPresent(request, "store_indoor_pic", command.storeIndoorPictureId());
        putIfPresent(request, "store_cashier_desk_pic", command.storeCashierPictureId());
        request.put("cash_config", JSON.toJSONString(List.of(cash)));
        request.put("settle_config", JSON.toJSONString(settleConfig()));
        putIfPresent(request, "async_return_url", properties.getMerchantOnboardingNotifyUrl());
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingCommand.java
@@ -25,5 +25,9 @@
        // (线下门店场所=实体门头照、流动经营=经营现场照、线上商品服务=店铺首页截图)。
        String storeHeaderPictureId,
        // 经营详细地址(请求里的 detail_addr)。线下口径下是通道必填项,与证件地址(legal_addr)分开。
        String businessAddress
        String businessAddress,
        // 店内环境照(F24)与店铺收银台照(F105)。线下经营的门店材料是**一组三张**,
        // 通道在进件这一步就按 F-code 逐张核对,缺一张整笔驳回。
        String storeIndoorPictureId,
        String storeCashierPictureId
) {}
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingGateway.java
@@ -103,6 +103,12 @@
        // 线上商品/服务交易传店铺首页截图、流动经营/便民服务传经营现场照、门店场所传实体门头照。
        // 这里只负责把商户交的图带上,内容按经营类型由商户端提示。
        request.put("store_header_pic", command.storeHeaderPictureId());
        // 线下经营的门店材料是一组三张:门头照(F22)、内景照(F24)、收银台(F105)。通道的
        // 「商户材料校验服务」在进件这一步按 F-code 逐张核对,缺一张就整笔驳回——报错原文
        // 「缺失图片:F22-店铺门头照图片;F24-店铺内景图片;F105-店铺收银台」(2026-09-29 实测)。
        // 字段名 store_indoor_pic / store_cashier_desk_pic 取自通道自己的报错原文。
        putIfPresent(request, "store_indoor_pic", command.storeIndoorPictureId());
        putIfPresent(request, "store_cashier_desk_pic", command.storeCashierPictureId());
        request.put("cash_config", JSON.toJSONString(List.of(cash)));
        // 与商户主体一致:进件即打开自动结算,核销确认后的钱由斗拱按结算周期自动划到结算卡。
        request.put("settle_config", JSON.toJSONString(HuifuEnterpriseOnboardingGateway.settleConfig()));
talent-card-server/src/main/java/com/talentcard/server/business/payment/huifu/HuifuMerchantBusinessOpenGateway.java
@@ -31,10 +31,11 @@
     * @param mccRequired        必须下发 {@code mcc}:个体工商户与小微商户(个人主体)都算。
     *                           与 {@code individualMerchant} 分开,因为个体工商户走的是企业进件接口,
     *                           它不是"个人主体",但官方同样要求 mcc 必填
     * @param storeCashierFileId 店铺收银台照(F105):线下经营门店材料三张里的第三张,与主体类型无关
     */
    public Result open(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) {
    public Result open(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId, String storeCashierFileId) {
        ensureConfigured();
        Map<String, Object> request = buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId);
        Map<String, Object> request = buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId, storeCashierFileId);
        try {
            synchronized (SDK_LOCK) {
                initializeSdk();
@@ -67,10 +68,14 @@
    }
    static Map<String, Object> buildRequestForTest(HuifuPaymentProperties properties, String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) {
        return new HuifuMerchantBusinessOpenGateway(properties).buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId);
        return buildRequestForTest(properties, requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId, "");
    }
    private Map<String, Object> buildRequest(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId) {
    static Map<String, Object> buildRequestForTest(HuifuPaymentProperties properties, String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId, String storeCashierFileId) {
        return new HuifuMerchantBusinessOpenGateway(properties).buildRequest(requestNo, huifuId, shortName, mcc, individualMerchant, mccRequired, storeHeaderFileId, storeIndoorFileId, storeCashierFileId);
    }
    private Map<String, Object> buildRequest(String requestNo, String huifuId, String shortName, String mcc, boolean individualMerchant, boolean mccRequired, String storeHeaderFileId, String storeIndoorFileId, String storeCashierFileId) {
        String feeRuleId = feeRuleId(individualMerchant, mccRequired);
        List<Map<String, Object>> wechatList = new ArrayList<>();
        // 支付场景来自配置(默认线下 1/2/3,见 application.yml 的 merchant-wechat-scenes)。
@@ -125,14 +130,15 @@
        request.put("upper_huifu_id", properties.getOnboardingUpperHuifuId().trim());
        request.put("short_name", shortName);
        request.put("wx_conf_list", JSON.toJSONString(wechatList));
        // 个人(小微)商户填了 micro_biz_type,就同时欠这两张门店照片:官方在 micro_biz_type 的说明里
        // 写明「需要提供 F22:店铺门头照图片;F24:店铺内景图片,补充资料填写在 extended_material_list
        // 对象中」。企业主体没有这项要求,不下发(避免给企业商户凭空加材料要求)。
        // 门店材料是一组三张:门头照(F22)、内景照(F24)、收银台(F105)。官方在 micro_biz_type 的
        // 说明里点名前两张要填进 extended_material_list;第三张是线下经营的必需项,出处是通道材料校验
        // 的报错原文(见 HuifuIndividualOnboardingGateway)。**企业主体也交这三张**:线下报备按
        // 「有实体经营场所」口径索要材料,不分主体类型,不交就被驳回(2026-09-29 实测)。
        // putMaterial 会跳过空值,所以某张图确实没有时不会留下残缺条目(空列表则整个字段不下发)。
        List<Map<String, Object>> extendedMaterials = new ArrayList<>();
        if (individualMerchant) {
            putMaterial(extendedMaterials, "F22", storeHeaderFileId);
            putMaterial(extendedMaterials, "F24", storeIndoorFileId);
        }
        putMaterial(extendedMaterials, "F22", storeHeaderFileId);
        putMaterial(extendedMaterials, "F24", storeIndoorFileId);
        putMaterial(extendedMaterials, "F105", storeCashierFileId);
        if (!extendedMaterials.isEmpty()) request.put("extended_material_list", JSON.toJSONString(extendedMaterials));
        // online_busi_type 不下发:官方口径是「基本信息入驻接口 scene_type=ONLINE/ALL 时必填」,它是线上
        // 业务类型编码,属于**进件**请求的字段(我们从来只在业务开通里发过它,是个历史错位)。统一走线下
talent-card-server/src/main/resources/db/migration/V156__merchant_onboarding_store_cashier.sql
New file
@@ -0,0 +1,26 @@
-- 店铺收银台照片(F105):线下经营「门店材料」三张里的第三张(门头照 F22、内景照 F24、收银台 F105)。
--
-- 背景:进件的 scene_type 统一改成 OFFLINE 之后,通道按「有实体经营场所」口径索要材料,且是在**进件
-- 这一步**就由「商户材料校验服务」按 F-code 逐张核对——缺一张整笔驳回,不是等到业务开通或实名才要。
-- 实测驳回原文:调用商户材料校验服务返回,校验失败 缺失图片:F22-店铺门头照图片;F24-店铺内景图片;
-- F105-店铺收银台。在这之前我们只收两张(且只对个人主体收),三张里的收银台全仓没有任何采集口。
--
-- 存量数据不回填:历史进件的商户要补图时走「重新提交」路径(supplyMissingOnboardingMaterials 只补
-- 空着的那几项),本地这一列为空不影响任何读路径。
--
-- 用存储过程按 exists 判断追加,使脚本可以重复执行(本地开发的库常常手工加过列)。
DROP PROCEDURE IF EXISTS add_store_cashier_url_column;
DELIMITER //
CREATE PROCEDURE add_store_cashier_url_column()
BEGIN
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns
                   WHERE table_schema = DATABASE() AND table_name = 'merchant_onboarding_application'
                     AND column_name = 'store_cashier_url') THEN
        ALTER TABLE merchant_onboarding_application
            ADD COLUMN store_cashier_url VARCHAR(512) DEFAULT NULL
                COMMENT '店铺收银台照片(线下经营门店材料 F105)';
    END IF;
END //
DELIMITER ;
CALL add_store_cashier_url_column();
DROP PROCEDURE add_store_cashier_url_column;
talent-card-server/src/test/java/com/talentcard/server/business/merchant/MerchantExistingOnboardingTests.java
@@ -308,7 +308,7 @@
        assertThat(statusOf("wechat_config_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved");
        assertThat(statusOf("business_open_status",String.valueOf(fixture.get("applicationNo")))).isEqualTo("approved");
        verify(wechatConfigGateway,never()).configure(anyString(),anyString());
        verify(businessOpenGateway,never()).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString());
        verify(businessOpenGateway,never()).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),any(),any(),any());
    }
    /**
@@ -326,7 +326,7 @@
        MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal");
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N"));
        when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(true,"BO-APPLY","90000000","审核中"));
        when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),any(),any(),any())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(true,"BO-APPLY","90000000","审核中"));
        // 第一轮:通道侧还没有微信业务 → 只提交业务开通,配置这一步根本不碰。
        service.refreshOnboarding(principal,false);
@@ -343,7 +343,7 @@
        service.refreshOnboarding(principal,false);
        // 业务开通只提交过一次——通道已开通时不再重复提交,避免第二份申请。
        verify(businessOpenGateway,times(1)).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString());
        verify(businessOpenGateway,times(1)).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),any(),any(),any());
        verify(wechatConfigGateway,times(1)).configure(anyString(),anyString());
        assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("approved");
        assertThat(statusOf("wechat_config_status",applicationNo)).isEqualTo("approved");
@@ -400,7 +400,7 @@
        String applicationNo=String.valueOf(fixture.get("applicationNo"));
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N"));
        when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(false,"","00000001","wx_conf_list格式错误"));
        when(businessOpenGateway.open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),any(),any(),any())).thenReturn(new com.talentcard.server.business.payment.huifu.HuifuMerchantBusinessOpenGateway.Result(false,"","00000001","wx_conf_list格式错误"));
        MerchantPrincipal principal=(MerchantPrincipal)fixture.get("principal");
        service.refreshOnboarding(principal,false);
@@ -408,15 +408,36 @@
        service.refreshOnboarding(principal,false);
        var requests=org.mockito.ArgumentCaptor.forClass(String.class);
        verify(businessOpenGateway,times(2)).open(requests.capture(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),anyString(),anyString());
        verify(businessOpenGateway,times(2)).open(requests.capture(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),any(),any(),any());
        assertThat(requests.getAllValues()).doesNotHaveDuplicates();
        assertThat(requests.getAllValues().get(0)).isEqualTo(applicationNo+"BO");
        assertThat(statusOf("business_open_request_no",applicationNo)).isEqualTo(requests.getAllValues().get(1));
    }
    /**
     * 门店材料缺一张就不发业务开通请求:通道按 F-code 逐张核对,缺图发出去只会被驳回,
     * 还会在通道侧留下一条缺材料的申请。进件收口成 failed,并明确告诉商户去进件页补哪三张图。
     *
     * <p>2026-09-29 的真实驳回就是这条口径:线下报备要「门头照 + 内景照 + 收银台」三张,
     * 企业主体原先一张都不交。这里盯住的是「缺图不要发注定被拒的请求」这道闸门,以及它给出的补图提示。
     */
    @Test
    void aFailedWechatConfigIsRetriedWithANewRequestNumber() {
    void aMissingStorePhotoStopsTheBusinessOpenBeforeItIsSent() {
        Map<String,Object> fixture=provisioningFixture();
        String applicationNo=String.valueOf(fixture.get("applicationNo"));
        jdbc.update("update merchant_onboarding_application set store_cashier_url=null where application_no=?",applicationNo);
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        when(gateway.queryCapabilities(anyString())).thenReturn(Map.of("resp_code","00000000","mer_stat","NORMAL","delay_flag","Y","forced_delay_flag","N"));
        service.refreshOnboarding((MerchantPrincipal)fixture.get("principal"),false);
        verify(businessOpenGateway,never()).open(anyString(),anyString(),anyString(),anyString(),anyBoolean(),anyBoolean(),any(),any(),any());
        assertThat(statusOf("business_open_status",applicationNo)).isEqualTo("failed");
        assertThat(statusOf("reject_reason",applicationNo)).contains("收银台");
    }
    @Test
    void aFailedWechatConfigIsRetriedWithANewRequestNumber() {        Map<String,Object> fixture=provisioningFixture();
        String applicationNo=String.valueOf(fixture.get("applicationNo"));
        when(gateway.queryApplication(anyString(),anyString())).thenReturn(Map.of("resp_code","00000000","apply_status","Y","reg_status","S"));
        // 通道侧已开通线上场景,配置这一步才有东西可挂(见 advanceWechatConfig 的闸门)。
@@ -552,7 +573,7 @@
        Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant);
        jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key);
        Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key);
        jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id,business_open_status) values (?, ?, ?, 'individual','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'failed')",merchant,store,key,key,card);
        jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id,business_open_status,store_header_url,store_indoor_url,store_cashier_url) values (?, ?, ?, 'individual','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'failed','https://cdn.example.com/store-header.jpg','https://cdn.example.com/store-indoor.jpg','https://cdn.example.com/store-cashier.jpg')",merchant,store,key,key,card);
        return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key);
    }
@@ -564,7 +585,7 @@
    private static MerchantOnboardingRequest request(String storeHeaderUrl,String storeIndoorUrl,String settlementCardFrontUrl) {
        return new MerchantOnboardingRequest("individual","Owner",null,null,null,null,null,null,null,null,
                null,null,"510100",null,null,null,null,null,null,null,null,null,null,null,null,null,
                "Owner","13700000000",null,null,"510100",null,settlementCardFrontUrl,null,storeHeaderUrl,storeIndoorUrl,"四川省成都市武侯区某路1号");
                "Owner","13700000000",null,null,"510100",null,settlementCardFrontUrl,null,storeHeaderUrl,storeIndoorUrl,"四川省成都市武侯区某路1号",null);
    }
    /**
@@ -663,14 +684,15 @@
                "https://cdn.example.com/legal-front.jpg","https://cdn.example.com/legal-back.jpg","四川省成都市武侯区某路1号",null,"owner@example.com",
                "测试联系人","13700000001","6222000012345678","510000","510100",clearingCode,
                null,"https://cdn.example.com/registration-account.jpg",
                null,null,null);
                "https://cdn.example.com/store-header.jpg","https://cdn.example.com/store-indoor.jpg",null,
                "https://cdn.example.com/store-cashier.jpg");
    }
    /** 对公主体重提时要补的是开户许可证那一张(请求里走 registrationAccountUrl)。 */
    private static MerchantOnboardingRequest registrationAccountRequest(String registrationAccountUrl) {
        return new MerchantOnboardingRequest("enterprise","Owner",null,null,null,null,null,null,null,null,
                null,null,"510100",null,null,null,null,null,null,null,null,null,null,null,null,null,
                "Owner","13700000000",null,null,"510100",null,null,registrationAccountUrl,null,null,null);
                "Owner","13700000000",null,null,"510100",null,null,registrationAccountUrl,null,null,null,null);
    }
    /** 个人进件的一次完整提交(除门店照片与结算卡卡号面外的必填项也都给上)。 */
@@ -682,7 +704,8 @@
                "https://cdn.example.com/id-front.jpg","https://cdn.example.com/id-back.jpg","四川省邻水县丰禾镇9组108号","13700000000","owner@example.com",
                "测试负责人","13700000000","6222000012345678","510000","510100",clearingCode,
                "https://cdn.example.com/card-front.jpg",null,
                "https://cdn.example.com/store-header.jpg","https://cdn.example.com/store-indoor.jpg","四川省邻水县丰禾镇9组108号");
                "https://cdn.example.com/store-header.jpg","https://cdn.example.com/store-indoor.jpg","四川省邻水县丰禾镇9组108号",
                "https://cdn.example.com/store-cashier.jpg");
    }
    /** 建一张还停在 not_started 的进件,用来观察刷新会不会去推进开通步骤。 */
@@ -694,7 +717,7 @@
        Long store=jdbc.queryForObject("select id from merchant_store where merchant_id=?",Long.class,merchant);
        jdbc.update("insert into merchant_bank_card(merchant_id,store_id,bank_name,branch_name,city_code,card_tail,card_masked,card_hash,status,idempotency_key) values (?,?,'Bank','Branch','510100','1234','****1234',?,'approved',?)",merchant,store,key,key);
        Long card=jdbc.queryForObject("select id from merchant_bank_card where idempotency_key=?",Long.class,key);
        jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?)",merchant,store,key,key,card);
        jdbc.update("insert into merchant_onboarding_application(merchant_id,store_id,application_no,subject_type,subject_name,contact_name,contact_mobile,idempotency_key,status,provider_huifu_id,provider_apply_no,initial_bank_card_id,store_header_url,store_indoor_url,store_cashier_url) values (?, ?, ?, 'enterprise','Owner','Owner','13700000000',?,'pending','6666000241335656','original-apply',?,'https://cdn.example.com/store-header.jpg','https://cdn.example.com/store-indoor.jpg','https://cdn.example.com/store-cashier.jpg')",merchant,store,key,key,card);
        return Map.of("principal",new MerchantPrincipal(1L,merchant,store,"owner"),"applicationNo",key);
    }
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuBusinessOpenRequiredFieldsTests.java
@@ -19,7 +19,8 @@
 *       是否一致。平台用一个运营媒介给众多子商户开通,主体天然不一致,取 N。它属于媒介**元素**而不是顶层参数,
 *       放顶层会被拒:「【media_merCommon_flag-媒介主体与商户主体是否一致】不能为空,或格式非法」。</li>
 *   <li>{@code micro_biz_type}(wx_conf_list 元素内的 wx_real_name_info):个人商户开通微信业务必填,
 *       且三种取值都要求 F22 店铺门头照 + F24 店铺内景照。</li>
 *       且线下经营的经营场所材料是一组三张——F22 店铺门头照、F24 店铺内景、F105 店铺收银台,企业与
 *       个人主体都要交(见下文的材料用例)。</li>
 * </ul>
 *
 * <p>层级依据:文档里 {@code wx_real_name_info} 既是父字段列、又有同名子表,按本接口的写法约定
@@ -99,20 +100,22 @@
    }
    /**
     * 个人(小微)商户填了 {@code micro_biz_type},就欠这两张门店照片:官方在 micro_biz_type
     * 的说明里写明「需要提供 F22:店铺门头照图片;F24:店铺内景图片,补充资料填写在
     * extended_material_list 对象中」。原先只有 micro_biz_type、没有材料,开通是缺材料提交。
     * 门店材料是线下经营的一组**三张**:门头照(F22)、内景照(F24)、收银台(F105)。官方在
     * micro_biz_type 的说明里点名前两张要填进 {@code extended_material_list};第三张(F105)是
     * 通道材料校验报错里给出的(「缺失图片:F22-店铺门头照图片;F24-店铺内景图片;F105-店铺收银台」,
     * 2026-09-29 实测)。原先只发两张、且只有个人主体发。
     */
    @Test
    void anIndividualMerchantCarriesTheStorePhotosAsExtendedMaterial() {
        Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
                properties(), "BO-TEST-MATERIAL", "6666000241390821", "阳野", "5812", true, true, "file-f22", "file-f24");
                properties(), "BO-TEST-MATERIAL", "6666000241390821", "阳野", "5812", true, true, "file-f22", "file-f24", "file-f105");
        List<Map<String, Object>> materials =
                JSON.parseObject(String.valueOf(request.get("extended_material_list")), List.class);
        assertThat(materials).hasSize(2);
        assertThat(materials).hasSize(3);
        assertThat(materials.get(0)).containsEntry("file_type", "F22").containsEntry("file_id", "file-f22");
        assertThat(materials.get(1)).containsEntry("file_type", "F24").containsEntry("file_id", "file-f24");
        assertThat(materials.get(2)).containsEntry("file_type", "F105").containsEntry("file_id", "file-f105");
    }
    /** 门店照片缺一张就只发一张:宁可让通道明确报缺哪一项,也不要塞占位图顶上。 */
@@ -127,11 +130,27 @@
        assertThat(materials.get(0)).containsEntry("file_type", "F22");
    }
    /** 企业主体没有这项材料要求,不要凭空给企业商户加附件。 */
    /**
     * 企业主体同样交这三张。线下报备按「有实体经营场所」口径索要材料,通道不分主体类型;原先
     * {@code extended_material_list} 只在 {@code individualMerchant} 为真时下发,企业进件的门店材料
     * 一张都到不了通道(2026-09-29 企业主体进件被材料校验整笔驳回的直接原因)。
     */
    @Test
    void anEnterpriseMerchantCarriesNoExtendedMaterial() {
    void anEnterpriseMerchantCarriesTheSameStoreMaterialGroup() {
        Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
                properties(), "BO-TEST-MATERIAL-2", "6666000241390821", "成都喜望科技", "5812", false, false, "file-f22", "file-f24");
                properties(), "BO-TEST-MATERIAL-2", "6666000241390821", "成都喜望科技", "5812", false, false, "file-f22", "file-f24", "file-f105");
        List<Map<String, Object>> materials =
                JSON.parseObject(String.valueOf(request.get("extended_material_list")), List.class);
        assertThat(materials).hasSize(3);
        assertThat(materials.get(2)).containsEntry("file_type", "F105").containsEntry("file_id", "file-f105");
    }
    /** 三张图都没有时整个字段不下发,而不是发一个空数组。 */
    @Test
    void noStorePhotoMeansNoExtendedMaterialField() {
        Map<String, Object> request = HuifuMerchantBusinessOpenGateway.buildRequestForTest(
                properties(), "BO-TEST-MATERIAL-3", "6666000241390821", "成都喜望科技", "5812", false, false, "", "", "");
        assertThat(request).doesNotContainKey("extended_material_list");
    }
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuIndividualOnboardingRequiredFieldsTests.java
@@ -31,6 +31,35 @@
        assertThat(request).containsEntry("settle_card_front_pic", "F13-CARD");
    }
    /**
     * 线下经营的经营场所材料是**一组三张**:门头照(F22)、内景照(F24)、收银台(F105)。通道的
     * 「商户材料校验服务」在进件这一步就按 F-code 逐张核对,只发门头照会被整笔驳回——报错原文
     * 「缺失图片:F22-店铺门头照图片;F24-店铺内景图片;F105-店铺收银台」(2026-09-29 实测)。
     */
    @Test
    void theOfflineStoreMaterialGroupCarriesAllThreePhotos() {
        Map<String, Object> request = request(command("0", "20120110", "20320110"));
        assertThat(request).containsEntry("store_indoor_pic", "F24-STORE-INDOOR");
        assertThat(request).containsEntry("store_cashier_desk_pic", "F105-STORE-CASHIER");
    }
    /** 新增的两张图没交时不下发空字符串:空值会换来「参数格式」类的报错,把「缺材料」这条真原因淹掉。 */
    @Test
    void missingStorePhotosAreOmittedInsteadOfSentAsEmptyStrings() {
        HuifuIndividualOnboardingCommand withoutPhotos = new HuifuIndividualOnboardingCommand(
                "MO-TEST-1", "阳野", "511602199803310019", "0", "20120110", "20320110", "15708261096",
                "四川省广安市广安区兴安中街227号3栋1单元6楼2号", "1347747024@qq.com", "5812",
                "510000", "510100", "510104", "F40-ID", "F41-ID",
                "6217003680002760924", "510000", "511600", "01050000", "F13-CARD", "",
                "四川省广安市广安区兴安中街228号1栋101", "", "");
        Map<String, Object> request = request(withoutPhotos);
        assertThat(request).doesNotContainKey("store_indoor_pic");
        assertThat(request).doesNotContainKey("store_cashier_desk_pic");
    }
    @Test
    void aLongTermCertificateOmitsBothDeadlines() {
        Map<String, Object> request = request(command("1", "20120110", ""));
@@ -112,6 +141,7 @@
                "510000", "510100", "510104", "F40-ID", "F41-ID",
                "6217003680002760924", "510000", "511600", "01050000", "F13-CARD", "F22-STORE",
                // 经营详细地址刻意与证件地址(第 8 个参数)用不同字符串:取错成证件地址时断言能发现。
                "四川省广安市广安区兴安中街228号1栋101");
                "四川省广安市广安区兴安中街228号1栋101",
                "F24-STORE-INDOOR", "F105-STORE-CASHIER");
    }
}
talent-card-server/src/test/java/com/talentcard/server/business/payment/huifu/HuifuOnboardingMerchantLevelTests.java
@@ -93,7 +93,8 @@
                "四川省广安市广安区兴安中街227号3栋1单元6楼2号", "1347747024@qq.com", "5812",
                "510000", "510100", "510104", "F40-ID", "F41-ID",
                "6217003680002760924", "510000", "511600", "01050000", "F13-CARD", "F22-STORE",
                "四川省广安市广安区兴安中街227号3栋1单元6楼2号");
                "四川省广安市广安区兴安中街227号3栋1单元6楼2号",
                "F24-STORE-INDOOR", "F105-STORE-CASHIER");
    }
    private static HuifuEnterpriseOnboardingCommand enterpriseCommand() {
@@ -105,6 +106,7 @@
                "0", "成都喜望科技有限公司", "6217003680002760924", "510000", "510100", "01050000",
                "CCB510400", "中国建设银行攀枝花东区支行营业部", "", "", "", "", "",
                "F08-ACCT", "", "", "",
                "20180110", "100.00");
                "20180110", "100.00",
                "F22-STORE", "F24-STORE-INDOOR", "F105-STORE-CASHIER");
    }
}