瀏覽代碼

feat(进件): 前端开出更新商户入口 —— 进件页加模式切换,白名单是建号白名单的真子集

R-7c 的 HTTP 端点(1af6ef2)此前没有任何页面入口:`onboarding.ts` 只有 /areas /mcc
/upload-image /create 四个调用,用户拿不到「改已有商户资料」这条路。本提交把它补上。

设计取舍:不新建页面、不开弹窗,而是在既有进件页加一个「建号 / 更新」模式切换。
理由是更新腿的字段集**就是**建号腿的字段集减两个 —— 另起一份必然漂移,而这类漂移是
静默的(改了一边忘另一边,页面照常能提交);且省市区与行业类目两条级联已经在这里接好,
弹窗方案要把级联状态整个再复制一遍。代价约 200 行,模板零重复。

三个绝不能出现在更新报文里的键,按后果分两档(本仓 JacksonConfig 绕过了 Spring 的
builder、FAIL_ON_UNKNOWN_PROPERTIES 为 true ⇒ 多送一个键是 HTTP 500,**不是** 400):

  tenant_id / rate_code   → 信封 400(控制器两道封盖当场拒)
  category                → HTTP 500(LsyMerchantUpdateDTO 上没有它)
  merchant_name_business  → HTTP 500(同上)
  lease_contract          → HTTP 500(同上)

后两个是本提交新排除的,配了**两道彼此独立**的防线,缺一不可:
  ① 更新模式下不渲染 —— 渲染出来就是一格「按下去必 500」的输入框;
  ② 不进 UPDATE_KEYS 白名单 —— 因为 switchMode **有意不清空表单**(41 个框,误点一下
     不能毁掉已填内容),「建号模式下填过再切过来」的值仍留在 form 里,只靠「不渲染」挡不住。

键集算术(每一格都是在执行级台子上**跑出来**的,不是从注释里抄的):
  建号白名单 CREATE_KEYS = 41
  更新白名单 UPDATE_KEYS = 39(真子集,差的恰好 2)
  后端 updateMerchant 实际读取 = 50(putIfNotBlank 调用点 50 处)
  差 11 = 10 个「本页没有入口」的字段 + rate_code(**有意封盖**,前端连输入框都不该有)
那 10 个不是漏做,是**已知边界**:它们的中文标签来源(后端 LsyMerchantRequiredFields.LABELS)
是**创建页** 62 个参数那张表,键集被一条 assertEquals 钉成「不多不少等于那 62 个」
⇒ 取这 10 个键会拿到 null 而非异常。要补得先从更新页参数表逐个转录。

★ 跨端键名核对 —— 这是本次真正的风险点:前端键名拼错不会当场报错,只会变成线上 500。
新增 fe-update-keycheck.mjs,把两边键集在**同一个 node 进程**里做集合运算,两条关键读数:
  - 前端 39 个键 ⊆ 后端 50 个键,**多出 0 个**
  - 正控:全文件 `putIfNotBlank(` 出现 51 次 = 定义 1 处 + 调用 50 处,且 updateMerchant
    方法体内提取到 50 个 ⇒ 正则不是空转,且**没有**调用落在 updateMerchant 之外
      (落在外面的调用就等于漏键)
  ★ 这一条是**换量具**换出来的:第一版用 awk|grep -oE|sed|sort 管道量,给出的数是 50 ——
    恰好等于预期,看起来完全正确。真相是那支管道吐了 50 行**空串**(grep -oE 的匹配含
    结尾那个引号,紧跟的 sed 's/.*"//' 里 .* 贪婪吃到最后那个引号、把整行删光),而
    这次的后端正则又一个都没匹配上(线上真实形状是三参 putIfNotBlank(p, "k", v),
    我写成了两参)。两条坏读数互相掩护,是靠换第二种数法 + 一条硬正控逼出来的。

增量语义:上游更新页页末逐字「注:此接口更新模式为增量更新,请求参数有值则更新,
请求参数无值则保持原数据」⇒ **留空 = 不改,不是清空**。buildUpdateBody 据此把空白字段
整个省掉,**不送空串** —— 空串会进上游签名原文并照发(LeshouyingSigner 文件头第 1 条
「为空字符串 → 参与」)。想让某字段变空,这个接口做不到,别在这里加「清空」语义。

其余形状:
- mch_no 只在更新模式必填,校验规则随 mode 走。已从**装好的** element-plus 2.14.0 源码
  核实:form-item 在 onBeforeUnmount 调 formContext.removeField,而 doValidateField →
  obtainValidateFields 对空字段表直接 return true ⇒ 被 v-if 隐藏的格子确实不再参与校验。
- 应答是 Result<Void>(data 为 null)⇒ 不读 res.data.data.mch_no,成功提示用请求里那个。
- 更新模式下只填 mch_no、别的一律不填是**合法**请求,因此不加「必须改点什么」的客户端
  规则 —— 那等于凭空发明一条上游从未声明的约束。
- 权限串复用 module_payment:leshouying:onboarding:create(本模块只有三个权限串)。

验证(读数全部取在最终版文件上;提交前复量 sha256 与验证基线逐字节相同):
- pnpm ts:check:输出与本改动前**逐字节相同**(两侧均 287 个 error TS、exit 2 —— 本仓
  ts:check 有一个非零历史基线,「跑绿了」在这里不成立,只能做同机前后差分)。
- ts:check 可见性变异 2 条:各注入一处类型错 ⇒ 各 288 个 error TS 且**点名**被注入的
  那个文件 ⇒ 证明 vue-tsc 确实看得见这两个文件,「没引入新错」才不是空话。
- pnpm build:exit 0。
- 执行级台子 fe-update-harness.mjs(编译真 SFC、跑 frontend 里那份真 vue、驱动真函数、
  只桩掉 api 与上传组件):43 条断言全绿。
- 两向变异 4 条,读数取在 43 条台子上,每次还原后 sha256 回到 5c4c34e8…:
    M-U1 拆掉「减去两个」       ⇒ 7 红
    M-U2 白名单换成整表摊开     ⇒ 7 红
    M-U3 渲染投影失效           ⇒ 3 红
    M-U5 无害向(只加一行注释) ⇒ 43 全绿
- 跨端键名核对 8 条断言全绿(见上)。

未包含:那 10 个无入口字段的 UI;以及线上联调(缺凭据,未做)。
alphaH 3 周之前
父節點
當前提交
4a67ffdd9f

+ 41 - 0
frontend/src/api/module_payment/onboarding.ts

@@ -125,3 +125,44 @@ export function uploadOnboardingImage(body: FormData) {
 export function createOnboardingMerchant(data: Record<string, unknown>) {
   return request({ url: "/payment/collection/onboarding/create", method: "post", data });
 }
+
+/**
+ * 更新已有商户的上游资料。**JSON body**,键名 snake_case(见文件头 ★)。
+ *
+ * ★★ **上游的更新语义是「增量」** —— 更新页页末逐字:「注:此接口更新模式为增量更新,
+ *   请求参数有值则更新,请求参数无值则保持原数据」。所以本接口**留空 ≠ 清空,留空 = 不改**,
+ *   页面据此把空白字段整个省掉(`buildUpdateBody` 见页面)。
+ *   ⇒ 想让某个字段变成空值,这个接口**做不到**,别在这里加「清空」语义。
+ *
+ * ★★ **三个绝不能出现在 body 里的键,后果分两档**(本仓 `FAIL_ON_UNKNOWN_PROPERTIES` 是 `true`,
+ *   `JacksonConfig` 绕过了 Spring 的 builder ⇒ 多送一个键是 **HTTP 500**,**不是** 400):
+ *
+ *   | 键 | 后果 | 依据 |
+ *   |---|---|---|
+ *   | `tenant_id` / `rate_code` | 信封 **400** | 控制器两道封盖 `rejectBodySuppliedTenantId` / `rejectBodySuppliedRateCode` 当场拒 |
+ *   | `category` | **HTTP 500** | `LsyMerchantUpdateDTO` **没有** `category`(创建腿「一个 category 派生两个输出」在更新腿不成立:`merchant_type` 压根不在该页参数表里) |
+ *   | `merchant_name_business` | **HTTP 500** | 同上,该页参数表没有它;更新页逐字「存在少数字段商户创建后即固定,无法修改」
+ *   | `lease_contract` | **HTTP 500** | 同上(创建页有、更新页没有) |
+ *
+ *   ⇒ 更新腿的键白名单**不是**创建腿的键白名单,是它的**子集再减去两个**
+ *     (页面里那张唯一清单 `SECTIONS` 之外,另有一处 `UPDATE_EXCLUDED_KEYS`)。
+ *
+ * ★ 应答是 **`Result<Void>`**:`data` 是 `null` ⇒ **不要**去读 `res.data.data.mch_no`
+ *   (创建腿有、更新腿没有)。成功提示由拦截器弹(非 GET 成功响应)⇒ 页面不重复弹。
+ * ★ **租户闸**:`mch_no` 不属于当前登录租户 ⇒ **404**(`requireByMchNoForTenant`),
+ *   且上游一次都不会被调用。这是控制器里 `mch_no` 之后的第一件事。
+ * ★ 权限串**复用** `module_payment:leshouying:onboarding:create`(本模块只有三个权限串,
+ *   更新腿不占第四个 —— 与 `/upload-image` 同款)。
+ *
+ * ★ **本页没有入口的上游更新腿字段(10 个,不是漏做,是边界)**:
+ *   `license_stt` / `account_type` / `legal_cert_stt` / `account_idcard_stt`(三张证件的**起始**日
+ *   与账户类型 —— 建号页的参数表里就没有这几个字段,所以建号时也填不了)、
+ *   `reg_capital` / `fSettle_account_type` / `actual_addr` / `business_scope`(上游标「平安收银宝必填」)、
+ *   `check_code`(上游标「星驿付必填」)、`p_cashier`(收银台照)。
+ *   前端**没有**这 10 个字段的中文标签来源:后端那张翻译表 `LsyMerchantRequiredFields.LABELS`
+ *   是**创建页** 62 个参数那张表,它的键集被一条 `assertEquals` 钉成「不多不少等于创建页那 62 个」
+ *   ⇒ 这 10 个键一个都不在里面(取了会拿到 `null`,不是异常)。要补,得先从更新页参数表逐个转录。
+ */
+export function updateOnboardingMerchant(data: Record<string, unknown>) {
+  return request({ url: "/payment/collection/onboarding/update", method: "post", data });
+}

+ 181 - 17
frontend/src/views/module_payment/collection/onboarding/index.vue

@@ -1,18 +1,28 @@
 <template>
   <div class="app-container">
-    <el-alert
-      type="info"
-      :closable="false"
-      style="margin-bottom: 12px"
-      title="按所选商户类别填写资料,提交后由服务端建号。必填项按类别分档,漏填时服务端会在提交后一次列全(按中文字段名)—— 照那份清单补齐即可。"
-    />
+    <el-alert type="info" :closable="false" style="margin-bottom: 12px" :title="MODE_HINT[mode]" />
 
-    <!-- 商户号是本页唯一的产出:toast 会自己消失,这里**常驻**到下一次提交 -->
+    <!-- 商户号是**建号腿**唯一的产出:toast 会自己消失,这里**常驻**到下一次提交 -->
     <el-alert v-if="createdMchNo" type="success" :closable="false" style="margin-bottom: 12px">
       <template #title>
         提交成功,商户号:
         <strong class="mch-no">{{ createdMchNo }}</strong>
       </template>
+      <template #default>
+        <!-- 建号后当场发现填错,是更新腿最常见的入口 —— 这里少一次「回头找商户号」 -->
+        <el-button link type="primary" @click="startUpdateFor(createdMchNo)">
+          资料填错了?改这个商户
+        </el-button>
+      </template>
+    </el-alert>
+
+    <!-- 更新腿**不回**商户号(`Result<Void>`),所以这里显示的是**请求里那个**,不是响应里的 -->
+    <el-alert v-if="updatedMchNo" type="success" :closable="false" style="margin-bottom: 12px">
+      <template #title>
+        已提交更新:
+        <strong class="mch-no">{{ updatedMchNo }}</strong>
+        —— 表单里留空的项,上游原值未变。
+      </template>
     </el-alert>
 
     <!-- 信封说成功、却没有商户号:既不能说成功(拿不到号),也不能说失败(可能已经建出来了) -->
@@ -39,6 +49,13 @@
     </el-alert>
 
     <el-card shadow="never">
+      <!-- ★ 两种模式**共用**这一张表单、这一套级联。不另起页面/弹窗:更新腿的字段集是
+           建号腿那张表**减去两个**(见 `UPDATE_EXCLUDED_KEYS`),另起一份必然漂移。 -->
+      <el-radio-group v-model="mode" style="margin-bottom: 16px" @change="switchMode">
+        <el-radio-button value="create">建号(新建商户)</el-radio-button>
+        <el-radio-button value="update">更新(改已有商户资料)</el-radio-button>
+      </el-radio-group>
+
       <el-form
         ref="formRef"
         :model="form"
@@ -47,8 +64,18 @@
         label-position="right"
         v-loading="submitting"
       >
-        <!-- 商户类别:全部资料的**分档依据**,不进折叠 —— 它是这一页的入口 -->
-        <el-form-item label="商户类别" prop="category">
+        <!-- 商户号:**只有更新模式**有 —— 建号时它由服务端产出,不是输入 -->
+        <el-form-item v-if="mode === 'update'" label="商户号" prop="mch_no">
+          <el-input
+            v-model="form.mch_no"
+            placeholder="要改哪个商户就填哪个 —— 只能改本租户的商户号"
+            style="width: 420px"
+          />
+        </el-form-item>
+
+        <!-- 商户类别:**只有建号模式**有 —— 更新页的参数表里压根没有它
+             (`merchant_type` 不在那张表里 ⇒ 建号腿「一个 category 派生两个输出」在更新腿不成立) -->
+        <el-form-item v-if="mode === 'create'" label="商户类别" prop="category">
           <el-select v-model="form.category" placeholder="请选择商户类别" style="width: 320px">
             <el-option
               v-for="c in MERCHANT_CATEGORY_OPTIONS"
@@ -69,7 +96,7 @@
 
             <!-- 纯文本组 -->
             <el-row v-if="s.kind === 'text'" :gutter="20">
-              <el-col v-for="f in s.fields" :key="f.key" :span="12">
+              <el-col v-for="f in visibleFields(s)" :key="f.key" :span="12">
                 <el-form-item :label="f.label">
                   <el-input v-model="form[f.key]" />
                 </el-form-item>
@@ -133,7 +160,7 @@
                   </el-select>
                 </el-form-item>
               </el-col>
-              <el-col v-for="f in s.fields" :key="f.key" :span="12">
+              <el-col v-for="f in visibleFields(s)" :key="f.key" :span="12">
                 <el-form-item :label="f.label">
                   <el-input v-model="form[f.key]" />
                 </el-form-item>
@@ -212,7 +239,7 @@
 
             <!-- 上传组:每格 = 标签 + 图片框(图片框自己就地显示图片本身,不把地址当文本渲染) -->
             <div v-else class="upload-grid">
-              <div v-for="f in s.fields" :key="f.key" class="upload-cell">
+              <div v-for="f in visibleFields(s)" :key="f.key" class="upload-cell">
                 <div class="upload-cell__label">{{ f.label }}</div>
                 <SingleImageUpload
                   v-model="form[f.key]"
@@ -227,7 +254,9 @@
 
         <div class="form-actions">
           <el-button @click="handleReset">重置</el-button>
-          <el-button type="primary" :loading="submitting" @click="handleSubmit">提交进件</el-button>
+          <el-button type="primary" :loading="submitting" @click="handleSubmit">
+            {{ mode === "update" ? "提交更新" : "提交进件" }}
+          </el-button>
         </div>
       </el-form>
     </el-card>
@@ -249,6 +278,7 @@ import {
   createOnboardingMerchant,
   getOnboardingAreas,
   getOnboardingMcc,
+  updateOnboardingMerchant,
   uploadOnboardingImage,
   type OnboardingArea,
   type OnboardingLevel,
@@ -422,8 +452,48 @@ const CREATE_KEYS: string[] = [
   AREA_FIELDS.county.key,
 ];
 
+/**
+ * ★★ 更新腿**不接受**的两个键。发出去**不是**「被服务端忽略」,是 **HTTP 500**
+ *   (本仓 `FAIL_ON_UNKNOWN_PROPERTIES` 是 `true`,`JacksonConfig` 绕过了 Spring 的 builder
+ *   ⇒ 多一个未知键就是 500,不是 400)。两者都**不在** `LsyMerchantUpdateDTO` 上:
+ *   · `merchant_name_business` —— 上游更新页逐字「存在少数字段商户创建后即固定,无法修改,
+ *     如:merchant_name_business, merchant_type 等」;
+ *   · `lease_contract` —— 创建页有、更新页没有(更新 DTO 的类 javadoc 里那组
+ *     「创建页有、本页没有」)。
+ *   ⇒ **两道各自独立**的防线,两件都要成立:更新模式下这两格**不渲染**
+ *     (渲染出来 = 提供一个按下去必定 500 的入口),且 `UPDATE_KEYS` 里也没有它们
+ *     (「看不见」挡不住「切模式前已经填进去的值」,见 `buildUpdateBody`)。
+ */
+const UPDATE_EXCLUDED_KEYS: readonly string[] = ["merchant_name_business", "lease_contract"];
+
+/** 更新腿的键白名单 = 建号腿那张表**减去两个**;`mch_no` 另加(它不是建号表单的字段)。 */
+const UPDATE_KEYS: string[] = CREATE_KEYS.filter((k) => !UPDATE_EXCLUDED_KEYS.includes(k));
+
+/**
+ * 本页两种模式 —— **同一张表、同一套级联、两个出口**(`/create` 与 `/update`)。
+ *
+ * ★ 不另起一个页面或弹窗:更新腿的字段集就是建号腿那张表减去两个,另起一份必然漂移,
+ *   而漂移的症状是「同一份资料在两处能填的格不一样」且不报错。
+ */
+const mode = ref<"create" | "update">("create");
+
+const MODE_HINT: Record<"create" | "update", string> = {
+  create:
+    "按所选商户类别填写资料,提交后由服务端建号。必填项按类别分档,漏填时服务端会在提交后一次列全(按中文字段名)—— 照那份清单补齐即可。",
+  update:
+    "更新已有商户的上游资料。★ 上游是增量更新:这里留空 = 不改这一项,不是清空 —— 只想改哪几项就只填哪几项;下拉与图片同理,没动过的不会被提交。",
+};
+
+/** 更新模式下该分区要渲染的字段 —— `SECTIONS` 仍是**唯一**那张表,这里只是投影。 */
+function visibleFields(s: Section): Field[] {
+  return mode.value === "create"
+    ? s.fields
+    : s.fields.filter((f) => !UPDATE_EXCLUDED_KEYS.includes(f.key));
+}
+
 function emptyForm(): Record<string, string> {
-  const f: Record<string, string> = { category: "" };
+  // `mch_no` 只有更新模式用得上(建号时它是服务端的**产出**);不填它 ⇒ 建号报文不带这个键
+  const f: Record<string, string> = { category: "", mch_no: "" };
   for (const key of CREATE_KEYS) {
     f[key] = "";
   }
@@ -436,6 +506,11 @@ const submitting = ref(false);
 const createdMchNo = ref("");
 /** ★ 提交被受理、却**没回商户号**:不当成功(拿不到号),也不当失败(可能已经建出来了)。 */
 const submitNoMchNo = ref(false);
+/**
+ * 更新腿没有「商户号」这个产出(应答是 `Result<Void>`)⇒ 这里存的是**请求里那个**商户号,
+ * 只用来渲染提交后那条常驻提示。不要把它读成响应里的值。
+ */
+const updatedMchNo = ref("");
 const cascadeFailed = ref(false);
 
 /** 默认**全部展开**(理由见模板上的注释)。 */
@@ -452,9 +527,18 @@ const activeSections = ref<string[]>(SECTIONS.map((s) => s.key));
  * 而服务端的 400 文案会**一次性**按中文标签列出缺哪几项
  * (`LsyOnboardingService` 逐字「进件必填项缺失(共 N 项):…」)⇒ 那份清单才是判据。
  */
-const rules = {
-  category: [{ required: true, message: "请选择商户类别", trigger: "change" }],
-};
+const rules = computed(() => {
+  if (mode.value === "update") {
+    // 更新腿**只钉商户号**:其余**一个都不能设成必填** —— 增量语义下「留空 = 不改」是正常用法,
+    // 设了必填就等于把「只想改结算卡」逼成「必须把 41 项重填一遍」,而那些重填的值会真的发上游。
+    return {
+      mch_no: [{ required: true, message: "请输入要更新的商户号", trigger: "blur" }],
+    };
+  }
+  return {
+    category: [{ required: true, message: "请选择商户类别", trigger: "change" }],
+  };
+});
 
 // ==================== 级联:省/市/区 ====================
 // ★ 每一级的 `parentCode` 都是**上一级选中项的 `area_code`**(如 `"1"`),
@@ -701,6 +785,25 @@ function buildCreateBody(): Record<string, string> {
   return body;
 }
 
+/**
+ * 更新报文的键白名单 —— `mch_no` + `UPDATE_KEYS`。
+ *
+ * ★ 与 `buildCreateBody` 共用「空白字段整个省掉」这条判据(理由见上)。**不共用函数**是因为
+ *   两个出口的键集不同,而且这次的「省掉」还多一层语义:上游是**增量更新**,
+ *   留空 = 保持原值 ⇒ 省掉正是正确行为,不是「少发了」。
+ * ★★ 切模式**不清表单**(见 `switchMode`)⇒ `form` 里此刻可能还留着建号模式打进去的
+ *   `merchant_name_business` / `lease_contract` 值。`UPDATE_KEYS` 不含它们,**物理上发不出去** ——
+ *   这就是「白名单」而非 `{ ...form }` 的意义:那道 500 由白名单挡,不靠「用户没填过」这个假设。
+ */
+function buildUpdateBody(): Record<string, string> {
+  const body: Record<string, string> = { mch_no: form.mch_no.trim() };
+  for (const key of UPDATE_KEYS) {
+    const value = (form[key] ?? "").trim();
+    if (value !== "") body[key] = value;
+  }
+  return body;
+}
+
 async function handleSubmit() {
   try {
     await formRef.value?.validate();
@@ -709,6 +812,38 @@ async function handleSubmit() {
     return;
   }
 
+  if (mode.value === "update") {
+    await submitUpdate();
+    return;
+  }
+  await submitCreate();
+}
+
+/**
+ * 更新腿。与建号腿的三处**有意不同**:
+ *  ① 应答是 `Result<Void>` ⇒ **没有**商户号可读,也就没有「没回商户号」那条告警
+ *     (那条的语义是「本页唯一的产出没了」,在更新腿没有对应物);
+ *  ② 商户号是**输入**(`form.mch_no`)而不是产出,提示里显示的是请求里的那个;
+ *  ③ 提交完**不清表单**(与建号腿一致)⇒ 一条常驻提示说明「哪几项没被提交」,
+ *     因为从表单上看不出「留空 = 不改」。
+ */
+async function submitUpdate() {
+  submitting.value = true;
+  updatedMchNo.value = "";
+  try {
+    await updateOnboardingMerchant(buildUpdateBody());
+    // ★ 这里**不读** `res.data?.data?.mch_no` —— 更新腿回的是 `Result<Void>`,`data` 是 `null`,
+    //   读了只会拿到 `undefined` 并把成功显示成「拿不到商户号」。
+    updatedMchNo.value = form.mch_no.trim();
+  } catch {
+    // 失败文案由拦截器统一弹(两道封盖的 400、非本租户的 404、上游失败的 502 都在 `msg` 里)
+    // ⇒ 页面**不重复弹**,也不把它当成功往下走。这里只是不让 rejection 逃逸成未处理的 Promise 拒绝。
+  } finally {
+    submitting.value = false;
+  }
+}
+
+async function submitCreate() {
   submitting.value = true;
   createdMchNo.value = "";
   submitNoMchNo.value = false;
@@ -742,9 +877,38 @@ function handleReset() {
   clearLeaf();
   createdMchNo.value = "";
   submitNoMchNo.value = false;
+  updatedMchNo.value = "";
+  formRef.value?.clearValidate();
+}
+
+/**
+ * 切模式:**不动**表单里已填的值 —— 41 个框,误点一下清空是不可逆的。
+ * 代价是建号模式填的 `merchant_name_business` / `lease_contract` 会留在模型里,
+ * 但那条路径由 `buildUpdateBody` 的**键白名单**堵死(不靠「用户没填过」这个假设)。
+ *
+ * ★ 只清**上一次提交的结果提示**:那是「上一次那个动作」的结论,
+ *   留着会让人以为刚切换到的这个模式已经提交过了。
+ */
+function switchMode() {
+  createdMchNo.value = "";
+  submitNoMchNo.value = false;
+  updatedMchNo.value = "";
   formRef.value?.clearValidate();
 }
 
+/**
+ * 从「刚建完号」那条常驻提示跳进更新模式,并带上商户号 —— 建号后当场发现填错,
+ * 是更新腿最常见的入口,这里省掉一次「回头把商户号抄过来」。
+ *
+ * ★ 只预填 `mch_no`,**不**把建号表单里那一堆值带过去:更新是增量的,
+ *   带过去等于「按建号时填的再覆盖一遍」,而不是「只改填错的那一项」。
+ */
+function startUpdateFor(mchNo: string) {
+  mode.value = "update";
+  form.mch_no = mchNo;
+  switchMode();
+}
+
 onMounted(() => {
   // 两组级联与表单无关,进页面就拉(失败由 `loadCascade` 落成页面上那条常驻提示)
   loadProvinces();