package com.talentcard.server.business.admin;
import com.talentcard.server.business.DatabaseRows;
import com.talentcard.server.business.merchant.MerchantOnboardingProgressService;
import com.talentcard.server.business.merchant.MerchantPaymentReadinessService;
import com.talentcard.server.business.payment.huifu.HuifuOnboardingProgressGateway;
import com.talentcard.server.common.BusinessException;
import com.talentcard.server.common.ErrorCode;
import com.talentcard.server.common.SensitiveDataCrypto;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
/**
* 外部进件商户的纳管与解绑:把「已在汇付侧完成进件」的商户接到我方某个商家上,使它能直接用我们这套
* 进件记录、分阶段进度与收款闸门收款;绑错了能解绑重来。
*
*
为什么不新造链路:纳管产出的就是一条进件记录。它复用既有的进件记录、六阶段进度、收款能力快照
* 与收款就绪判定,只在「写入口」和「来源标记」两处新增——{@code provider_huifu_id} 原先三个写入口
* 全部来自汇付回执,纳管是第四个(见 V157 的 source 列)。
*
*
全程只查询、不下发。 外部进件商户的口径已由业务要求与自助进件保持一致,纳管只需核实;
* 而补开微信、绑人才端 AppID、改支付场景、开启延迟入账这些下发操作都不幂等(改支付场景会重建微信
* 子商户号),且连锁多店共用同一个汇付商户号时各自下发会造成重复下发。所以:
*
* - 进度刷新走 {@link MerchantOnboardingProgressService#refresh}(纯查询);
* - {@code source=managed} 的记录在刷新链路里一律跳过下发步骤(延迟入账、微信配置、业务开通)。
*
*
* 防绑错靠**人工核对**,不靠程序判定:我们这边 {@code merchant} 只有一个商家名(往往是服务点名或
* 品牌简称),通道那边是工商全称加简称,形态不对应,字符串比较必然误判——误拒会把运营卡死且无路可绕,
* 误放等于没设防。所以程序只判确定性的事(用我们的签名身份查得到该商户),名称改成界面展示 + 运营
* 显式勾选确认,并在两个名称与商家名一个字符都不重叠时给出醒目警示(不硬拒,可能是简称、别名或服务点名)。
*/
@Service
public class MerchantManagedOnboardingService {
/**
* 通道的企业种类取值 → 我们进件记录里的公司类型。
*
*
与 {@code MerchantSettlementService.entType} 是同一张表的两面:那里是「我们的取值 → 通道代码」,
* 这里是通道代码 → 我们的取值。取值必须一致,否则对私/对公结算凭证的判定会跟着漂。
*/
private static final Map COMPANY_TYPE_BY_ENT_TYPE = Map.ofEntries(
Map.entry("1", "政府机构"), Map.entry("2", "国营企业"), Map.entry("3", "私营企业"),
Map.entry("4", "外资企业"), Map.entry("5", "个体工商户"), Map.entry("6", "其它组织"),
Map.entry("7", "事业单位"), Map.entry("9", "业主委员会"));
private final JdbcTemplate jdbc;
private final HuifuOnboardingProgressGateway providerGateway;
private final SensitiveDataCrypto sensitiveDataCrypto;
private final MerchantOnboardingProgressService progressService;
private final MerchantPaymentReadinessService readinessService;
private final TransactionTemplate transactions;
public MerchantManagedOnboardingService(JdbcTemplate jdbc, HuifuOnboardingProgressGateway providerGateway,
SensitiveDataCrypto sensitiveDataCrypto,
MerchantOnboardingProgressService progressService,
MerchantPaymentReadinessService readinessService,
TransactionTemplate transactions) {
this.jdbc = jdbc;
this.providerGateway = providerGateway;
this.sensitiveDataCrypto = sensitiveDataCrypto;
this.progressService = progressService;
this.readinessService = readinessService;
this.transactions = transactions;
}
/**
* 纳管前置核验:用我们自己的签名身份只读查一次该商户,把要人工核对的材料取回来。
*
* 查不到就明确拒绝——那说明该商户不在我们**渠道商**的管辖范围内,纳管进行不下去。注意把商户
* 改成挂在平台自己名下既解决不了权限失败、还会平白多出材料要求,不是「迁一下归属就能解决」的事。
*/
public Map preview(Long merchantId, String huifuId) {
Map merchant = requireMerchant(merchantId);
String providerHuifuId = requireHuifuId(huifuId);
Map channel = channelMerchant(providerHuifuId);
Long storeId = primaryStore(merchantId);
Map card = pickSettlementCard(channel);
Map view = new LinkedHashMap<>();
view.put("merchantId", String.valueOf(merchant.get("id")));
view.put("merchantName", clean(merchant.get("merchantName")));
view.put("storeId", String.valueOf(storeId));
view.put("storeName", storeName(storeId));
view.put("providerHuifuId", providerHuifuId);
// 通道返回的两个名字与主体类型:运营拿它们与所选商家核对,勾选确认后才能提交。
view.put("channelMerchantName", clean(channel.get("reg_name")));
view.put("channelShortName", clean(channel.get("short_name")));
view.put("channelSubjectType", subjectTypeOf(channel));
view.put("channelCompanyType", COMPANY_TYPE_BY_ENT_TYPE.getOrDefault(clean(channel.get("ent_type")), ""));
view.put("settlementCardMasked", card == null ? "" : maskedCardNumber(clean(card.get("card_no"))));
view.put("settlementBankName", card == null ? "" : clean(card.get("bank_name")));
// 只提示不拦截:连锁各门店共用同一个收款主体是本次有意支持的做法,提示里列出已绑的商家即可。
view.put("boundMerchants", boundMerchantNames(providerHuifuId, merchantId));
view.put("nameWarning", nameWarning(clean(merchant.get("merchantName")),
clean(channel.get("reg_name")), clean(channel.get("short_name"))));
// 纳管只管收款绑定,不创建商户端登录账号;但商户登不进来要让运营知道。
view.put("loginNotice", loginNotice(merchantId, merchant));
// 已经纳管过这个商户号时不算冲突:重复纳管是幂等的,提交会直接返回原进件,别让运营以为要先解绑。
Map active = activeApplication(merchantId, storeId);
boolean alreadyManaged = active != null && providerHuifuId.equals(clean(active.get("providerHuifuId")));
view.put("alreadyManaged", alreadyManaged);
// 硬拦截项在这里先告诉运营,免得填完一屏才被拒。
List blockers = new ArrayList<>();
if (card == null) blockers.add("通道未返回可用的结算银行卡,无法纳管(分阶段进度与收款闸门都要求先绑定结算卡)");
if (active != null && !alreadyManaged) blockers.add(conflictMessage(active));
view.put("blockers", blockers);
return view;
}
/**
* 纳管:为该商家建立结算卡记录与进件记录,并触发一次进度刷新,使分阶段进度与收款能力快照在既有
* 模型内收敛。
*
* 幂等由「由汇付商户号派生的键」保证:{@code application_no} 与 {@code idempotency_key} 都是
* 唯一键且非空(见 V80),两个键都只从「商户号 + 门店」派生,重复点击不会产生第二条进件记录。
*
*
{@code confirmed} 是运营在对话框里勾的那个确认框:通道的商户注册名称与简称是运营肉眼核对过的,
* 程序不为它做字符串判定(见类注释)。
*
*
两条写入放在同一个事务里(半途失败不留孤儿结算卡),但**进度刷新与能力快照在事务外**:
* 刷新是 {@code NOT_SUPPORTED} 的独立连接,在未提交的事务里它查不到这条刚建的进件;
* 而通道查询本来也不该夹在持有行锁的事务里。
*/
public Map adopt(Long merchantId, String huifuId, String applyNo, boolean confirmed) {
Map merchant = requireMerchant(merchantId);
String providerHuifuId = requireHuifuId(huifuId);
String providerApplyNo = clean(applyNo);
if (providerApplyNo.isBlank()) throw new BusinessException(ErrorCode.BAD_REQUEST, "请填写汇付侧的入驻申请单号:没有它,通道的申请单状态查询(该接口 apply_no 必填)无法推进,进度会永远停在未确认");
if (!confirmed) throw new BusinessException(ErrorCode.BAD_REQUEST, "请先核对通道返回的商户注册名称与简称,并勾选确认后再提交");
Long storeId = primaryStore(merchantId);
String idempotencyKey = managedIdempotencyKey(providerHuifuId);
// 先看幂等:这一条查的就是我们自己上一次纳管留下的记录,别把它当成「门店已有进件」的冲突。
List