Ver código fonte

feat(汇安融): 门店表单录地区(四)—— 前端省市选择器 + 开放 API 文档同步

后端那一刀(见本系列三)把目录端点做出来了,这一刀让它在页面上真能用:

- api/module_payment/har.ts:regionProvinces / regionCities 两个请求 + HarRegionVO;
  HarShopVO 补 province_code/city_code(返回侧),HarShopCreateParams 补两个**必填**码;
- views/.../har/shop/index.vue:建店表单加省→市两级选择器(都必填、换省清市、
  城市下拉按省缓存),列表加「地区」列、详情加一行,把码显示成名字;
  缺地区的门店(2026-10-08 前建的,补不回来)显示成红色的「未填(不参与按地区收款)」
  —— 它们在挑店时必然落选,页面若看着像有地区,运营会以为这家店还能收款;
- apikey/docs/har-openapi.md:10.1 请求表补两行必填 + 地区说明 + 联调清单一条。

地区显示名一律取后端目录(`/payment/har/region/*`),**前端不存第二份省市表**:
编码是按顾客 IP 挑店时比对的键,两份表一旦分叉,运营选出来的就是「永远挑不中」的门店,
而上游没有门店更新接口,事后补不上。目录取不到时宁可显示编码本身,也不猜一个名字。

★ 这是**破坏性变更**(对外):开放 API 的 /payment/openapi/har/shop/create 共用同一个
HarShopCreateDTO,所以外部调用方不传这两个码会拿到 code=400(msg「省份不能为空」)。
文档已按实测口径写明,并点出「地区填错不会报错,但那家店永远收不到款」。

验证:本仓前端无测试框架(无 vitest/jest,scripts 只有 ts:check/build/lint)。
① pnpm ts:check 同机差分:基线 289 条 error TS(退出码本就非零),改动后**逐条集合相同**,
   新增 0、消失 0;
② pnpm build 通过(vite build,exit 0,57.6s);
③ 页面上新用到的两个 Element Plus 属性对着装好的包核过(element-plus 2.14.0
   select.mjs:loading: Boolean「whether Select is loading data from server」、
   disabled: Boolean),不是凭记忆。
★ 未做执行级验证:本仓前端没有可以起一个 SFC 的测试台子,页面又要登录态才能打开,
   「选择器拉到了 31 个省、换省真的换了市」这几步**没有**被真跑过 —— 交付说明里如实标注。
alphaH 1 semana atrás
pai
commit
8e352e7ee9

+ 64 - 0
frontend/src/api/module_payment/har.ts

@@ -77,6 +77,16 @@ export interface HarShopVO {
   /** 商户自己的门店编码,幂等键 */
   third_shop_id: string;
   shop_name?: string;
+  /**
+   * GB/T 2260 两位省码(`32`=江苏省)。
+   *
+   * ★ 可空**只对 2026-10-08 之前建的门店**成立:上游没有门店更新接口,那批店补不上,
+   *   挑店时会被当成「地区不匹配」落选 ⇒ 页面必须把「未填」显示成未填,
+   *   不能看着像有地区(见 shop/index.vue 的 regionLabel)。
+   */
+  province_code?: string | null;
+  /** GB/T 2260 四位市码(`3201`=南京市),见 province_code */
+  city_code?: string | null;
   /** 上游返回的平台门店编号,创建成功才有值 */
   shop_id?: string;
   /** PENDING=结果未知待人工核实 / SUCCESS=上游已确认 / FAILED=上游明确拒绝 */
@@ -92,6 +102,13 @@ export interface HarShopVO {
 export interface HarShopCreateParams {
   third_shop_id: string;
   shop_name: string;
+  /**
+   * 省份编码,**必填**(规范 261008 5.5)。取值只能来自 `regionProvinces()`
+   * —— 前端不许自己存一份省市表:编码是挑店比对用的键,与后端目录必须同源。
+   */
+  province_code: string;
+  /** 市编码,**必填**,且必须属于上面那个省(后端按省查表校验) */
+  city_code: string;
   /** 选填。为空 ⇒ 走**租户级**商户映射(enterprise_id IS NULL 的那一行) */
   enterprise_id?: string;
   /**
@@ -103,6 +120,20 @@ export interface HarShopCreateParams {
   merchant_code?: string;
 }
 
+/**
+ * 一条地区记录(后端 HarRegionVO)—— 省与地级共用这个形状:码 + 名字。
+ *
+ * ★ `code` 是提交回 `HarShopCreateParams` 的那一格,`name` 只用于显示。
+ * ★ 这份目录同时是**顾客 IP 挑店**的比对表 ⇒ 页面选出来的码就是路由用的码。
+ *   不要在前端另存省市表,也不要对码做加工(补零、截断、拼接)。
+ */
+export interface HarRegionVO {
+  /** GB/T 2260:省两位(`32`),地级四位(`3201`) */
+  code: string;
+  /** 展示名。直辖市的地级是占位名换成的省名(重庆给出 5001/5002 两个) */
+  name: string;
+}
+
 /** 订单(后端 HarOrderVO)— 状态直接采用上游枚举,不做本地转义 */
 export interface HarOrderVO {
   id?: string;
@@ -358,6 +389,39 @@ const HarAPI = {
     });
   },
 
+  // ---------- 地区目录(门店表单的省市选择器)----------
+
+  /**
+   * 省级列表(31 个,港澳台不在内)。
+   *
+   * ★ 目录在后端只有一份(`HarRegionCatalog`,同一份也用于按顾客 IP 挑店),
+   *   所以**不要**在前端另存一份省市表:两份表迟早会分叉,而分叉的后果是
+   *   运营选出来的门店「永远挑不中」,上游又没有门店更新接口,补不上。
+   *
+   * ★ 权限串复用门店的 `list`/`create`(`@perm.hasAny(...)`)⇒ 不需要新的菜单 SQL。
+   */
+  regionProvinces() {
+    return request<ApiResponse<HarRegionVO[]>>({
+      url: `${API_PATH}/region/provinces`,
+      method: "get",
+    });
+  },
+
+  /**
+   * 某省的地级列表。
+   *
+   * ★ 参数名是下划线 `province_code`(后端 `@RequestParam(name = "province_code")`)。
+   * ★ 省码不在目录里 ⇒ 后端 **400**(不是空数组):空表在下拉里与「这个省真的没有市」
+   *   长得一样,所以失败要按失败处理,别静默吞掉。
+   */
+  regionCities(provinceCode: string) {
+    return request<ApiResponse<HarRegionVO[]>>({
+      url: `${API_PATH}/region/cities`,
+      method: "get",
+      params: { province_code: provinceCode },
+    });
+  },
+
   // ---------- 订单 ----------
 
   /** 订单列表(分页);silent = 后台静默刷新 */

+ 11 - 0
frontend/src/views/module_payment/apikey/docs/har-openapi.md

@@ -30,6 +30,7 @@
 
 **门店与下单的关系**:10.2 里的 `shop_code` / `third_shop_code` 都是**选填**。门店不是下单的前置条件 —— 只有需要把交易归属到某个具体门店时才填。
 
+> **但门店的「地区」是有用的**:平台的收款链接(第 6 章的顾客付款页)下单时会按顾客 IP 的归属地自动挑一家同地区的门店,见 10.1 的地区说明。你的接口自己下单时若指定了门店,那个地区的风控判断同样适用。
 ---
 
 ### 10.1 创建门店
@@ -47,6 +48,14 @@ POST /payment/openapi/har/shop/create
 | `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**:门店建在哪一个商户下,见本章开头。只配了一个的不用传 |
 | `third_shop_id` | string | 是 | 你方的门店编码,≤32 字符。**重复提交会被拒绝**,见下 |
 | `shop_name` | string | 是 | 门店名称,≤128 字符 |
+| `province_code` | string | **是**(2026-10-08 起) | 门店所在省,GB/T 2260 两位码,如 `32`(江苏省)、`11`(北京市) |
+| `city_code` | string | **是**(2026-10-08 起) | 门店所在市,GB/T 2260 四位码,如 `3201`(南京市)、`1101`(北京市)。**必须是 `province_code` 下的市**,不属于该省的编码会被拒(`msg` 形如 `城市编码不属于该省: 1101(省 32)`;省码写错则为 `省份编码不存在: 99`) |
+
+> ⚠️ **`province_code` / `city_code` 自 2026-10-08 起为必填。** 在此之前提交成功的门店不传这两个字段也能建号,**现在不传会返回 `code=400`**(`msg` 为 `省份不能为空` / `城市不能为空`)。取值用国标 GB/T 2260 的行政区划代码:省级两位、地级四位,直辖市取 `1101`(北京市)/ `1201`(天津市)/ `3101`(上海市)/ `5001`(重庆市)、`5002`(重庆市县)。
+
+> ⚠️ **地区填错不会有任何报错,但这家门店会永远收不到款。** 支付宝对**异地收款**有风控,所以平台的**收款链接**(第 6 章的顾客付款页)在下单时会按**顾客 IP 的归属地**自动挑一家同地区的门店去发起收款。地区为空、或与顾客所在地不一致的门店,在挑店时直接落选。上游没有门店更新接口,**建号时填错就补不回来**,只能换一个新的 `third_shop_id` 重新建号。
+
+> **2026-10-08 之前建的门店**:那批门店没有地区,无法回填,**也不会被收款链接挑中**。让顾客扫码付款的门店必须重新建号。
 
 **响应**(B 型,`data` 内)
 
@@ -54,6 +63,7 @@ POST /payment/openapi/har/shop/create
 |---|---|
 | `third_shop_id` | 原样返回 |
 | `shop_name` | 原样返回 |
+| `province_code` / `city_code` | 原样返回(建号时提交的那两个码) |
 | `shop_id` | 平台门店编号,**建号成功才有值**;下单时作为 `shop_code` 传入 |
 | `create_status` | 建号结果,见下 |
 | `merchant_code` | 平台侧为你配置的商户编号(你无需关心,原样带回) |
@@ -310,6 +320,7 @@ Content-Type: application/x-www-form-urlencoded
 ## 11. 门店与订单通道的联调检查清单
 
 - [ ] 认证与签名复用 3.3 的实现,先跑通 3.4 的样例
+- [ ] 建号带了 `province_code` / `city_code`(GB/T 2260),且市码属于该省码
 - [ ] `price` 用**元**、写成最简小数(不要 `1.50`、`0.010`)
 - [ ] 下单**不重发** —— 超时先查 10.3,确认没有再决定
 - [ ] `auth_code` 有值走被扫、不传走主扫,两种都测过

+ 136 - 1
frontend/src/views/module_payment/har/shop/index.vue

@@ -96,6 +96,13 @@
         <el-table-column prop="shop_name" label="门店名称" min-width="150">
           <template #default="{ row }">{{ row.shop_name || "-" }}</template>
         </el-table-column>
+        <el-table-column label="地区" min-width="160">
+          <template #default="{ row }">
+            <span :class="{ 'region-missing': !row.province_code && !row.city_code }">
+              {{ regionLabel(row) }}
+            </span>
+          </template>
+        </el-table-column>
         <el-table-column prop="shop_id" label="平台门店编号" min-width="140">
           <template #default="{ row }">{{ row.shop_id || "-" }}</template>
         </el-table-column>
@@ -200,6 +207,36 @@
         <el-form-item label="门店名称" prop="shop_name">
           <el-input v-model="form.shop_name" maxlength="128" show-word-limit />
         </el-form-item>
+        <el-form-item label="省份" prop="province_code">
+          <!-- 两级都是必填(规范 261008 5.5)。取值只能来自这里给的下拉:
+               编码要进上游报文,也是收款时按顾客 IP 挑店的匹配键,手敲一个错码
+               就是一家永远挑不中的门店(上游没有更新接口,事后补不上) -->
+          <el-select
+            v-model="form.province_code"
+            placeholder="选择省份"
+            filterable
+            style="width: 100%"
+            @change="onProvinceChange"
+          >
+            <el-option v-for="p in provinceOptions" :key="p.code" :label="p.name" :value="p.code" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="城市" prop="city_code">
+          <el-select
+            v-model="form.city_code"
+            :disabled="!form.province_code"
+            :loading="cityLoading"
+            :placeholder="form.province_code ? '选择城市' : '请先选择省份'"
+            filterable
+            style="width: 100%"
+          >
+            <el-option v-for="c in currentCities" :key="c.code" :label="c.name" :value="c.code" />
+          </el-select>
+          <div class="form-tip">
+            支付宝对异地收款有风控:顾客付款时会自动用顾客所在地区的门店发起收款,
+            所以每家门店都要有准确的地区。
+          </div>
+        </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="createVisible = false">取消</el-button>
@@ -218,6 +255,7 @@
         </el-descriptions-item>
         <el-descriptions-item label="门店编码">{{ detail.third_shop_id }}</el-descriptions-item>
         <el-descriptions-item label="门店名称">{{ detail.shop_name || "-" }}</el-descriptions-item>
+        <el-descriptions-item label="地区">{{ regionLabel(detail) }}</el-descriptions-item>
         <el-descriptions-item label="平台门店编号">
           {{ detail.shop_id || "未返回(创建未成功)" }}
         </el-descriptions-item>
@@ -247,13 +285,14 @@
 </template>
 
 <script setup lang="ts">
-import { onMounted, reactive, ref } from "vue";
+import { computed, onMounted, reactive, ref } from "vue";
 import { ElMessage, type FormInstance, type FormRules } from "element-plus";
 import dayjs from "dayjs";
 import HarAPI, {
   HAR_SHOP_STATUS_TAG,
   HAR_SHOP_STATUS_TEXT,
   type HarMerchantVO,
+  type HarRegionVO,
   type HarShopCreateParams,
   type HarShopVO,
 } from "@/api/module_payment/har";
@@ -264,6 +303,75 @@ const enterpriseStore = useEnterpriseStore();
 /** 本租户可见的商户配置 —— 建店时用哪一条由这里选 */
 const merchantOptions = ref<HarMerchantVO[]>([]);
 
+/**
+ * 省市目录 —— 建店表单的两级选择器,同时用来把列表里的**码**显示成**名**。
+ *
+ * ★ 目录来自后端(`/payment/har/region/*`),**页面不存第二份省市表**:编码是按顾客 IP
+ *   挑店时比对的键,两份表一旦分叉,运营选出来的门店就会「永远挑不中」。
+ * ★ 市按省缓存:列表每页可能有多个省的门店,缓存住免得每行各打一次。
+ *   失败的省**不入缓存**(留着下次再试),成功的即使是空数组也入缓存(那是上游的答案)。
+ */
+const provinceOptions = ref<HarRegionVO[]>([]);
+const cityCache = reactive<Record<string, HarRegionVO[]>>({});
+const cityLoading = ref(false);
+/** 正在拉的城市所属省码 —— 拉 A 省时切到 B 省,A 的响应回来不能当成 B 的 */
+let loadingCityOf = "";
+
+async function ensureProvinces() {
+  if (provinceOptions.value.length) {
+    return;
+  }
+  try {
+    const res = await HarAPI.regionProvinces();
+    provinceOptions.value = res.data.data || [];
+  } catch {
+    // 拉不到就保持空:选择器空着,用户看得见「没得选」,比给一份猜的表强
+    provinceOptions.value = [];
+  }
+}
+
+async function ensureCities(code?: string | null) {
+  if (!code || cityCache[code]) {
+    return;
+  }
+  loadingCityOf = code;
+  cityLoading.value = true;
+  try {
+    const res = await HarAPI.regionCities(code);
+    cityCache[code] = res.data.data || [];
+  } catch {
+    // 不入缓存 ⇒ 重新选一次省可以再试(省码不认识时后端是 400,重试也还是 400,
+    // 但那是给人看的错,不该被这里吞成一个空下拉)
+  } finally {
+    if (loadingCityOf === code) {
+      cityLoading.value = false;
+    }
+  }
+}
+
+function onProvinceChange(code: string) {
+  // 换省必须把市清掉:留着一个属于上一省的市码,提交出去就是「省 32 + 市 1101」,
+  // 后端按省查表会拒(正确),但用户看到的是自己明明选过城市
+  form.city_code = "";
+  ensureCities(code);
+}
+
+/**
+ * 行的地区显示名。码 → 名的对照取自目录;目录里没有就**如实回退到码本身**
+ * (不猜名字),两格都空则明说「未填」—— 2026-10-08 前建的店就是这一种,
+ * 它们在挑店时必然落选,页面若显示成有地区,运营会以为这家店还能收款。
+ */
+function regionLabel(row: HarShopVO) {
+  if (!row.province_code && !row.city_code) {
+    return "未填(不参与按地区收款)";
+  }
+  const province = provinceOptions.value.find((p) => p.code === row.province_code);
+  const city = (cityCache[row.province_code || ""] || []).find((c) => c.code === row.city_code);
+  return [province?.name || row.province_code, city?.name || row.city_code]
+    .filter(Boolean)
+    .join(" / ");
+}
+
 const list = ref<HarShopVO[]>([]);
 const total = ref(0);
 const pageNo = ref(1);
@@ -291,16 +399,27 @@ const defaultForm = (): HarShopCreateParams => ({
   enterprise_id: "",
   third_shop_id: "",
   shop_name: "",
+  province_code: "",
+  city_code: "",
 });
 
 const form = reactive<HarShopCreateParams>(defaultForm());
 
+/** 表单里当前可选的城市(市级下拉) */
+const currentCities = computed(() =>
+  form.province_code ? cityCache[form.province_code] || [] : []
+);
+
 const rules: FormRules = {
   // 企业**非必填**:留空即按租户建店(enterprise_id 落 NULL 的租户级映射),
   // 与 HarShopCreateDTO 上「选填」的注释、后端 HarShopService#create 的分流一致。
   // 商户同理非必填:本租户只有一条配置时留空即为那条;有多条时后端会 400 要求指定。
   third_shop_id: [{ required: true, message: "请填写门店编码", trigger: "blur" }],
   shop_name: [{ required: true, message: "请填写门店名称", trigger: "blur" }],
+  // 地区两个都必填(规范 261008 5.5):这既是发上游的必填项,也是按顾客 IP 挑店的键。
+  // 门店建完在上游**没有更新接口** ⇒ 这里漏填/填错就是一家永远收不到款的门店。
+  province_code: [{ required: true, message: "请选择省份", trigger: "change" }],
+  city_code: [{ required: true, message: "请选择城市", trigger: "change" }],
 };
 
 const merchantLabel = (m: HarMerchantVO) =>
@@ -335,6 +454,11 @@ async function load() {
     });
     list.value = res.data.data?.items || res.data.data?.list || [];
     total.value = res.data.data?.total || 0;
+    // 本页各行涉及的省各拉一次市(有缓存就不重复拉)——列表里要把码显示成名,
+    // 缺这一句时每行只能显示编码,运营得自己背 3201 是哪个市
+    ensureProvinces();
+    new Set(list.value.map((row) => row.province_code).filter((code): code is string => !!code))
+      .forEach((code) => ensureCities(code));
   } finally {
     loading.value = false;
   }
@@ -356,6 +480,8 @@ function handleReset() {
 function openCreate() {
   Object.assign(form, defaultForm());
   createVisible.value = true;
+  // 目录是异步来的(也只在真正建店时用得上),别放进 onMounted 让列表页白等
+  ensureProvinces();
 }
 
 function handleCreateClosed() {
@@ -389,6 +515,9 @@ async function openDetail(row: HarShopVO) {
   const res = await HarAPI.shopDetail(row.id as string);
   detail.value = res.data.data;
   detailVisible.value = true;
+  // 详情里也要把地区显示成名字(这家店可能不在本页列表里,缓存里未必有)
+  ensureProvinces();
+  ensureCities(detail.value?.province_code);
 }
 
 onMounted(async () => {
@@ -414,4 +543,10 @@ onMounted(async () => {
   line-height: 1.6;
   color: var(--el-text-color-secondary);
 }
+
+/* 缺地区的门店在收款链接里必然落选(2026-10-08 前建的店补不上)——
+   用警示色,别让它与「有地区」的普通文本长得一样 */
+.region-missing {
+  color: var(--el-color-danger);
+}
 </style>