比较提交

...
作者 SHA1 备注 提交日期
jw a611948477 团期人员配置位成员改由字典决定,取值域三处对齐(#7079)
changelog-filename-gate / validate (push) Successful in 3s
PR #7085 合入 dev-v3(合并提交 d1b724d59),2026-09-04 部署测试环境
并经真实网关鉴权实测 7 条用例。

核心证据:15:30:33 往 group_batch_staff_slot_guide 插一行 GUIDE_ASSISTANT,
15:35:44(301 秒后)导游位候选由 7 人变 8 人——未改代码、未重启、未重新部署。
随后连打 8 次全部返回 8 人,两个实例都已回源。演示行验证后已撤除。

2 个接口的候选池来源与落库取值变更,路径/入参/出参结构全部不变:
- GET /v3/admin/group-batch/:groupBatchId/staff/candidates  候选池成员由字典决定,582113 文案变更
- PUT /v3/admin/group-batch/:groupBatchId/staff             staffRole 落真实 staffType

含字典初始化脚本 sql/dict_group_batch_staff_slot.sql(幂等,需手工执行)。
dict_type_id 取 8071/8072——8021/8022 在本机与测试环境都已被
contract_status / contract_platform 占用。

staff_role 取值域测试环境实测三处一致(字典 = 枚举 = @Pattern,8 项)。

frontend_status=not_required:路径、入参、出参结构均未变,前端无需改动;
但不要再硬编码候选池成员,也不要沿用自己提交的 staffRole 回显。
2026-09-04 15:37:23 +08:00
jw和Claude Opus 5 db6698061b docs(changelog): #7066 团期子订单列表补应收总额 totalAmount
changelog-filename-gate / validate (push) Successful in 2s
GET /v3/admin/order/group-batch/:groupBatchId/orders 出参新增 totalAmount
应收总额,口径复用既有 OrderAmountUtil.payableForDisplay(取消单归 0),
与 calcBalance 口径一致。纯 additive,不新建端点、不改老字段语义。

关键变化:前端不要再用 paidAmount + balanceAmount 反推应收总额。
balanceAmount 被钳在 ≥0,超付/退款/取消单三种情况反推值都偏大。

2026-09-04 于测试环境网关实测 8 组用例全部通过,两处在真实数据上
命中本单要修的缺陷:
- 超付单 totalAmount=2943.00,旧反推得 3270.00,偏差 327.00
- 取消单 totalAmount=0.00 而 paidAmount=3270.00,旧算法会把已取消
  子订单显示成有应收

backend_status=deployed / gateway_status=verified。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 15:01:20 +08:00
jw 2096a47340 团期导游位下游按配置位归组,GUIDE 与 LEADER 一视同仁(#7063)
changelog-filename-gate / validate (push) Successful in 2s
PR #7065 合入 dev-v3(合并提交 f37b6ee0f),2026-09-04 部署测试环境
并经真实网关鉴权实测 AC-2/AC-3/AC-4/AC-5/AC-9。

4 个接口的副作用与取值口径变更,路径/入参/出参结构全部不变:
- PUT  /v3/admin/group-batch/:groupBatchId/staff      导游位配 GUIDE 也置 guide_ready
- POST /v3/admin/order/:id/staff                      GUIDE 也写 guide_status 与 GUIDE_DONE
- DELETE /v3/admin/order/:id/staff/:staffAssignmentId 按导游位整组计数,不再误置空
- GET  /v3/admin/order/:id/print-itinerary            导游栏 GUIDE 优先、缺位回退 LEADER

顺带修一处更深的缺陷:ready 回填此前用产品侧 batchId 打 order_group_batch 主键,
rows=0 只 WARN 不抛,改前不论配 GUIDE 还是 LEADER,guide_ready 从未被置位过。

frontend_status=not_required:路径、入参、出参结构均未变,前端无需改动。
2026-09-04 14:25:48 +08:00
lc e5356a65e0 供应商企微在途状态统一审核中(#7069)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 12:55:57 +08:00
wx 71cc52c1a3 changelog(6926): 导出补 opsStage 筛选 + 统计条自校验死代码修正(修改接口) Refs #6926
changelog-filename-gate / validate (push) Failing after 2s
2026-09-04 11:26:36 +08:00
lc 0df2c917bf docs: add supplier context handoff (#7059)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 11:11:05 +08:00
API Changelog Bot cf78c344a1 changelog(6905): 003 totalPrice 应收字段 + birthdayInTrip 跨年修正已实现(#6929)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 10:31:34 +08:00
lc a0daab2b90 更正供应商类型过滤前端状态(#7042)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 09:51:13 +08:00
lc 5046bce500 docs: add supplier type filter handoff for #7042
changelog-filename-gate / validate (push) Successful in 2s
2026-09-03 21:48:26 +08:00
lc c80d1975aa docs(changelog): #7036 供应商状态变更企微审批
changelog-filename-gate / validate (push) Successful in 2s
2026-09-03 20:03:18 +08:00
lc f4354d8c31 docs(changelog): #7029 供应商账户企微审批
changelog-filename-gate / validate (push) Successful in 3s
2026-09-03 15:51:47 +08:00
jw和Claude Opus 5 828d9ae2e4 docs(changelog): #6986 团期物资从备品库选择 + suppliesName 二选一校验
changelog-filename-gate / validate (push) Successful in 2s
新增 GET /v3/admin/order/group-batch/:groupBatchId/supplies/candidates,
从资源域备品库拉候选并标记本团期已加入项;资源域新增内部接口
GET /internal/supplies/list-available,两个服务需同时部署。

记录本次行为变更:POST .../supplies 的 suppliesName 由 @NotBlank
改为与 suppliesResourceId 二选一,传 resourceId 时名称从库取并覆盖入参。

新增错误码 589518 备品库查询失败、589519 所选备品不存在或已下架。

2026-09-03 于测试环境网关实测 10 条正负向用例全部通过,
写入用例已删除还原;backend_status=deployed / gateway_status=verified。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 15:22:10 +08:00
jw和Claude Opus 5 d2c39b6c24 docs(changelog): #6950 团期人员配置候选列表新增接口 + staffRole 白名单校验
changelog-filename-gate / validate (push) Successful in 2s
新增 GET /v3/admin/group-batch/:groupBatchId/staff/candidates,导游位并收
GUIDE/LEADER 并按 staffId 去重,摄影位只收 PHOTOGRAPHER,其余位返 582113。

同时记录本次行为变更:PUT .../staff 的 staffList[].staffRole 新增
@Pattern 白名单校验,越界值由原先原样入库改为返回 400。

2026-09-03 于测试环境网关实测 10 条正负向用例全部通过,
backend_status=deployed / gateway_status=verified。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 14:57:42 +08:00
lc 411a549548 docs(changelog): #7013 启用供应商清账归档
changelog-filename-gate / validate (push) Successful in 3s
2026-09-03 10:42:52 +08:00
lc f68b6d80d4 docs(changelog): #6979 供应商状态流转契约
changelog-filename-gate / validate (push) Successful in 2s
2026-09-03 08:10:43 +08:00
lc c579da759e docs(changelog): #6938 供应商注册提交可选主表字段缺席保留(管理后台)
changelog-filename-gate / validate (push) Successful in 3s
2026-09-02 11:30:39 +08:00
API Changelog Bot c66a28b43f changelog(团期看板): 6904/6905 复审补充知会-排序口径/008 opsStage预告/chips空判/demandStatus与totalPrice待补(管理后台)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-01 10:01:41 +00:00
API Changelog Bot 6c1d11d40f changelog(6921): 六芯片读端接入专用投影+车芯片车务文案修正(管理后台/修改接口)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-01 16:13:30 +08:00
API Changelog Bot 9428076c2c Merge remote-tracking branch 'origin/main'
changelog-filename-gate / validate (push) Successful in 2s
2026-09-01 15:59:00 +08:00
API Changelog Bot 95394c35fc docs(changelog): 6905 团期看板 4 接口对齐补全-补齐模板章节(使用场景/请求响应错误示例/VO 契约/清单对应) 2026-09-01 15:51:27 +08:00
API Changelog Bot edfffdf063 docs(changelog): 6905 团期看板 4 接口对齐补全-逐接口规范化 请求/响应/空数据/错误/业务边界 示例顺序 2026-09-01 15:15:47 +08:00
API Changelog Bot 06a86d488d docs(changelog): 6905 团期看板 4 接口对齐补全-逐接口补请求/响应/错误示例并规范化模板标题 2026-09-01 15:11:03 +08:00
API Changelog Bot 082f32ad3f changelog(6905): 团期看板 4 接口对齐补全 GB-ADM-000/001/002/003(管理后台/修改接口) 2026-09-01 14:56:52 +08:00
Mimingguang cfc3aa8b1e docs(changelog): #6904 补前端评估(待看板列表契约)
changelog-filename-gate / validate (push) Successful in 2s
status_note 增前端评估:看板纯 mock,统计条/导出依赖真实看板页;看板列表
契约 #6905/GB-ADM-000~003 未入仓,等契约后统一接真。frontend_status 保持 pending。
2026-09-01 14:24:09 +08:00
Mimingguang 72949d869c docs(changelog): #6903 补前端评估(待看板接真)
changelog-filename-gate / validate (push) Successful in 2s
status_note 增前端评估:团期看板纯 mock、chips 需真实 groupBatchId,先接真
看板再接下钻,形态定弹层明细。frontend_status 保持 pending。
2026-09-01 14:18:43 +08:00
API Changelog Bot a031039140 chore(6904): changelog 文件名归档 01 前缀 (2026-09-01)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-01 13:26:58 +08:00
API Changelog Bot c9efecebc5 chore(6904): 团期看板统计条与导出 GB-ADM-009/008 变更归档 (dev-v3) 2026-09-01 13:26:58 +08:00
API Changelog Bot 4c12c1db01 chore(changelog): 6903 团期看板六芯片逐户明细 GB-ADM-090~095 接口交接
changelog-filename-gate / validate (push) Successful in 2s
2026-09-01 10:16:46 +08:00
Mimingguang 937f353b41 docs(changelog): 回写 #6842 前端已交付(hl-admin f5414b12)
changelog-filename-gate / validate (push) Successful in 2s
frontend_status verified,frontend_ref f5414b12,target_release v2.1。
2026-09-01 09:52:26 +08:00
Mimingguang 43bf3936d1 docs(changelog): 回写 #6834 前端已交付(hl-admin 848cca7b)
changelog-filename-gate / validate (push) Successful in 2s
frontend_status verified,frontend_ref 848cca7b,target_release v2.1。
2026-09-01 09:31:20 +08:00
Mimingguang 33a8f7cb5b docs(changelog): 回写派单 605074 车队停用前端已消费(frontend_ref f1b861c6)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-01 09:13:41 +08:00
wx 5ce53bcd47 docs: 供应商暂停合作联动停用车队与派单拦截(#6843) (#84)
changelog-filename-gate / validate (push) Successful in 1s
docs: 供应商暂停合作联动停用车队与派单拦截(#6843) (#84)
2026-08-31 14:36:14 +08:00
lc 430d825904 补充供应商余额与支付类型接口说明(#6834)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-31 12:24:30 +08:00
lc 2b60fc3323 补充供应商合同字段与附件(#6842)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-31 12:20:58 +08:00
Mimingguang 070c937a08 docs(changelog): 回写车队 601112 在役车辆守卫前端已消费(frontend_ref 6985f0cf)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-31 10:55:41 +08:00
API Changelog Bot e28f675c8f 车队保存无供应商时自动停用与在役车辆守卫(#6811)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-31 10:46:03 +08:00
Mimingguang 787b9a007b docs(changelog): 回写车队排序校验缺陷前端已修复(frontend_ref e2b2482e)
changelog-filename-gate / validate (push) Successful in 3s
2026-08-31 09:56:51 +08:00
API Changelog Bot d81fea420a docs(changelog): 车队编辑弹窗排序已填仍提示请输入排序-前端缺陷通知
changelog-filename-gate / validate (push) Successful in 2s
2026-08-31 09:45:39 +08:00
Mimingguang 0e1ab992e8 chore(changelog): 回写 #6717 前端消费状态
changelog-filename-gate / validate (push) Successful in 2s
2026-08-30 18:10:46 +08:00
wx 95212ad9a5 docs: 车队关联供应商并展示供应商全名(#6717) (#83)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-30 17:54:25 +08:00
Mimingguang 2ebb4644a6 chore(changelog): 回写 #6684/#6710/#6739 前端消费状态
changelog-filename-gate / validate (push) Successful in 2s
2026-08-30 17:44:59 +08:00
lc 17bb0ef2bf 新增供应商三级地址保存与回显契约(#6739)
changelog-filename-gate / validate (push) Successful in 3s
2026-08-30 15:44:39 +08:00
lc 5c52926269 docs: 精简6710前端变更记录
changelog-filename-gate / validate (push) Successful in 2s
2026-08-30 10:24:09 +08:00
lc a45b51d8a8 docs: 更正6710为无需前端处理
changelog-filename-gate / validate (push) Successful in 2s
2026-08-30 10:16:40 +08:00
lc 73a4518181 docs: 交付供应商联系人快照版本兼容 (#6710)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-30 10:08:10 +08:00
lc 8821d1df6f docs: 交付供应商草稿变更原因契约 (#6684)
changelog-filename-gate / validate (push) Successful in 1s
2026-08-30 08:18:42 +08:00
Mimingguang 51708dc7a6 chore(changelog): 回写 #6669 前端消费状态 verified(frontend_ref 6678b97f)
changelog-filename-gate / validate (push) Successful in 3s
2026-08-29 17:34:50 +08:00
lc b28bd34ceb Merge pull request 'docs(changelog): 补充草稿结算编辑契约 #6669' (#82) from chore/6669-6676-supplier-draft-settlement-changelog into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-29 17:12:56 +08:00
lc 1ba3299afa docs(changelog): 补充草稿结算编辑契约 #6669
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-29 17:12:25 +08:00
Mimingguang bd235c2e1d chore(changelog): 回写 #6654 前端消费状态 verified(frontend_ref 5fe4e6fa)
changelog-filename-gate / validate (push) Successful in 3s
2026-08-29 16:32:05 +08:00
lc eb628f56b0 docs(changelog): 补充 #6654 供应商合同与结算契约
changelog-filename-gate / validate (push) Successful in 2s
2026-08-29 16:15:01 +08:00
Mimingguang b54c4cc8d7 chore(changelog): 回写 #6544/#6620/#6643 前端消费状态 verified
changelog-filename-gate / validate (push) Successful in 2s
修改原因:三条 changelog 前端已交付并推送 hl-admin v2.1,回写 frontmatter 消费证据。
- #6544 供应商合同独立登记 → frontend_ref 55a3a056
- #6620 主体证件号 taxNo 回显(DRAFT 可改非 DRAFT 锁定) → frontend_ref 9d5dcbab
- #6643 营业执照 licenseImageUrl 顶层权威回显 → frontend_ref bab3673b
2026-08-29 15:07:39 +08:00
lc 4621f11e36 交接供应商编辑字段回显契约(#6643)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-29 14:36:10 +08:00
lc 1f608928b7 Merge pull request 'docs: 交接供应商 tax_no 表单绑定缺口 (#6620)' (#81) from changelog/6620-supplier-taxno-frontend into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-29 12:01:09 +08:00
lc a4aba37568 docs: hand off supplier tax_no binding #6620
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-29 11:53:48 +08:00
lc 60a28339e0 Merge pull request 'docs: 纠正供应商合同独立登记交付状态 (#6544)' (#80) from changelog/6544-supplier-contract-boundary into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-29 09:40:44 +08:00
lc d3b5c0e8c9 docs: 纠正供应商合同独立登记交付状态 (#6544)
changelog-filename-gate / validate (pull_request) Successful in 1s
2026-08-29 09:40:18 +08:00
API Changelog Bot 0474e6ff78 docs: 交付供应商合同独立登记契约(#6544)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-29 00:55:23 +08:00
Mimingguang 9aace47441 chore(changelog): 回写 #6474 供应商变更/审批分离 verified(199147de/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 17:01:30 +08:00
lc 605859b12f docs: 交付供应商历史分离契约(#6474)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:44:13 +08:00
Mimingguang 734a9b1112 chore(changelog): 回写 #6518 供应商草稿即生成编号 verified(d546912c/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:38:45 +08:00
lc 20272bb706 docs: 下发供应商草稿编号契约(#6518)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:28:01 +08:00
Mimingguang 59090c889b chore(changelog): 回写 #6476 供应商详情地址备注 verified(6635a2ae/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 16:03:03 +08:00
lc 07387e506f docs(changelog): 记录 #6476 供应商表单契约
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 15:41:38 +08:00
Mimingguang e4f7c5cb0a chore(changelog): 回写 #6436 供应商敏感字段完整回显 verified(5957c58c/v2.1)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 12:31:26 +08:00
lc cf3fe30a0f docs: 交付 #6436 供应商完整字段契约
changelog-filename-gate / validate (push) Successful in 3s
2026-08-27 11:42:09 +08:00
Mimingguang e91101c037 docs(changelog): 回写 #6397 amount String 化前端 verified
changelog-filename-gate / validate (push) Successful in 2s
前端 002475b2 已按字符串重新集成 PR #6450 修正(响应 contracts[].amount Number→String),fillForm toContractAmount 归一 number、详情列透传、提交侧 Number 不变,补 String 回填测试。
2026-08-27 00:25:29 +08:00
API Changelog Bot ebc2fecbfd docs(changelog): #6397 全面对齐模板——逐接口自包含详情 + 五、数据库行为 + 九、相关历史PR
changelog-filename-gate / validate (push) Successful in 2s
2026-08-27 00:04:27 +08:00
API Changelog Bot e362441e9c docs(changelog): #6397 接口详情子节编号化 + 补六.6/六.7 章节 2026-08-26 23:58:34 +08:00
API Changelog Bot 81aab7abb0 docs(changelog): #6397 对齐 CHANGELOG_TEMPLATE 必备章节(二/三/四/六/七/八/十) 2026-08-26 23:56:51 +08:00
API Changelog Bot 4f81ef1cf9 fix(changelog): #6397 响应 contracts[].amount Number→String 同日修正 (PR #6450)
后端每日审查红线修复: 合同金额 BigDecimal 补 ToStringSerializer,输出字符串化与全站金额对齐;
frontend_status 回退 pending 待 mmg 按字符串重新集成,请求侧 Number 不变。
2026-08-26 23:53:04 +08:00
lc ca11783f72 Merge pull request 'fix(changelog): 按 TEST 代码修正行政区划契约 (#6438)' (#79) from docs/6438-region-changelog-code-sync into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 18:01:45 +08:00
lc 3a2c1ae38d fix(changelog): 按 TEST 代码修正行政区划契约 (#6438)
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-26 17:59:58 +08:00
Mimingguang 7b37f31df6 chore(changelog): #6350 前端接入完成(行政区划三级级联,ref=7acd1cb0)
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 17:20:35 +08:00
lc 49f8b6d0a0 Merge pull request 'fix(changelog): 补齐行政区划三级联动接口文档与模板门禁(#6422)' (#78) from fix/6422-region-changelog-contract into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 16:24:50 +08:00
lc 3d6f6245a0 补齐行政区划三级联动接口文档与模板门禁(#6422)
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-26 16:23:19 +08:00
Mimingguang ff16cfd94c chore(changelog): #6392 管理端 verified (ref=49b071b0)
changelog-filename-gate / validate (push) Failing after 2s
2026-08-26 16:11:05 +08:00
lc 2e18de27e1 Merge pull request 'docs(changelog): 交付行政区划主数据接口(#6350)' (#77) from docs/6350-region-api-changelog into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 15:53:04 +08:00
共修改 45 个文件,包含 15256 行新增和 221 行删除
+3 -3
查看文件
@@ -2,7 +2,7 @@
# changelog 发布门禁(推送前强制校验)
# 启用(每台机一次): git config core.hooksPath .githooks
# 拦截目标: 接口类 changelog 未部署测试服(backend_status != deployed)就推送给前端,
# 以及文件名/frontmatter 结构违规。规则实现见 scripts/validate-changelog-*.mjs。
# 以及文件名/frontmatter/CHANGELOG_TEMPLATE.md 结构违规。规则实现见 scripts/validate-changelog-*.mjs。
zero=0000000000000000000000000000000000000000
status=0
while read local_ref local_sha remote_ref remote_sha; do
@@ -19,7 +19,7 @@ while read local_ref local_sha remote_ref remote_sha; do
done
if [ "$status" -ne 0 ]; then
echo "" >&2
echo "推送被 changelog 发布门禁拦截:接口类条目必须测试服已部署+实测(backend_status=deployed)后才能推送给前端。" >&2
echo "修正文件后重试;规则详见 BACKEND_CHANGELOG_DELIVERY_GUIDE.md §2.1。" >&2
echo "推送被 changelog 发布门禁拦截:接口类条目必须已部署实测,并完整仿照 CHANGELOG_TEMPLATE.md。" >&2
echo "每个接口需自含入参、出参、请求/响应、错误和业务边界;修正规则详见 BACKEND_CHANGELOG_DELIVERY_GUIDE.md。" >&2
fi
exit $status
+7 -1
查看文件
@@ -25,7 +25,8 @@ changelogs-v2/2026-07/24_5205_车务首页汇总状态补全-修改接口-管理
## 2. 写什么
可以复制仓库根目录的 `CHANGELOG_TEMPLATE.md`,至少写清:
必须复制并按仓库根目录的 `CHANGELOG_TEMPLATE.md` 组织接口文档。接口类 Changelog 不能只写
路径和变更摘要,至少写清:
- 关联的 Issue 和后端 PR;
- 接口路径和 HTTP 方法;
@@ -34,6 +35,11 @@ changelogs-v2/2026-07/24_5205_车务首页汇总状态补全-修改接口-管理
- 前端需要做什么;
- 后端测试、部署和网关验证结果。
多接口条目必须让每个接口小节独立包含入参、出参、请求示例、响应示例、错误响应和业务边界;
“变更接口清单”的 `METHOD /path` 必须与逐接口详情一一对应。该结构由
`check:frontmatter` 机器校验,缺失时分别报 `E_API_TEMPLATE`、`E_API_ENDPOINTS` 或
`E_API_DETAIL`。存量接口文档一旦修改,也必须升级到当前模板标准。
元数据中:
```yaml
+12 -2
查看文件
@@ -3,7 +3,8 @@ schema: "hl-changelog/v2"
ticket: "{issue-no}"
title: "{一句话概括变化}"
consumer: "{admin|mp|internal|multiple}"
author: "{推送者登录名}(GIT)" # 如 wx(GIT)/yst(GIT),谁 push 到 main 就写谁
# author 示例: wx(GIT) / yst(GIT);谁 push 到 main 就写谁
author: "{推送者登录名}(GIT)"
change_type: "{新增接口|修改接口|删除接口}"
backend_status: "pending"
gateway_status: "pending"
@@ -67,6 +68,10 @@ base: "{dev|dev-v3}"
**VO**: `{VO 类名}`
#### 使用场景
说明前端在什么页面、什么时机调用;多接口条目中每个接口都必须自包含,不依赖其他章节补参数或错误语义。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
@@ -113,9 +118,14 @@ base: "{dev|dev-v3}"
}
```
#### 业务边界
- 写清本接口自己的鉴权、空值、状态、幂等/并发、失败零写入和兼容规则。
- 不要只在全局章节写一次;消费方应能单独阅读本接口小节完成联调。
---
## 四、契约约束与正确调用方式(选填,字段互斥/联动/切换场景必写)
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
+18
查看文件
@@ -75,6 +75,24 @@ pending → claimed → implemented → released → verified
- `FRONTEND_CONSUMPTION_STATUS_GUIDE.md`
- `BACKEND_CHANGELOG_DELIVERY_GUIDE.md`
## 接口文档模板门禁
新增、修改或删除接口的 Changelog 必须以仓库根目录
[`CHANGELOG_TEMPLATE.md`](CHANGELOG_TEMPLATE.md) 为结构基线,不能只写接口路径和一段变更摘要。
机器校验要求:
- 使用“二、变更接口清单”的标准六列表格;
- 清单中的每个 `METHOD /path` 都必须在“三、接口详情”中有且只有一个对应小节;
- 每个接口小节必须自含入参字段表、出参字段表、请求示例、响应示例、错误响应和业务边界;
- 含写接口时必须说明外部可观察的数据库行为,但不得泄露物理表结构;
- 修改或删除接口必须补“修改前后对比”和“影响评估”;
- 必须保留契约约束、边界行为、不影响范围、TEST 验证、相关文档及联系人章节。
缺少上述内容时 `check:frontmatter` 返回 `E_API_TEMPLATE`、`E_API_ENDPOINTS` 或
`E_API_DETAIL`,PR/push 检测失败。存量文件不全量追责,但任何接口类文件一旦新增或修改,
就必须补到当前模板标准。
已下发路径是消费契约的一部分,不通过重命名表达状态。历史路径已发生迁移时:
- `changelog-path-aliases.json` 是机器可识别的唯一映射源;
@@ -7,67 +7,290 @@ author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "7acd1cb0"
target_release: "v2.1"
verified_at: "2026-08-26"
status_note: "主工单 #6350 及补充工单 #6384、#6389、#6395、#6399、#6409 的六个 PR 均已合并 dev-v3。精确提交 b62054035a139e3c689179ef75e2d052d08dde52 已由 Deploy Panel 任务 52771074(hl-user-service)和 b1db24b2(hl-gateway)部署 TEST;真实管理员经 Gateway 完成未认证、读取、失败零写入、创建、CAS 更新、数据库和 Redis 一致性验收,临时数据、缓存快照及登录会话均已恢复。管理端尚待接入新增接口。"
status_note: "主工单 #6350 及补充工单 #6384、#6389、#6395、#6399、#6409 的六个 PR 均已合并 dev-v3。精确提交 b62054035a139e3c689179ef75e2d052d08dde52 已部署 TEST;真实管理员经 Gateway 完成未认证、读取、失败零写入、创建、CAS 更新、数据库和 Redis 一致性验收。#6422 已按 CHANGELOG_TEMPLATE.md 补齐四个接口的前端联调契约,三级联动由 mmg 以 ref 7acd1cb0 完成验证;#6438 再按 TEST 代码修正种子父链、聚合节点、可扩展编码命名空间、异步缓存刷新和 extension 更新限制。"
updated_at: "2026-08-26"
base: "dev-v3"
---
# 行政区划主数据与三级联动
# User: 行政区划主数据与三级联动
管理后台新增可版本化的行政区划主数据接口,统一提供省、市、县三级联动、任意节点父链回显,以及受权限、有效期、层级和并发版本保护的创建与更新能力。
> **服务**: `hl-user-service`(统一经 Gateway 访问)
>
> **PR**: #6359、#6387、#6393、#6398、#6402、#6412
>
> **Issue**: #6350、#6384、#6389、#6395、#6399、#6409、#6422、#6438
>
> **日期**: 2026-08-26
>
> **影响范围**: 管理后台行政区划维护、省/市/县三级选择器和历史值父链回显
> **服务**:`hl-user-service`,统一经 Gateway `/admin/region/**` 访问
> **Issue**:#6350、#6384、#6389、#6395、#6399、#6409
> **PR**:#6359、#6387、#6393、#6398、#6402、#6412
> **影响范围**:管理后台行政区划维护、三级选择器和历史值父链回显
---
## 关键约定
## ⚠️ 关键变化
- 所有响应中的行政区划 ID 均为 JSON 字符串,包括 `data`、`parentId`、`defaultId`、`selectedId`、`provinceId`、`prefectureId`、`countyId` 和节点 `id`;没有父级或对应层级时保持 `null`。
- 当前写入边界为省、市、县三级,层级代码依次为 `PROVINCE`、`PREFECTURE`、`COUNTY`;服务端根据父链计算 `depth`,请求不能直接指定深度。
- `ADMINISTRATIVE` 表示可作为业务行政区的节点;`AGGREGATION` 只用于导航分组,必须使用 `HL_GROUP`、`navigable=true`、`selectable=false`。
- 有效期使用左闭右开区间 `[validFrom, validTo)`;`validTo=null` 表示持续有效。
- 读接口只要求真实管理员登录;创建还要求 `system:region:create`,更新要求 `system:region:update`。
- 统一响应为 `Result<T>`。业务失败可能仍是 HTTP 200,调用方必须同时检查 `code`、`success`、`message` 和 `data`。
- 本次补文档不改变 TEST 上已经部署的接口行为;它把原 Changelog 中分散的说明整理为可直接联调的完整契约。
- 前端不得再把行政区划 ID 当 JavaScript number:响应中的所有行政区划 ID 均为 JSON string,缺失层级保持真正的 `null`。
- 三级联动不允许通过行政区编码截位推断父子关系;必须逐级调用 `children`,编辑回显先调用 `path`。
- `PREFECTURE` 是第二层导航层,不保证一定是可选择的法定地级行政区;直辖市等链路会返回 `AGGREGATION` 聚合节点,必须继续下钻且不得作为最终业务值。
- 当前没有单节点详情接口,读响应也不返回 `extension`;更新接口省略或传 `null` 都会清空该字段,前端不能把它理解成“保持原值”。
- 业务失败可能仍使用 HTTP 200,调用方必须同时判断响应体 `code`、`success`、`message` 和 `data`。
## 变更接口清单
---
| 方法 | 路径 | 权限 | 用途 |
|---|---|---|---|
| POST | `/admin/region` | `system:region:create` | 创建行政区或导航聚合节点 |
| PUT | `/admin/region/{id}` | `system:region:update` | 使用 `rowVersion` 更新可变字段 |
| GET | `/admin/region/children` | 管理员登录 | 查询根层或指定父节点的当前有效直接子节点 |
| GET | `/admin/region/{id}/path` | 管理员登录 | 返回任意节点从省级根开始的完整父链 |
## 一、背景
## 1. 单级联动列表
管理后台需要一套统一的行政区划主数据能力,同时支持:
- 省、市、县三级选择器逐级加载;
- 编辑历史业务数据时,从任意节点恢复完整父链;
- 具有专用权限的管理员创建自定义行政区、带来源证据的新法定版本或导航聚合节点;
- 使用 `rowVersion` 防止两位管理员并发更新时互相覆盖。
接口当前只开放省、市、县三级写入,层级代码依次为 `PROVINCE`、`PREFECTURE`、`COUNTY`。第二层可能是 `AGGREGATION` 导航分组,不等同于可提交的法定地级行政区。下文读取示例使用 TEST migration 中的北京链路 `1 → 35 → 376`,仅用于说明响应结构,业务代码仍不得硬编码。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 单级联动列表 | GET | `/admin/region/children` | 新增接口 | 加载根层省级或指定父节点的当前有效直接子节点 |
| 2 | 父链回显 | GET | `/admin/region/{id}/path` | 新增接口 | 从任意省/市/县节点恢复省级根到目标节点的完整路径 |
| 3 | 创建行政区划节点 | POST | `/admin/region` | 新增接口 | 创建自定义行政区、新法定版本或不可选择的导航聚合节点 |
| 4 | 更新行政区划节点 | PUT | `/admin/region/{id}` | 新增接口 | 使用完整可变字段和 `rowVersion` 执行 CAS 更新 |
---
## 三、接口详情
### 1. 单级联动列表 `GET /admin/region/children`
**VO**: `AdministrativeRegionChildrenVO / AdministrativeRegionItemVO`
#### 使用场景
- 新建页面首次加载省级列表:省略 `parentId`。
- 选择省后加载市、选择市后加载县:把当前选中节点 ID 作为下一次请求的 `parentId`。
- 编辑页面逐层恢复默认值:把本层历史 ID 作为 `selectedId`;它仍在当前 `items` 中时会成为 `defaultId`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `Authorization` | Header | String | ✅ | `Bearer <token>` | 真实管理端登录令牌 |
| `parentId` | Query | String | ❌ | 十进制正整数;根层省级列表必须省略 | 指定要查询直接子节点的父节点 ID |
| `selectedId` | Query | String | ❌ | 十进制正整数 | 编辑回显时希望优先选中的本级节点 ID |
无请求体。
#### 出参 `Result<AdministrativeRegionChildrenVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | `200` 表示成功;业务失败读取具体业务码 |
| `message` | String | 响应说明 |
| `success` | Boolean | 仅当 `code=200` 时为 `true` |
| `traceId` | String / null | 链路追踪 ID;报错排查时提供给后端 |
| `data.parentId` | String / null | 本次查询父节点;根层查询为 `null` |
| `data.defaultId` | String / null | 合法 `selectedId`,否则为排序后首项 ID;空列表为 `null` |
| `data.items` | Array | 当前有效且状态为 `ACTIVE` 的直接子节点,稳定排序 |
| `data.items[].id` | String | 节点 ID,始终为字符串 |
| `data.items[].parentId` | String / null | 父节点 ID;省级根节点为 `null` |
| `data.items[].codeStandard` | String | 编码标准,例如 `GB/T 2260`、`HL_CUSTOM`、`HL_GROUP` |
| `data.items[].regionCode` | String | 编码标准内的地区代码 |
| `data.items[].levelCode` | String | `PROVINCE`、`PREFECTURE` 或 `COUNTY` |
| `data.items[].depth` | Integer | 树深度,省/市/县分别为 1/2/3 |
| `data.items[].name` | String | 行政区划完整名称 |
| `data.items[].shortName` | String / null | 简称 |
| `data.items[].nodeKind` | String | `ADMINISTRATIVE` 或 `AGGREGATION` |
| `data.items[].navigable` | Boolean | 是否允许继续查询下一层 |
| `data.items[].selectable` | Boolean | 是否允许作为最终业务行政区值 |
| `data.items[].hasChildren` | Boolean | 是否存在当前有效、启用的直接子节点 |
| `data.items[].currentlyEffective` | Boolean | 中国业务当日是否位于有效期且状态启用 |
| `data.items[].status` | String | `ACTIVE` 或 `INACTIVE` |
| `data.items[].validFrom` | String | 生效日期,格式 `yyyy-MM-dd` |
| `data.items[].validTo` | String / null | 失效日期;`null` 表示持续有效 |
| `data.items[].sortOrder` | Integer | 同级排序号 |
| `data.items[].rowVersion` | Integer | 当前 CAS 版本,更新时原样提交 |
#### 请求示例
```http
GET /admin/region/children?parentId=1&selectedId=35
GET /admin/region/children?parentId=1&selectedId=35 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员令牌>
Accept: application/json
```
查询省级根列表时省略 `parentId`。`selectedId` 只有在本次 `items` 中存在时才作为 `defaultId`;否则回退为排序后的首项,空列表返回 `null`。
根层省级列表:
```http
GET /admin/region/children HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员令牌>
Accept: application/json
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"parentId": "1",
"items": [
{
"id": "35",
"parentId": "1",
"codeStandard": "GB/T 2260",
"regionCode": "110100",
"codeStandard": "HL_GROUP",
"regionCode": "HL-GROUP-110000",
"levelCode": "PREFECTURE",
"depth": 2,
"name": "北京市辖区",
"shortName": null,
"nodeKind": "AGGREGATION",
"navigable": true,
"selectable": false,
"hasChildren": true,
"currentlyEffective": true,
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 2000000001,
"rowVersion": 0
}
],
"defaultId": "35"
},
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
#### 空数据 / 降级响应
父节点存在但没有当前有效直接子节点时,返回成功空数组。Redis 读取失败时服务会降级查数据库,成功结构不变:
```json
{
"code": 200,
"message": "成功",
"data": {
"parentId": "376",
"items": [],
"defaultId": null
},
"success": true
}
```
#### 错误响应
父节点不存在或已删除:
```json
{
"code": 210801,
"message": "行政区划节点不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 只返回 `status=ACTIVE` 且当前有效的直接子节点,停用或历史节点不会混入当前选择项。
- `selectedId` 不在本次 `items` 中时不会报错,而是稳定回退到排序后的第一项;空列表回退为 `null`。
- 排序固定为 `sortOrder`、`regionCode`、`id`,前端不得另用行政区编码推断顺序或父子关系。
- `navigable` 控制能否继续向下加载,`selectable` 控制能否作为最终值;两者不能相互替代。
- `defaultId` 只表示本级默认导航项,不保证 `selectable=true`;例如北京第二层默认项 `"35"` 是不可提交的聚合节点。
- 未登录返回业务码 `401`;`parentId=0` 等非正整数参数返回业务码 `400`。
---
### 2. 父链回显 `GET /admin/region/{id}/path`
**VO**: `AdministrativeRegionPathVO / AdministrativeRegionItemVO`
#### 使用场景
编辑已有业务数据时,后端只保存了一个最终行政区 ID。前端先调用本接口得到完整三级父链,再按父链逐层调用 `children` 加载每一级导航项。父链的第二层可能是不可选择的 `AGGREGATION`,不能仅凭 `prefectureId` 字段名判断它一定是法定地级行政区。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `Authorization` | Header | String | ✅ | `Bearer <token>` | 真实管理端登录令牌 |
| `id` | Path | String | ✅ | 十进制正整数 | 要回显的省、市或县节点 ID |
无请求体。
#### 出参 `Result<AdministrativeRegionPathVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | `200` 表示成功;业务失败读取具体业务码 |
| `message` | String | 响应说明 |
| `success` | Boolean | 仅当 `code=200` 时为 `true` |
| `traceId` | String / null | 链路追踪 ID;报错排查时提供给后端 |
| `data.selectedId` | String | 调用方传入并成功解析的目标节点 ID |
| `data.provinceId` | String | 父链中的省级 ID |
| `data.prefectureId` | String / null | 父链第二层 ID,可能是地级行政区或聚合导航节点;目标为省级时为 `null` |
| `data.countyId` | String / null | 父链中的县级 ID;目标高于县级时为 `null` |
| `data.path` | Array | 从省级根到目标节点的完整有序父链 |
| `data.path[].id` | String | 节点 ID,始终为字符串 |
| `data.path[].parentId` | String / null | 父节点 ID;首个省级节点为 `null` |
| `data.path[].codeStandard` | String | 编码标准 |
| `data.path[].regionCode` | String | 地区代码 |
| `data.path[].levelCode` | String | 省/市/县层级代码 |
| `data.path[].depth` | Integer | 从 1 开始的连续深度 |
| `data.path[].name` | String | 完整名称 |
| `data.path[].shortName` | String / null | 简称 |
| `data.path[].nodeKind` | String | 节点类型 |
| `data.path[].navigable` | Boolean | 是否允许逐级导航 |
| `data.path[].selectable` | Boolean | 是否允许作为最终值 |
| `data.path[].hasChildren` | Boolean | 是否存在当前有效直接子节点 |
| `data.path[].currentlyEffective` | Boolean | 当前是否有效;历史节点可能为 `false` |
| `data.path[].status` | String | `ACTIVE` 或 `INACTIVE` |
| `data.path[].validFrom` | String | 生效日期 |
| `data.path[].validTo` | String / null | 失效日期 |
| `data.path[].sortOrder` | Integer | 同级排序号 |
| `data.path[].rowVersion` | Integer | 当前 CAS 版本 |
#### 请求示例
```http
GET /admin/region/376/path HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员令牌>
Accept: application/json
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"selectedId": "376",
"provinceId": "1",
"prefectureId": "35",
"countyId": "376",
"path": [
{
"id": "1",
"parentId": null,
"codeStandard": "GB/T 2260",
"regionCode": "110000",
"levelCode": "PROVINCE",
"depth": 1,
"name": "北京市",
"shortName": null,
"nodeKind": "ADMINISTRATIVE",
@@ -78,80 +301,143 @@ Authorization: Bearer <管理员令牌>
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 100,
"sortOrder": 10,
"rowVersion": 0
}
],
"defaultId": "35"
}
}
```
仅返回中国业务当日有效且状态为 `ACTIVE` 的直接子节点,排序稳定为 `sortOrder`、`regionCode`、`id`。停用或历史节点不会进入联动列表,但仍可通过父链接口回显。
## 2. 父链回显
```http
GET /admin/region/376/path
Authorization: Bearer <管理员令牌>
```
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"selectedId": "376",
"provinceId": "1",
"prefectureId": "35",
"countyId": "376",
"path": [
{
"id": "1",
"parentId": null,
"levelCode": "PROVINCE",
"depth": 1,
"name": "北京市",
"nodeKind": "ADMINISTRATIVE",
"currentlyEffective": true
},
{
"id": "35",
"parentId": "1",
"codeStandard": "HL_GROUP",
"regionCode": "HL-GROUP-110000",
"levelCode": "PREFECTURE",
"depth": 2,
"name": "北京市",
"nodeKind": "ADMINISTRATIVE",
"currentlyEffective": true
"name": "北京市辖区",
"shortName": null,
"nodeKind": "AGGREGATION",
"navigable": true,
"selectable": false,
"hasChildren": true,
"currentlyEffective": true,
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 2000000001,
"rowVersion": 0
},
{
"id": "376",
"parentId": "35",
"codeStandard": "GB/T 2260",
"regionCode": "110101",
"levelCode": "COUNTY",
"depth": 3,
"name": "东城区",
"shortName": null,
"nodeKind": "ADMINISTRATIVE",
"currentlyEffective": true
"navigable": true,
"selectable": true,
"hasChildren": false,
"currentlyEffective": true,
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 20,
"rowVersion": 0
}
]
}
},
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
目标为省级或地级时,尚未到达的快捷层级字段返回 `null`。历史停用节点可返回,但节点自身的 `currentlyEffective=false`,调用方不能把它重新放入当前可选列表。父链存在孤儿、环、越级或超过当前安全深度时,接口失败且不返回部分路径。
#### 空数据 / 降级响应
## 3. 创建节点
```http
POST /admin/region
Authorization: Bearer <具有 system:region:create 的管理员令牌>
Content-Type: application/json
```
创建一个县级业务行政区示例:
本接口不返回“成功但空父链”。目标不存在、父链不完整或超过当前三级安全边界时使用错误响应,不返回部分路径。Redis 不可用时整条父链降级为同一数据库事务快照读取,不拼接缓存与数据库的半条路径。
```json
{
"code": 210801,
"message": "行政区划节点不存在",
"data": null,
"success": false
}
```
#### 错误响应
父链存在孤儿、越级、循环或异常终止:
```json
{
"code": 210811,
"message": "行政区划父链数据不完整",
"data": null,
"success": false
}
```
#### 业务边界
- `path` 必须从省级根开始,`parentId`、`depth` 和 `levelCode` 逐级连续,最后一个节点必须等于 `selectedId`。
- 目标为省级时 `prefectureId`、`countyId` 均为 `null`;目标为地级时只有 `countyId=null`,绝不会返回字符串 `"null"`。
- `prefectureId` 只是路径第二层快捷 ID;直辖市等链路可指向 `nodeKind=AGGREGATION && selectable=false` 的聚合节点。
- 历史停用或过期节点允许回显,节点自身 `currentlyEffective=false`,且后端会把该节点的 `navigable`、`selectable` 强制返回 `false`;前端只能展示历史值,不能把它重新加入当前可选列表。
- 父链异常时整体失败,不返回可被误用的部分路径。
- 未登录返回业务码 `401`;不存在节点返回 `210801`。
---
### 3. 创建行政区划节点 `POST /admin/region`
**VO**: `AdministrativeRegionCreateReqVO / String`
#### 使用场景
具有 `system:region:create` 权限的管理员可以创建自定义业务行政区、带完整来源证据且不与旧版本重叠的 `GB/T 2260` 新版本,或只用于层级导航、不可作为最终业务值的聚合节点。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `Authorization` | Header | String | ✅ | `Bearer <token>` 且具有 `system:region:create` | 管理端登录令牌 |
| `codeStandard` | Body | String | ✅ | 1~32 字符;服务端去空白并转大写;聚合节点必须为 `HL_GROUP` | 可扩展编码命名空间,自定义行政区推荐 `HL_CUSTOM` |
| `regionCode` | Body | String | ✅ | 1~64 字符;首字符为字母或数字;仅含字母、数字、`.`、`_`、`:`、`-` | 编码标准内的唯一地区代码;服务端转大写 |
| `parentId` | Body | String / null | ❌ | 十进制正整数;创建省级根节点时为 `null` | 父节点 ID |
| `levelCode` | Body | String | ✅ | 1~32 字符;省/市/县必须匹配派生深度 | `PROVINCE`、`PREFECTURE`、`COUNTY` |
| `nodeKind` | Body | String | ✅ | `ADMINISTRATIVE` 或 `AGGREGATION` | 节点业务类型 |
| `name` | Body | String | ✅ | 非空,最多 128 字符 | 完整名称 |
| `shortName` | Body | String / null | ❌ | 最多 64 字符;空白归一为 `null` | 简称 |
| `navigable` | Body | Boolean | ✅ | 聚合节点固定为 `true` | 是否允许继续向下导航 |
| `selectable` | Body | Boolean | ✅ | 聚合节点固定为 `false` | 是否允许作为最终业务值 |
| `validFrom` | Body | String | ✅ | `yyyy-MM-dd` | 生效日期 |
| `validTo` | Body | String / null | ❌ | `yyyy-MM-dd`,必须严格晚于 `validFrom` | 失效日期;`null` 表示持续有效 |
| `status` | Body | String | ✅ | `ACTIVE` 或 `INACTIVE` | 节点状态 |
| `sortOrder` | Body | Integer | ✅ | 0~2,000,000,000 | 同级排序号 |
| `sourceVersion` | Body | String | ✅ | 非空,最多 64 字符 | 可审计的数据来源版本 |
| `sourceRef` | Body | String | ✅ | 非空,最多 512 字符 | 来源 URL、文件定位或管理批次标识 |
| `extension` | Body | Object / null | ❌ | 必须是 JSON object,序列化后最多 4000 字符 | 结构化扩展;旧入参别名 `extJson` 仍兼容 |
`depth`、操作人和来源校验值由服务端派生,禁止由前端提交。
#### 出参 `Result<String>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | `200` 表示创建成功 |
| `message` | String | 响应说明 |
| `success` | Boolean | 创建成功为 `true` |
| `traceId` | String / null | 链路追踪 ID;报错排查时提供给后端 |
| `data` | String | 新节点 ID,始终为 JSON string |
#### 请求示例
```http
POST /admin/region HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <具有 system:region:create 的管理员令牌>
Content-Type: application/json
{
"codeStandard": "HL_CUSTOM",
"regionCode": "DEMO-COUNTY-001",
@@ -174,28 +460,94 @@ Content-Type: application/json
}
```
成功响应的 `data` 是字符串 ID:
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": "2090000000000000001"
"data": "2090000000000001001",
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
`extension` 只能是 JSON 对象;历史入参名 `extJson` 仅作为请求别名兼容,响应统一使用 `extension`。服务端会记录来源版本、来源定位、操作人和来源校验值,并在事务提交后刷新行政区划独立缓存。
#### 空数据 / 降级响应
## 4. 更新节点
本接口成功时一定返回字符串 ID,不存在 `data=null` 的成功语义。失败时事务回滚且不创建节点;本接口没有“Redis 不可用仍受理写入”的公开降级承诺。
```http
PUT /admin/region/2090000000000000001
Authorization: Bearer <具有 system:region:update 的管理员令牌>
Content-Type: application/json
```
#### 错误响应
层级代码与父链派生深度不一致:
```json
{
"code": 210813,
"message": "行政区划层级代码与父链深度不一致",
"data": null,
"success": false
}
```
#### 业务边界
- 当前最大写入深度为 3;省、市、县层级必须分别使用 `PROVINCE`、`PREFECTURE`、`COUNTY`。
- 父节点必须存在、可导航、有效期完整覆盖子节点,并且不会让写入深度超过三级。
- `AGGREGATION` 必须同时满足 `codeStandard=HL_GROUP`、`navigable=true`、`selectable=false`;它只能导航,不能作为最终业务值。
- `ADMINISTRATIVE` 不得占用 `HL_GROUP`;除 `HL_GROUP`、`HL_INTERNAL` 的限制外,`codeStandard` 不是封闭枚举,自定义命名空间可扩展,推荐使用 `HL_CUSTOM`。
- 旧命名空间 `HL_INTERNAL` 不允许产生新写入;`GB/T 2260` 只允许六位数字 `regionCode`,并要求完整的 `sourceVersion`、`sourceRef`。
- 同一 `codeStandard + regionCode` 的版本有效期不得重叠;有效期采用左闭右开 `[validFrom, validTo)`。
- 缺少权限返回 `210802`;空请求或格式错误返回 `400`;全部失败路径零业务写入。
---
### 4. 更新行政区划节点 `PUT /admin/region/{id}`
**VO**: `AdministrativeRegionUpdateReqVO / Void`
#### 使用场景
具有 `system:region:update` 权限的管理员更新节点名称、父级、层级、可导航/可选状态、有效期、排序和扩展信息。请求必须携带完整可变字段快照和最近读取到的 `rowVersion`;这不是局部 PATCH。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `Authorization` | Header | String | ✅ | `Bearer <token>` 且具有 `system:region:update` | 管理端登录令牌 |
| `id` | Path | String | ✅ | 十进制正整数 | 要更新的节点 ID |
| `parentId` | Body | String / null | ❌ | 十进制正整数;省级根节点为 `null` | 更新后的父节点 ID |
| `levelCode` | Body | String | ✅ | 1~32 字符;必须匹配更新后父链深度 | 省/市/县层级代码 |
| `name` | Body | String | ✅ | 非空,最多 128 字符 | 完整名称 |
| `shortName` | Body | String / null | ❌ | 最多 64 字符;空白归一为 `null` | 简称 |
| `navigable` | Body | Boolean | ✅ | 聚合节点必须保持 `true` | 是否允许继续向下导航 |
| `selectable` | Body | Boolean | ✅ | 聚合节点必须保持 `false` | 是否允许作为最终业务值 |
| `validFrom` | Body | String | ✅ | `yyyy-MM-dd` | 生效日期 |
| `validTo` | Body | String / null | ❌ | 必须严格晚于 `validFrom` | 失效日期 |
| `status` | Body | String | ✅ | `ACTIVE` 或 `INACTIVE` | 节点状态 |
| `sortOrder` | Body | Integer | ✅ | 0~2,000,000,000;部分初始化聚合节点的只读返回值大于此上限 | 同级排序号;不能无脑回填超限旧值 |
| `extension` | Body | Object / null | ❌ | 必须是 JSON object,最多 4000 字符;省略与显式 `null` 都会清空 | 结构化扩展;旧别名 `extJson` 仍兼容 |
| `rowVersion` | Body | Integer | ✅ | 大于等于 0,必须等于当前版本 | CAS 并发版本,成功后自动加 1 |
`codeStandard`、`regionCode`、`nodeKind`、`sourceVersion` 和 `sourceRef` 不属于更新接口,不能通过请求改写。
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | `200` 表示更新成功 |
| `message` | String | 成功时为“行政区划更新成功” |
| `success` | Boolean | 更新成功为 `true` |
| `traceId` | String / null | 链路追踪 ID;报错排查时提供给后端 |
| `data` | null | 更新接口不返回业务数据 |
#### 请求示例
```http
PUT /admin/region/2090000000000001001 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <具有 system:region:update 的管理员令牌>
Content-Type: application/json
{
"parentId": "35",
"levelCode": "COUNTY",
@@ -214,77 +566,312 @@ Content-Type: application/json
}
```
#### 响应示例
```json
{
"code": 200,
"message": "行政区划更新成功",
"success": true,
"data": null
"data": null,
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
更新采用完整可变字段加 `rowVersion` 的 CAS 语义。成功后版本号递增;并发版本过期返回 `210807`,不会覆盖另一位管理员的提交。编码标准、地区编码、节点类型和来源证据不可通过更新接口改写。
#### 空数据 / 降级响应
已到生效日的 `GB/T 2260` 法定节点不能原位修改历史属性;只能保持其他字段不变,将旧版本关闭,再创建不重叠的新版本。有子节点的分支不能直接移动或改变层级,父节点的状态、导航能力和有效期必须持续覆盖当前或未来仍会生效的子版本。
本接口成功时 `data=null` 是固定契约,调用方以 `code=200 && success=true` 判断成功。本接口没有“Redis 不可用仍受理写入”的公开降级承诺,也不会返回更新后的节点快照。
## 字段说明
#### 错误响应
| 字段 | JSON 类型 | 说明 |
提交了已经过期的 `rowVersion`:
```json
{
"code": 210807,
"message": "行政区划已被其他操作更新,请刷新后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 更新采用完整可变字段快照,不是局部 PATCH;省略 `extension` 不表示保持原值,而是写成 `null`。
- 当前 `children` / `path` 响应不包含 `extension`,也没有单节点详情接口。前端无法仅靠这四个公开接口保留未知的非空扩展值;在补充详情契约前,不应开放此类节点的通用编辑入口。
- TEST 初始化聚合节点可返回大于 `2,000,000,000` 的 `sortOrder`(例如北京第二层为 `2,000,000,001`),而更新请求上限是 `2,000,000,000`;直接回填会返回 `400`,这类节点也不能按无损通用编辑处理。
- `rowVersion` 冲突返回 `210807` 且零写入;前端应重新加载,而不是自动覆盖重试。
- 有子节点的分支禁止移动父级或改变层级,返回 `210810`。
- 父节点提前停用、取消导航或缩短有效期,导致现有子节点失去合法父级时返回 `210814`。
- 已到生效日的 `GB/T 2260` 法定节点不能原位改写历史属性;代码只允许在其他历史字段(包含 `extension`)完全不变时关闭状态/结束时间。由于当前读接口不返回 `extension`,前端不能把“关闭已有法定版本”当成已具备的安全通用操作。
- 成功后数据库中的 `rowVersion` 增加 1,但响应不返回新版本;再次编辑前需重新读取可见节点,且要考虑提交后缓存异步刷新的短暂窗口。
---
## 四、契约约束与正确调用方式
### 三级联动新建流程
```text
GET /admin/region/children
→ 用户选择 provinceId
GET /admin/region/children?parentId={provinceId}
→ 用户选择第二层导航项 prefectureId(可能是不可提交的 AGGREGATION)
GET /admin/region/children?parentId={prefectureId}
→ 用户选择 countyId
→ 业务表单最终保存 countyId(或业务允许的上级 selectable 节点)
```
### 编辑历史值回显流程
```text
GET /admin/region/{savedRegionId}/path
→ 读取 provinceId / prefectureId / countyId
→ 按 path 顺序逐层调用 children
→ 每层把对应历史 ID 作为 selectedId
→ 历史节点 currentlyEffective=false 时只展示历史值,不重新作为当前选项提交
```
### ✅ 正确 / ❌ 错误调用对照
| 场景 | 调用或 payload |
|---|---|
| ✅ 加载省级 | `GET /admin/region/children`,省略 `parentId` |
| ✅ 加载市级 | `GET /admin/region/children?parentId={provinceId}` |
| ✅ 编辑回显 | 先 `GET /admin/region/{savedId}/path`,再逐层加载 `children` |
| ✅ 比较 ID | 全程以 string 比较,例如 `item.id === selectedId` |
| ✅ 判断最终值 | 只提交 `selectable=true` 的节点;`defaultId`、`prefectureId` 本身不等于可提交 |
| ❌ 根层传 0 | `GET /admin/region/children?parentId=0` → 400 |
| ❌ 截行政区编码推父级 | 根据 `110101` 截取 `110100`;父子关系只能以接口返回的 ID 为准 |
| ❌ 把聚合节点当最终值 | `nodeKind=AGGREGATION && selectable=false` 时仍提交为业务行政区 |
| ❌ 强转 number | `Number("2090000000000000376")` 会丢失精度 |
### 统一响应判断
```javascript
if (response.code !== 200 || response.success !== true) {
throw new Error(response.message || '行政区划接口调用失败');
}
```
禁止只判断 HTTP status;业务错误可能使用 HTTP 200 返回。
---
## 五、数据库行为
| 前端操作 | 外部可观察的持久化行为 | 失败行为 |
|---|---|---|
| `id`、`parentId` | string / null | 行政区划节点与父节点 ID;响应始终为字符串 |
| `codeStandard` | string | `GB/T 2260`、`HL_CUSTOM` 或聚合节点专用 `HL_GROUP` |
| `regionCode` | string | 同一编码标准内的业务标识;`GB/T 2260` 必须为六位数字 |
| `levelCode` | string | 当前三级固定为 `PROVINCE`、`PREFECTURE`、`COUNTY` |
| `nodeKind` | string | `ADMINISTRATIVE` 或 `AGGREGATION` |
| `navigable` | boolean | 是否可继续逐级导航 |
| `selectable` | boolean | 是否可作为最终业务值 |
| `hasChildren` | boolean | 是否存在当前有效、启用的直接子节点 |
| `currentlyEffective` | boolean | 节点在中国业务当日是否处于有效时间窗且为 `ACTIVE` |
| `validFrom`、`validTo` | string / null | `yyyy-MM-dd`;结束日为开区间 |
| `status` | string | `ACTIVE` 或 `INACTIVE` |
| `rowVersion` | integer | 更新 CAS 版本;创建后从 0 开始 |
| 查询 `children` | 只读,不改变节点、版本或审计 | 不写入 |
| 查询 `path` | 只读,不改变节点、版本或审计 | 不写入,不返回部分父链 |
| 创建节点 | 成功新增一个节点,初始 `rowVersion=0`;数据库事务提交后异步刷新节点和父级列表缓存 | 参数、权限、父链、有效期或唯一性失败时不新增节点 |
| 更新节点 | 成功更新一个节点并令 `rowVersion+1`;数据库事务提交后异步刷新节点、旧/新父级及上层列表缓存 | 版本冲突或业务 guard 失败时原节点保持不变 |
## 业务错误码
写接口成功只证明数据库事务已经提交,不承诺紧随其后的第一个缓存读取已经反映新值。前端不得自行操作缓存;需要立即回显时应短暂重读,并始终以新的 `rowVersion` 为准。读取阶段 Redis 故障会降级数据库;写入口还受公共幂等保护,不能据此推导“Redis 故障时写入一定成功”。
| code | message | 典型场景 |
---
## 六、边界行为
- 未登录:业务码 `401`。
- 已登录但缺少创建/更新专用权限:`210802`。
- Query/Path ID 为 0、负数或非十进制正整数:`400`。
- 父节点或目标节点不存在:`210801`。
- 父级不可导航、有效期不能覆盖子级:`210803`。
- 写入超过当前三级边界:`210804`。
- 自引用或父链成环:`210805`。
- 当前编码或版本冲突:`210806` / `210815`。
- `rowVersion` 过期:`210807`,零写入。
- `validTo` 不晚于 `validFrom`:`210808`。
- 父链异常:`210811`,不返回部分路径。
- `extension` 不是 JSON object 或过长:`210812`。
- 层级代码与父链深度不一致:`210813`。
- 写接口命中五秒参数幂等键:`100502`;这是拒绝重复提交,不会回放第一次成功响应。
- 老数据兼容:停用历史节点可由 `path` 回显;旧缓存中的数字 ID 可由后端读取,但当前 API 响应始终输出 string ID。
### 业务错误码
| code | message | 典型接口与场景 |
|---:|---|---|
| `210801` | 行政区划节点不存在 | 查询不存在的父级或父链目标 |
| `210802` | 无行政区划维护权限 | 创建或更新缺少细粒度权限 |
| `210803` | 行政区划父节点不合法 | 父级不可导航或有效期不能覆盖子级 |
| `210804` | 行政区划层级超过当前允许深度 | 写入超过当前三级边界 |
| `210805` | 行政区划父链存在循环 | 自引用或移动后形成祖先环 |
| `210806` | 行政区划当前编码已存在 | 当前唯一键冲突 |
| `210807` | 行政区划已被其他操作更新,请刷新后重试 | `rowVersion` 过期 |
| `400` | 参数校验失败文案 | 任一接口的 ID 非正整数、Body 缺少必填字段或格式不合法 |
| `401` | 未认证文案 | 任一接口未携带有效管理端身份 |
| `100502` | 请勿重复提交 | `create` / `update` 在五秒内命中相同参数幂等键;失败不会返回第一次结果 |
| `210801` | 行政区划节点不存在 | `children` 父节点或 `path`/`update` 目标不存在 |
| `210802` | 无行政区划维护权限 | `create` / `update` 缺少专用写权限 |
| `210803` | 行政区划父节点不合法 | 父级不可导航、已失效或有效期不能覆盖子级 |
| `210804` | 行政区划层级超过当前允许深度 | `create` / `update` 试图写入第四级 |
| `210805` | 行政区划父链存在循环 | `update` 自引用或移动后形成祖先环 |
| `210806` | 行政区划当前编码已存在 | `create` 唯一编码冲突 |
| `210807` | 行政区划已被其他操作更新,请刷新后重试 | `update.rowVersion` 已过期 |
| `210808` | 行政区划有效期不合法 | `validTo` 不晚于 `validFrom` |
| `210809` | 该行政区划编码标准为系统保留值 | 新写入继续使用旧 `HL_INTERNAL` |
| `210810` | 存在子节点的行政区划不能移动或变更层级 | 分支移动或改层级 |
| `210811` | 行政区划父链数据不完整 | 孤儿、越级、环或异常终止 |
| `210812` | 行政区划扩展字段必须是合法 JSON | `extension` 不是 JSON 对象 |
| `210813` | 行政区划层级代码与父链深度不一致 | 省市县代码与派生深度不匹配 |
| `210814` | 行政区划更新与现有子节点状态或有效期冲突 | 父级提前停用、取消导航或缩短窗口 |
| `210815` | 行政区划编码的版本有效期与既有数据重叠 | 同编码版本时间窗重叠 |
| `210816` | 行政区划节点类型或编码命名空间不合法 | 聚合节点类型、命名空间或可选性组合错误 |
| `210817` | 已生效法定行政区划只能关闭旧版本后新增 | 原位改写生效中的法定节点 |
| `210818` | 行政区划数据来源信息不完整 | 缺少来源版本或定位 |
| `210819` | 法定行政区划代码必须为六位数字 | `GB/T 2260` 新版本编码格式错误 |
| `210810` | 存在子节点的行政区划不能移动或变更层级 | `update` 移动分支或改变分支层级 |
| `210811` | 行政区划父链数据不完整 | `path` 遇到孤儿、越级、循环或异常终止 |
| `210812` | 行政区划扩展字段必须是合法 JSON | `extension` 不是 JSON object 或内容过长 |
| `210813` | 行政区划层级代码与父链深度不一致 | 省/市/县代码与派生深度不匹配 |
| `210814` | 行政区划更新与现有子节点状态或有效期冲突 | 父级提前停用、取消导航或缩短有效期 |
| `210815` | 行政区划编码的版本有效期与既有数据重叠 | 同编码的两个版本时间窗重叠 |
| `210816` | 行政区划节点类型或编码命名空间不合法 | `nodeKind`、`codeStandard`、可导航/可选组合错误 |
| `210817` | 已生效法定行政区划只能关闭旧版本后新增 | 原位改写生效中的 `GB/T 2260` 节点 |
| `210818` | 行政区划数据来源信息不完整 | `sourceVersion` 或 `sourceRef` 缺失/过长 |
| `210819` | 法定行政区划代码必须为六位数字 | `GB/T 2260` 的 `regionCode` 格式错误 |
参数格式错误继续使用统一参数错误响应,例如未登录返回业务码 `401`,`parentId=0` 或空创建请求返回业务码 `400`。
---
## 兼容与接入建议
## 六.5、枚举 / 数据字典
- 三级选择器首次加载调用不带 `parentId` 的 `children`;选择省、市后分别以当前 ID 继续查询下一层。
- 编辑历史数据时先调用 `/{id}/path` 得到完整选中链,再逐层加载 `children`;不要根据行政区编码截位推断父子关系。
- `AGGREGATION` 节点可能可导航但不可选,页面应分别使用 `navigable` 和 `selectable`,不要只根据 `hasChildren` 判断。
- 客户端必须把响应 ID 当字符串保存和比较;请求路径与查询参数可以继续发送十进制字符串。
- 旧行政区划缓存中的数字 ID 与新缓存中的字符串 ID 均可被后端读取,滚动部署期间无需调用方切换缓存版本。
### `levelCode`(行政区划层级)
## 验证证据
**所属字段**: `AdministrativeRegionCreateReqVO.levelCode`、`AdministrativeRegionUpdateReqVO.levelCode`、`AdministrativeRegionItemVO.levelCode` | **类型**: `String`
- PR #6359、#6387、#6393、#6398、#6402、#6412 均已合并 `dev-v3`,六个合并提交都包含在 TEST 精确提交 `b62054035a139e3c689179ef75e2d052d08dde52` 中。
- Deploy Panel API 任务 `52771074` 部署 `hl-user-service`,任务 `b1db24b2` 部署 `hl-gateway`;两项脚本均为 `/opt/hulalv/scripts/deploy-backend.sh`,终态 `success`、退出码 0,双实例、Nacos 健康注册、滚动采样与部署窗口日志检查通过。
- 真实 TEST 管理员经 Gateway 验证未认证返回 401、根级/子级联动、父链回显、失败零写入、创建字符串 ID、数据库落行、Redis 回填、CAS 更新和更新后缓存一致性。
- 验收创建的唯一测试行已精确删除;数据库总量与测试命名空间恢复基线,四个 Redis 快照按原值和绝对过期时间恢复,缓存锁释放,登录会话注销。操作审计按系统设计保留。
| 值 | 中文 | 说明 |
|---|---|---|
| `PROVINCE` | 省级 | 深度 1,父节点必须为 `null` |
| `PREFECTURE` | 地级 | 深度 2,父节点必须是省级 |
| `COUNTY` | 县级 | 深度 3,父节点必须是地级 |
### `nodeKind`(节点类型)
**所属字段**: `AdministrativeRegionCreateReqVO.nodeKind`、`AdministrativeRegionItemVO.nodeKind` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `ADMINISTRATIVE` | 行政区节点 | 可按 `selectable` 决定是否作为最终业务值;不得使用 `HL_GROUP` |
| `AGGREGATION` | 导航聚合节点 | 必须使用 `HL_GROUP`、`navigable=true`、`selectable=false` |
### `status`(节点状态)
**所属字段**: 创建/更新请求及节点响应的 `status` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `ACTIVE` | 启用 | 位于有效期时可进入当前联动列表 |
| `INACTIVE` | 停用 | 不进入当前联动列表,但历史父链仍可回显 |
### `codeStandard`(可扩展编码命名空间)
**所属字段**: `AdministrativeRegionCreateReqVO.codeStandard`、`AdministrativeRegionItemVO.codeStandard` | **类型**: `String`
`codeStandard` 不是封闭枚举。服务端会去除首尾空白并转成大写;下表仅列出内置/保留语义:
| 值或类别 | 中文 | 说明 |
|---|---|---|
| `GB/T 2260` | 法定行政区编码 | `regionCode` 必须为六位数字;生效历史受不可变保护 |
| `HL_CUSTOM` | 自定义行政区编码 | 管理端创建普通自定义行政区的推荐命名空间 |
| `HL_GROUP` | 导航聚合编码 | 只能与 `AGGREGATION` 搭配 |
| `HL_INTERNAL` | 旧内部命名空间 | 仅兼容识别旧缓存,新写入固定拒绝并返回 `210809` |
| 其他非空值 | 扩展命名空间 | 最多 32 字符;可供 `ADMINISTRATIVE` 使用,但不得冒充 `HL_GROUP` |
---
## 六.6、修改前后对比
本条为新增接口,没有需要兼容的旧 `/admin/region/**` 公开接口。补充工单 #6409 在正式前端接入前固定了 ID 输出类型:
### 字段级对比
| 字段 | 初始实现风险 | 当前已部署契约 |
|---|---|---|
| 所有行政区划 Long ID | 小数值可能被全局序列化规则输出为 JSON number | 一律输出 JSON string |
| 缺失父级/层级 | 可能被调用方误当字符串处理 | 保持真正的 JSON `null` |
| `extension` 历史入参 | 旧调用方可能使用 `extJson` | 请求继续兼容 `extJson`,当前文档统一使用 `extension` |
| TEST 北京第二层示例 | 曾被文档误写为可选择的 `GB/T 2260` 地级节点 | 实际为 `id="35"`、`HL_GROUP`、`AGGREGATION`、`selectable=false` 的导航分组 |
### 行为级对比
| 行为 | 接入前 | 当前已部署行为 |
|---|---|---|
| 三级联动 | 无统一公开接口,容易截行政区编码推断 | 逐级调用 `children`,以后端父子关系为准 |
| 编辑回显 | 调用方自行拼装父链 | 调用 `path` 返回完整有序父链和三级快捷 ID |
| 并发更新 | 无本接口旧语义 | 必须提交 `rowVersion`,冲突返回 `210807` 且零写入 |
| 写后读取 | 文档曾表述为提交后立即可见 | 代码为事务提交后异步刷新缓存,存在短暂最终一致窗口 |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否;这是新增接口,且正式前端接入前已经固定字符串 ID 契约。
- **前端接入状态**: `frontend_status=verified`;mmg 已完成三级联动接入验证(`frontend_ref=7acd1cb0`)。该验证不扩大为通用主数据编辑能力,后者仍受 `extension` 不可回读等限制,不能按“完整 CRUD 已具备”上线。
- **前端 workaround 清理点**: 不得保留行政区编码截位、Long ID 转 number、只判断 HTTP status、把 `hasChildren` / `defaultId` / `prefectureId` 等同于 `selectable`,或把省略 `extension` 理解成保持原值的临时逻辑。
---
## 七、不影响范围
- **仅影响**: 管理后台行政区划维护、省/市/县三级选择器及历史行政区值回显。
- **当前未提供**: 单节点详情、历史版本列表、按编码查询、删除、批量导入/导出接口;四个已发布接口不能组成无损的完整 CRUD。
- **前端接入边界**: 三级选择与父链回显可直接接入;对已有非空 `extension`、超出更新上限的初始化 `sortOrder`、已生效法定节点关闭等需要无损回读/回填的场景暂不具备完整公开契约。
- **零影响**:
- 小程序端现有行政区接口;
- Product、Order、Resource 和 Fleet 现有业务接口;
- 已保存业务数据中的行政区 ID;
- 登录、权限菜单和非行政区划缓存;
- 生产环境(本 Changelog 只证明 TEST 已部署和验收)。
---
## 八、测试环境已验证
```text
GET /admin/region/children → 根级/子级列表、defaultId、string/null ID ✓
GET /admin/region/{id}/path → 三级完整父链、缺失层级 null、string ID ✓
POST /admin/region → 创建成功返回 string ID,数据库与缓存读取一致 ✓
PUT /admin/region/{id} → CAS 更新成功,rowVersion 0→1,提交后缓存刷新完成再读取一致 ✓
GET /admin/region/children(未认证) → 401 ✓
POST /admin/region(缺参/非法节点类型) → 400 且零业务写入 ✓
GET /admin/region/{missingId}/path → 210801 ✓
```
- TEST 精确提交:`b62054035a139e3c689179ef75e2d052d08dde52`,包含六个相关 PR 的合并提交。
- 本次代码优先复核确认:该提交的 `AdminRegionController` 只有本文四个公开端点;`AdministrativeRegionItemVO` 不含 `extension`;缓存刷新注册在事务 `afterCommit` 后交给独立执行器;migration 中北京链路为 `1 → 35 → 376`,其中 `35` 规范化后为不可选择的 `HL_GROUP/AGGREGATION`。
- 2026-08-26 本次复核还通过 TEST Gateway 匿名请求确认 `/admin/region/children` 当前可达且返回业务码 `401`、`success=false`;未使用伪造身份,也未产生业务写入。
- Deploy Panel API 任务:`52771074`(`hl-user-service`)和 `b1db24b2`(`hl-gateway`),终态均为 `success`,双实例、Nacos、滚动采样及部署窗口日志检查通过。
- 真实 TEST 管理员经 Gateway 完成未认证、读取、失败零写入、创建、CAS 更新、持久化结果和缓存一致性验收。
- 验收创建的临时节点已精确清理;数据库数量与测试命名空间恢复基线,四个缓存快照按原值和绝对过期时间恢复,登录会话已注销;操作审计按系统设计保留。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6359 | #6350 | 行政区划主数据、三级写入与读接口基础实现 | ✅ 有效 |
| #6387 | #6384 | 权限、来源和节点类型契约补强 | ✅ 有效 |
| #6393 | #6389 | 有效期、父链和历史版本保护补强 | ✅ 有效 |
| #6398 | #6395 | Gateway 路由与认证策略补齐 | ✅ 有效 |
| #6402 | #6399 | 缓存一致性与滚动兼容补强 | ✅ 有效 |
| #6412 | #6409 | 所有行政区划响应 ID 固定为 JSON string | ✅ 有效 |
---
## 十、相关文档
- 主 Issue: [wx/HL#6350](https://git.1814.love:8443/wx/HL/issues/6350)
- ID 字符串补充 Issue: [wx/HL#6409](https://git.1814.love:8443/wx/HL/issues/6409)
- 代码优先核对修正 Issue: [wx/HL#6438](https://git.1814.love:8443/wx/HL/issues/6438)
- 主 PR: [wx/HL#6359](https://git.1814.love:8443/wx/HL/pulls/6359)
- ID 字符串 PR: [wx/HL#6412](https://git.1814.love:8443/wx/HL/pulls/6412)
- 本次文档补充 Issue: [wx/HL#6422](https://git.1814.love:8443/wx/HL/issues/6422)
---
## 撤回
代码撤回需回退上述六个 PR 并按依赖逆序重新部署 `hl-gateway` 与 `hl-user-service`;调用方停止访问 `/admin/region/**`,并经 Gateway 验证新增路由已不可达、既有 User 接口正常。数据库迁移已经在 TEST 应用,回退代码时保留行政区划表和权限元数据,不执行降版 DDL;Redis 仅在确需重建时精确清理 `hl:user:region:v1:` 命名空间,禁止触碰登录及其他业务缓存。无 MQ 或跨服务写入需要补偿。
本次 #6438 代码优先核对只修改 Changelog,不改变后端制品。若本次文档修正需要撤回,从最新 `hl-api-changelog/main` 创建独立分支,revert 本次文档合并提交并重新运行仓库全部校验;TEST 行政区划 API、数据库、配置、Redis 和 MQ 均不需要回退。撤回后前端必须以 TEST 对应提交的 Controller、VO、Service 与 migration 为准,不能恢复使用被本次指出的错误聚合节点示例或同步缓存假设。
---
## 关联 / 联系人
### 链接
- **Issue**: [#6350](https://git.1814.love:8443/wx/HL/issues/6350)
- **补充文档 Issue**: [#6422](https://git.1814.love:8443/wx/HL/issues/6422)
- **代码核对修正 Issue**: [#6438](https://git.1814.love:8443/wx/HL/issues/6438)
- **PR**: [#6359](https://git.1814.love:8443/wx/HL/pulls/6359)
- **ID 契约 PR**: [#6412](https://git.1814.love:8443/wx/HL/pulls/6412)
- **最终后端提交**: [b62054035](https://git.1814.love:8443/wx/HL/commit/b62054035a139e3c689179ef75e2d052d08dde52)
### 联系人
- **后端负责人**: @lc
@@ -7,11 +7,11 @@ author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "49b071b0"
target_release: ""
verified_at: ""
verified_at: "2026-08-26"
status_note: "PR #6403 已合并 dev-v3;两个状态命令已部署 TEST,ACTIVE→SUSPENDED、ACTIVE/SUSPENDED→BLACKLIST、原因审计、权限、并发、幂等和失败零写入均经真实 Gateway 验证。前端待接入原因弹窗与并发版本。"
updated_at: "2026-08-26"
base: "dev-v3"
@@ -1,41 +1,53 @@
---
schema: "hl-changelog/v2"
ticket: "6397"
title: "供应商注册合同聚合信息"
title: "供应商注册合同聚合信息(已废弃)"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: "f2a4200d"
target_release: ""
verified_at: "2026-08-26"
status_note: "PR #6405 已合并 dev-v3;Deploy Panel 任务 af3f205b 成功发布 Resource 双实例,合同接口已完成 23 项真实 Gateway 验收。TEST 工作副本存在服务器本地提交导致精确提交回读仍被阻塞,后端工单保持开启;本条只交接已经实测存在的接口契约。"
updated_at: "2026-08-26"
frontend_ref: "pending"
target_release: "v2.1"
verified_at: "2026-08-29"
status_note: "本条记录的 #6405 合同随供应商草稿/注册聚合写入语义已被业务纠正并废弃,不再是当前可联调契约。当前后端以 #6544 为准:add/update/submit 对旧 contracts 输入兼容忽略,合同只在已有 supplierId 后通过三个独立接口维护,不进入建档审批;详情继续只读返回 contracts。最终 Resource 已通过任务 ca2fe4d7 部署提交 c579c87014f56452fea2fce5075b31ae6fd12a35。旧前端 002475b2 仅作为历史记录,当前消费状态恢复 pending。"
updated_at: "2026-08-29"
base: "dev-v3"
---
# 🔧 供应商注册合同聚合信息
# ⚠️ 供应商注册合同聚合信息(已废弃)
供应商创建草稿、提交注册和基础信息详情现统一支持合同完整快照。管理端应把“合同信息”放在“资质证照”之后、现有账户区域之前,并把原“初始账户”展示标题改为“结算信息”。
> **2026-08-29 纠正:本文以下聚合写入说明仅保留为历史,不得继续用于联调或实现。** 当前契约见 [#6544 供应商合同独立登记](../2026-08/29_6544_供应商合同独立登记-新增接口-管理后台.md):供应商草稿、资料更新和注册提交对旧 `contracts` 输入兼容接收但完全忽略;合同必须在取得 `supplierId` 后,通过独立 add/update/del 接口和独立事务维护,且不进入建档审批。`basic-info/view` 仍只读返回未软删合同,`initialAccounts` 字段保持不变。
原前端提交 `002475b2` 消费的是已撤销的聚合写语义,只作为历史事实保留,当前状态为待按独立接口重新接入。
展示名调整不改变接口字段:原请求字段仍为 `initialAccounts`,不得改成 `settlementInfo` 或其他名称。
## 变更接口清单
## 二、变更接口清单(历史,禁止用于当前联调)
| # | 接口 | 方法 | 路径 | 变化 |
|---:|---|---|---|---|
| 1 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 请求可选增加 `contracts` 完整集合 |
| 2 | 提交供应商注册 | POST | `/admin/supplier/items/{supplierId}/submit` | 请求可选增加 `contracts` 完整快照 |
| 3 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应增加 `contracts` 列表 |
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 请求体新增可选字段 | 可选增加 `contracts` 完整集合 |
| 2 | 提交供应商注册 | POST | `/admin/supplier/items/{supplierId}/submit` | 请求体新增可选字段 | 可选增加 `contracts` 完整快照 |
| 3 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应新增字段 | 响应增加 `contracts` 列表 |
统一响应均为 `Result<T>`。业务失败可能仍为 HTTP 200,调用方必须同时判断 `code`、`success`、`message` 和 `data`。
## 公共合同字段
## ⚠️ 关键变化(2026-08-26 晚,PR #6450 同日修正)
### 请求字段 `contracts[]`
- **响应 `contracts[].amount` 由 Number 改为 String**:此前(f2a4200d 验收时)响应示例为 `"amount": 1200.50`,现实际输出 `"amount": "1200.50"`,与全站金额字段(`contractId` 之外的金额一律字符串)对齐,防 JavaScript 浮点精度丢失。
- **请求侧不变**:`contracts[].amount` 请求仍按 Number 传(字符串同值也可被兼容解析),无需改表单提交。
- 前端处理:详情/列表展示处把 amount 当字符串渲染即可,参与运算前 `Number(...)` 转换。
## 三、接口详情
三个接口的合同字段完全一致,先在「公共合同字段」统一约定;各接口再分节给出自包含的使用场景、入参、出参、示例与错误。
### 公共合同字段
#### 请求字段 `contracts[]`
| 字段 | 类型 | 创建必填 | 提交既有项必填 | 约束与说明 |
|---|---|---:|---:|---|
@@ -53,7 +65,7 @@ base: "dev-v3"
| `scanFileUrl` | String | 否 | 否 | 合同扫描件永久地址,最长 1000 字符 |
| `remark` | String | 否 | 否 | 最长 500 字符 |
### 响应字段 `contracts[]`
#### 响应字段 `contracts[]`
详情返回上述全部业务字段,并额外返回:
@@ -62,20 +74,40 @@ base: "dev-v3"
| `contractId` | String | 合同 ID,始终按字符串处理,不得转 JavaScript Number |
| `updateTime` | String | 合同当前版本,格式 `yyyy-MM-dd HH:mm:ss` |
响应中的 `amount` 为 String(如 `"1200.50"`,PR #6450 起),请求中的 `amount` 仍按 Number 传。
历史数据可能返回只读状态 `TERMINATED`;创建和提交请求不得发送该状态。
## 1. 创建供应商注册草稿
### 1. 创建供应商注册草稿 `POST /admin/supplier/items/add`
`POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO`(请求;响应 data 字段见下表)
### 使用场景与边界
#### 使用场景
- `contracts` 可省略、为 `null` 或空数组,旧客户端行为不变。
- 非空时最多 100 项,合同与供应商主体、资质和 `initialAccounts` 一起成功或一起失败。
- 创建请求中的每个合同都是新合同,禁止携带 `contractId`。
- 写入仍要求现有供应商创建权限;仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有对应平台权限的身份可执行。
供应商注册第一步:创建草稿。`contracts` 为本次新增的可选完整集合,随草稿与供应商主体、资质、`initialAccounts` 一起成功或一起失败;旧客户端省略该字段时行为完全不变。
### 典型请求
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `fullName` | Body | String | 是 | 非空白 | 供应商全称(本次未变更,列此定位) |
| `taxNo` | Body | String | 是 | 统一社会信用代码 | 本次未变更,列此定位 |
| `qualifications` | Body | Array | 否 | - | 资质证照集合(本次未变更) |
| `contracts` | Body | Array | 否 | 非空时最多 100 项 | 本次新增:合同完整集合,单项字段见「公共合同字段」;创建时每项禁止携带 `contractId` |
| `initialAccounts` | Body | Array | 否 | - | 结算账户集合,字段名不得改(本次未变更) |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID,字符串 |
| `supplierNo` | String/null | 供应商编号,草稿期可为 null |
| `status` | String | 固定 `DRAFT` |
| `onboardingStage` | String | 入驻阶段,草稿为 `PROFILE_DRAFT` |
| `initialAccounts` | Array | 结算账户回显 |
| `updateTime` | String | 聚合版本时间,`yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
POST /admin/supplier/items/add
@@ -123,7 +155,7 @@ Content-Type: application/json
}
```
### 成功响应
#### 响应示例
```json
{
@@ -141,7 +173,13 @@ Content-Type: application/json
}
```
### 失败响应:创建携带合同 ID
#### 空数据 / 降级响应
`contracts` 省略、为 `null` 或空数组均可正常创建,旧客户端行为不变;创建响应不返回合同明细(合同通过详情接口回显)。草稿无结算账户时 `initialAccounts` 返回空数组 `[]`,不返回 `null`。
#### 错误响应
创建请求携带合同 ID(创建时每个合同都是新项,禁止 `contractId`):
```json
{
@@ -154,20 +192,44 @@ Content-Type: application/json
失败时不会留下供应商主体或部分合同。
## 2. 提交供应商注册
#### 业务边界
`POST /admin/supplier/items/{supplierId}/submit`
- 非空时最多 100 项,合同与供应商主体、资质和 `initialAccounts` 一起成功或一起失败。
- 创建请求中的每个合同都是新合同,禁止携带 `contractId`。
- 写入仍要求现有供应商创建权限;仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有对应平台权限的身份可执行。
- `endDate` 早于 `startDate` 直接校验失败,零写入。
### 使用场景与快照语义
### 2. 提交供应商注册 `POST /admin/supplier/items/{supplierId}/submit`
- `contracts` 省略或为 `null`:本次不处理合同,保留草稿当前合同。
- `contracts: []`:明确清空当前全部合同。
- 非空数组:作为完整快照;带 `contractId` 的项覆盖当前合同,不带 ID 的项新增,当前已有但数组中遗漏的合同删除。
- 带 ID 的合同必须属于路径中的供应商;不属于当前供应商、重复 ID、非法枚举、负金额或日期逆序均失败。
- `expectedUpdateTime` 仍是供应商聚合并发版本;发生并发修改时调用方应刷新详情后重新组装完整表单。
- 合同快照会进入本次审批资料,但提交注册不会自动改写合同自身的 `status`。
**VO**: `SupplierDraftSaveReqVO`(请求,含 `expectedUpdateTime` 版本;响应 data 字段见下表)
### 典型请求
#### 使用场景
注册第二步:把草稿完整表单(含合同快照)提交审批。`contracts` 按完整快照语义处理:省略/`null` = 本次不处理合同;`[]` = 明确清空;非空数组 = 全量覆盖(带 ID 覆盖、无 ID 新增、遗漏删除)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 必须存在且未删除 | 目标供应商 |
| `fullName` | Body | String | 是 | 非空白 | 完整表单字段(本次未变更,列此定位) |
| `contracts` | Body | Array | 否 | 非空时最多 100 项 | 本次新增:合同完整快照,单项字段见「公共合同字段」;既有项必须带回字符串 `contractId` |
| `initialAccounts` | Body | Array | 否 | - | 结算账户集合(本次未变更) |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 聚合并发版本,须取详情最新值 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `approvalLogId` | String | 审批日志 ID,字符串 |
| `requestNo` | String | 审批请求号 |
| `provider` | String | 审批通道,如 `LOCAL_AUTO` |
| `approvalStatus` | String | 审批结果,如 `APPROVED` |
| `spNo` / `spStatus` | String/null | 外部审批单号/状态,本地通道为 null |
| `syncStatus` | String | 结果应用状态,如 `APPLIED` |
| `submittedAt` / `finishedAt` | String | 提交/完成时间 |
#### 请求示例
```http
POST /admin/supplier/items/2090300000000063970/submit
@@ -204,7 +266,7 @@ Content-Type: application/json
"signDate": "2026-08-26",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": 1200.50,
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
@@ -217,7 +279,7 @@ Content-Type: application/json
}
```
### 成功响应
#### 响应示例
```json
{
@@ -238,7 +300,13 @@ Content-Type: application/json
}
```
### 失败响应:合同不属于当前供应商
#### 空数据 / 降级响应
`contracts` 省略或为 `null` 时保留草稿当前合同,走既有审批流程,无降级差异;`contracts: []` 为明确清空(不是省略),会删除当前全部合同。草稿本身无合同时按无合同提交,不报错。
#### 错误响应
合同不属于当前供应商(或重复 ID、非法枚举、负金额、日期逆序等同族校验失败):
```json
{
@@ -251,20 +319,50 @@ Content-Type: application/json
该失败会回滚本次提交表单中的主体、资质、合同和审计变化,供应商仍保持原状态和原版本。
## 3. 查询供应商基本信息
#### 业务边界
`GET /admin/supplier/items/{supplierId}/basic-info/view`
- 带 ID 的合同必须属于路径中的供应商;不属于当前供应商、重复 ID、非法枚举、负金额或日期逆序均失败。
- `expectedUpdateTime` 仍是供应商聚合并发版本;发生并发修改时调用方应刷新详情后重新组装完整表单。
- 合同快照会进入本次审批资料,但提交注册不会自动改写合同自身的 `status`。
- 快照语义易错点:用户明确删除全部合同时发送 `contracts: []`;未加载合同或不处理合同时省略字段,不要误发空数组。
### 请求
### 3. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
供应商详情首屏:返回主体信息、资质、合同列表(本次新增 `contracts`)与聚合版本。管理端据此渲染「资质证照 → 合同信息 → 结算信息」区块。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 必须存在且未删除 | 目标供应商 |
无请求体、无查询参数。读取继续要求可信读角色和 `supplier:view` 平台权限。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID,字符串 |
| `fullName` / `shortName` | String | 供应商名称 |
| `tax_no` | String | 脱敏税号 |
| `types` | Array | 供应商类型,`typeCode`/`typeName` |
| `qualifications` | Array | 资质证照列表(本次未变更) |
| `contracts` | Array | 本次新增:合同列表,字段见「公共合同字段」;`amount` 为 String |
| `status` | String | 供应商状态 |
| `updateTime` | String | 聚合版本时间,提交表单时回传 `expectedUpdateTime` |
#### 请求示例
```http
GET /admin/supplier/items/2090300000000063970/basic-info/view
Authorization: Bearer <admin-token>
```
无请求体。读取继续要求可信读角色和 `supplier:view` 平台权限。
### 成功响应
#### 响应示例
```json
{
@@ -322,7 +420,7 @@ Authorization: Bearer <admin-token>
"signDate": "2026-08-26",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": 1200.50,
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "DRAFT",
@@ -337,9 +435,22 @@ Authorization: Bearer <admin-token>
}
```
合同按 `contractId` 升序返回,只包含当前有效合同。没有合同时返回空数组 `[]`,不返回 `null`。
#### 空数据 / 降级响应
### 常见失败
没有合同时返回空数组 `"contracts": []`,不返回 `null`;合同按 `contractId` 升序返回,只包含当前有效合同。
#### 错误响应
常见失败(统一 `Result` 包装,HTTP 200):
```json
{
"code": 395001,
"message": "供应商不存在",
"data": null,
"success": false
}
```
| 场景 | `code` | 前端处理 |
|---|---:|---|
@@ -347,17 +458,13 @@ Authorization: Bearer <admin-token>
| 可信角色或 `supplier:view` 平台权限不足 | `403` | 展示无权限状态 |
| 供应商不存在或已删除 | `395001` | 返回列表并刷新 |
## 修改前后对比
#### 业务边界
| 场景 | 修改前 | 修改后 |
|---|---|---|
| 创建草稿 | 请求不能携带合同 | 可选携带完整 `contracts`,与草稿一起成功或失败 |
| 提交注册 | 提交表单不能维护合同 | 可省略保留、空数组清空或提交完整合同快照 |
| 基础信息 | 不返回合同列表 | 返回完整 `contracts[]` 及字符串 ID、版本 |
| 账户区域标题 | 页面显示“初始账户” | 页面应显示“结算信息”,接口字段仍为 `initialAccounts` |
| 页面区块顺序 | 资质后直接进入账户区域 | 资质证照 → 合同信息 → 结算信息 |
- 读取要求可信读角色(`ADMIN`/`FINANCE`/`SUPER_ADMIN`)和 `supplier:view` 平台权限。
- 敏感字段(税号、证件号、手机号)一律脱敏返回,前端不得期待明文。
- `updateTime` 是后续提交表单的并发版本,必须原样缓存回传。
## 兼容性与管理端接入事项
## 四、契约约束与正确调用方式
1. 在“资质证照”区域之后新增“合同信息”表格,在合同之后显示原账户表格。
2. 原账户表格标题改为“结算信息”;所有请求和响应继续使用 `initialAccounts`,不要改字段名。
@@ -367,15 +474,65 @@ Authorization: Bearer <admin-token>
6. 本次不新增接口路径、权限点或业务错误码;旧客户端省略 `contracts` 时继续可用。
7. 管理端源码不在本后端工单中修改,前端状态保持 `pending`,直至完成页签、标题和表格接入并提供前端引用。
## TEST 验证证据
## 五、数据库行为
- 创建草稿与提交注册均为聚合级单事务写入:供应商主体、资质、合同快照、结算账户与审计记录一起成功或一起失败,任一校验失败零写入。
- 合同快照随供应商聚合版本(`expectedUpdateTime` 乐观并发控制)持久化;并发修改时提交失败,调用方须刷新详情后重试。
- 审计留痕:创建/删除等不可逆操作保留审计记录;测试环境临时数据已通过业务删除接口软删除。
- 查询供应商基本信息为只读,无写库行为。
- 本次无 DDL、无 Flyway 迁移、无 Redis/MQ 行为变化。
## 六、边界行为
- `contracts` 省略、`null` 与空数组语义不同:省略/传 `null` = 不处理合同(旧客户端兼容);`[]` = 明确清空全部合同。
- 合同非空时单请求最多 100 项;合同与供应商主体、资质、`initialAccounts` 同事务,一起成功或一起失败。
- 创建草稿禁止携带 `contractId`(每个合同都是新项);提交既有合同必须原样带回字符串 `contractId`,外部/他人合同 ID 触发整体回滚零写入。
- `endDate` 早于 `startDate` 直接校验失败,零写入。
- `amount` 边界:大于等于 0,最多 10 位整数和 2 位小数;响应按字符串输出(PR #6450 起)。
- 历史只读状态 `TERMINATED` 仅可返回,创建/提交发送该状态会被拒绝。
- 越权:仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有对应平台权限可写;普通 ADMIN 调用写接口返回越权错误。
## 六.6、修改前后对比
| 场景 | 修改前 | 修改后 |
|---|---|---|
| 创建草稿 | 请求不能携带合同 | 可选携带完整 `contracts`,与草稿一起成功或失败 |
| 提交注册 | 提交表单不能维护合同 | 可省略保留、空数组清空或提交完整合同快照 |
| 基础信息 | 不返回合同列表 | 返回完整 `contracts[]` 及字符串 ID、版本 |
| 账户区域标题 | 页面显示“初始账户” | 页面应显示“结算信息”,接口字段仍为 `initialAccounts` |
| 页面区块顺序 | 资质后直接进入账户区域 | 资质证照 → 合同信息 → 结算信息 |
| 响应 `contracts[].amount`(PR #6450) | Number(如 `1200.50`) | String(如 `"1200.50"`) |
## 六.7、影响评估
- **管理端(admin)**:供应商注册/编辑页需新增「合同信息」表格并调整区块顺序;既有合同必须缓存并原样回传字符串 `contractId`,否则提交会整单回滚。
- **同日修正(PR #6450)**:响应 `contracts[].amount` 由 Number 改为 String,前端 f2a4200d 按 Number 集成的解析处需改为字符串处理(展示直接渲染、运算前 `Number(...)`),影响面限于合同金额展示/计算处,请求提交不受影响。
- **C 端(mp)**:不涉及,无影响。
- **QA 排查面**:问题定位优先看「契约约束与正确调用方式」第 3、4 条(快照回传与空数组语义)与「关键变化」(amount 字符串化)。
## 七、不影响范围
- 不新增接口路径、权限点或业务错误码;旧客户端省略 `contracts` 时行为完全不变。
- `initialAccounts` 字段名、类型与语义不变(仅页面展示标题由「初始账户」改「结算信息」,属前端文案)。
- 请求侧 `contracts[].amount` 仍按 Number 传,PR #6450 只改响应输出,不改请求解析。
- 供应商其余模块(资源信息、审批记录、账户证明)的接口与字段不受影响。
- 数据库结构无变更(复用既有快照列),无 Redis/MQ 行为变化。
## 八、测试环境已验证
- 自动化:供应商定向测试 115 项通过;`hl-resource-service` 全量 2,111 项,0 失败、0 错误,38 项条件跳过;`hl-verify` 与差异检查通过。
- 部署:Deploy Panel API 任务 `af3f205b` 终态 `success`、退出码 0、`has_build_error=false`,未发现 Maven、编译或滚动发布错误。
- 健康:`hl-resource-service` 的 8082、8182 双实例运行;Nacos `test` 命名空间两实例均 `healthy=true`、`enabled=true`。
- 真实 Gateway:23 项断言通过,覆盖合同随草稿创建、详情完整回显、字符串 ID、创建携带 ID 失败、普通 ADMIN 越权、提交外部合同 ID 完整回滚、日期逆序零写入和旧客户端省略 `contracts`。
- 同日修正复验(PR #6450):`hl-resource-service` 于 2026-08-26 23:44 滚动发布双实例 UP,jar 构建时间戳与合并提交一致;供应商定向测试 330 项通过;空原因/超长原因的状态变更请求仍由请求校验层返回 400(对外契约不变)。
- 清理:两个临时 DRAFT 均通过业务删除接口软删除并回读为不存在;仅保留不可逆的 CREATE/DELETE 操作审计。
- 环境限制:部署前锁定 `origin/dev-v3=1a16a5aec7d0b95ec87e6fb222581060a3135984`,但面板 Git API 回读到服务器本地短提交 `6f7d3ca78`,Gitea 无法解析该对象。接口行为已真实验证,后端工单仍等待测试环境恢复精确远端提交后复验,不能据此宣称最终交付完成。
## 九、相关历史 PR
- [#6405](https://git.1814.love:8443/wx/HL/pulls/6405):本功能原始落地(合同聚合信息)。
- [#6450](https://git.1814.love:8443/wx/HL/pulls/6450):同日每日审查修正——响应 `contracts[].amount` Number→String(金额序列化红线)、状态变更原因错误码段位化(395043/395044,仅内部防御路径,对外契约不变)。
## 撤回
1. 从最新 `dev-v3` 创建回退分支,执行 `git revert -m 1 --no-edit 1a16a5aec7d0b95ec87e6fb222581060a3135984`,经独立 PR 合入。
@@ -384,6 +541,12 @@ Authorization: Bearer <admin-token>
4. 已保存的合同资料保留,不做破坏性批量清理;回退后旧客户端继续按省略 `contracts` 的路径工作。
5. 经 Gateway 复测创建、提交、基础信息、未认证、越权、非法合同 ID、失败零写入和旧客户端兼容,并确认双实例与 Nacos 健康。
## 十、相关文档
- 设计/API 说明:仓库 `docs/supplier/API-CHANGE-6397.html`
- 同日修正 PR:[#6450](https://git.1814.love:8443/wx/HL/pulls/6450)(响应 `contracts[].amount` Number→String,每日审查红线修复)
- 供应商暂停/拉黑原因必填(同属供应商状态域):`changelogs-v2/2026-08/26_6392_供应商暂停合作与拉黑原因必填接口-新增接口-管理后台.md`
## 关联 / 联系人
- **Issue**: [#6397](https://git.1814.love:8443/wx/HL/issues/6397)
@@ -0,0 +1,777 @@
---
schema: "hl-changelog/v2"
ticket: "6436"
title: "供应商敏感字段完整回显与主类型"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "5957c58c"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6478、补充 PR #6483/#6486 已合并 dev-v3,最终提交 aa735fb8 已由 Deploy Panel 任务 7a48028c 发布 TEST;真实 TEST 身份已验证完整值、权限投影、失败零写入及测试数据清理。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 🔧 供应商敏感字段完整回显与主类型
供应商管理接口不再用掩码替代已授权管理员需要处理的业务原值,并补齐唯一主类型和注册账户摘要。证明附件仍受独立权限保护,不能因为本次完整值调整而绕过授权或同步读取审计。
## 一、背景
此前详情、账户和审批记录混用了原值、掩码与历史密文回退,草稿类型也缺少稳定的主类型回显,导致管理端无法可靠编辑、审核或回填表单。本次统一以下消费口径:
- 通过既有供应商读取权限后,税号、法人证件、电话、证照号和银行账号返回完整业务值。
- `proofFileUrls` 继续要求 `supplier:account:proof:read`,且只有同步读取审计成功后才返回;无权限时字段不序列化。
- `types[].isPrimary` 与根对象 `primaryTypeCode`、`primaryTypeName` 成为主类型权威回显。
- `*Mask` 字段保留兼容但已废弃,兼容期内与对应完整值字段同值;新代码应使用无 `Mask` 的字段。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应修改 | 返回完整主体、联系人、资质字段及主类型 |
| 2 | 查询供应商账户信息 | GET | `/admin/supplier/items/{supplierId}/account-info/list` | 响应修改 | 返回可读账户完整账号,按权限决定附件字段 |
| 3 | 查询收款账户详情 | GET | `/admin/supplier/bank-accounts/{accountId}/view` | 响应修改 | 返回完整账号,按权限决定附件字段 |
| 4 | 查询供应商审批记录 | GET | `/admin/supplier/items/{supplierId}/approval-records/page` | 请求与响应修改 | 增加目标筛选及完整前后值可用性 |
| 5 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 响应修改 | `initialAccounts` 回显完整账号摘要 |
| 6 | 更新供应商 | PUT | `/admin/supplier/items/{supplierId}/update` | 响应修改 | 稳定回显主类型及完整初始账号摘要 |
| 7 | 提交供应商注册 | POST | `/admin/supplier/items/{supplierId}/submit` | 响应修改 | 审批结果增加完整初始账号摘要 |
## 三、接口详情
### 1. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
供应商详情和编辑表单初始化。管理端可直接使用完整字段,并用主类型字段初始化单选控件。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID |
| `tax_no` | String | 完整主体证件号,JSON 名保持既有口径 |
| `legalRepresentativeIdNo` | String/null | 完整法人居民身份证号 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | String/null | 完整永久文件地址 |
| `contactPhone` | String/null | 完整公司联系电话 |
| `contacts[].contactPhone` | String | 完整联系人电话 |
| `qualifications[].certNo` | String/null | 完整证照编号 |
| `types[].isPrimary` | Boolean | 当前类型是否为主类型 |
| `primaryTypeCode` / `primaryTypeName` | String/null | 主类型值和展示名称;无类型草稿为 null |
| `legalRepresentativeIdNoMask` / `contactPhoneMask` | String/null | 废弃兼容别名,与完整值字段同值 |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"fullName": "示例旅行服务有限公司",
"tax_no": "91350211M000100Y46",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdNoMask": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/front.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/back.jpg",
"contactPhone": "13800138000",
"contactPhoneMask": "13800138000",
"types": [{"typeCode": "HOTEL", "typeName": "酒店", "isPrimary": true}],
"primaryTypeCode": "HOTEL",
"primaryTypeName": "酒店",
"contacts": [{"contactName": "示例联系人", "contactPhone": "13900139000", "contactPhoneMask": "13900139000"}],
"qualifications": [{"qualType": "BUSINESS_LICENSE", "certNo": "LIC-2026-001", "certNoMask": "LIC-2026-001"}],
"updateTime": "2026-08-27 11:30:00"
}
}
```
#### 空数据 / 降级响应
无类型草稿返回 `types: []`、`primaryTypeCode: null`、`primaryTypeName: null`。联系人或资质为空时返回空数组;历史停用类型的名称无法从字典解析时,名称回退为类型值,不阻断详情。
#### 错误响应
```json
{
"code": 395001,
"message": "供应商不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 要求既有 `supplier:view` 和数据范围校验,登录态不能替代业务权限。
- 普通应用日志、异常和 Trace 不记录上述完整值。
- `*Mask` 仅用于旧客户端兼容,新代码不得继续依赖掩码语义。
### 2. 查询供应商账户信息 `GET /admin/supplier/items/{supplierId}/account-info/list`
**VO**: `SupplierAccountInfoRespVO`
#### 使用场景
在供应商结算信息区域展示已进入 PENDING、ACTIVE 或 DISABLED 的管理端可读账户。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID |
| `bankAccounts` | Array | 可读账户,默认账户优先 |
| `bankAccounts[].accountNo` | String | 完整银行账号 |
| `bankAccounts[].accountNoMask` | String | 废弃兼容别名,与 `accountNo` 同值 |
| `bankAccounts[].proofFileUrls` | Array | 有独立权限且审计成功时出现;无权限时整个字段省略 |
| `bankAccounts[].status` | String | `PENDING`、`ACTIVE` 或 `DISABLED` |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/account-info/list
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"bankAccounts": [{
"accountId": "2092800000000000011",
"accountName": "示例旅行服务有限公司",
"bankName": "示例银行",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"proofFileUrls": ["https://files.example.com/supplier/account-proof.pdf"],
"status": "ACTIVE",
"isDefault": "YES"
}],
"updateTime": "2026-08-27 11:31:00"
}
}
```
#### 空数据 / 降级响应
仅有 DRAFT 账户或没有账户时返回 `bankAccounts: []`。无 `supplier:account:proof:read` 时账号仍为完整值,但每个账户均省略 `proofFileUrls`,不是返回空数组。
#### 错误响应
证明附件同步审计不可用时失败关闭,不返回任何附件:
```json
{
"code": 395039,
"message": "暂时无法校验资源,请稍后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 要求 `supplier:view`;附件另需 `supplier:account:proof:read`。
- 每次获准的附件读取都会先同步写安全审计,失败时整个请求失败。
- DRAFT 初始账户不会出现在该读取接口中,应使用创建/更新响应的 `initialAccounts` 展示草稿摘要。
### 3. 查询收款账户详情 `GET /admin/supplier/bank-accounts/{accountId}/view`
**VO**: `SupplierBankAccountDetailRespVO`
#### 使用场景
查看单个已进入可读状态的收款账户完整信息。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `accountId` | Path | String | 是 | 正整数 ID 字符串 | 账户 ID |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `accountId` | String | 账户 ID |
| `accountName` | String | 收款户名 |
| `accountNo` | String | 完整银行账号 |
| `accountNoMask` | String | 废弃兼容别名,与完整账号同值 |
| `proofFileUrls` | Array | 有独立权限且同步审计成功时出现,否则省略 |
| `status` / `isDefault` | String | 账户状态与默认标记 |
#### 请求示例
```http
GET /admin/supplier/bank-accounts/2092800000000000011/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"accountId": "2092800000000000011",
"accountName": "示例旅行服务有限公司",
"accountType": "CORPORATE",
"bankName": "示例银行",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"proofFileUrls": ["https://files.example.com/supplier/account-proof.pdf"],
"settleMode": "PREPAY",
"invoiceType": "NONE",
"status": "ACTIVE",
"isDefault": "YES",
"updateTime": "2026-08-27 11:31:00"
}
}
```
#### 空数据 / 降级响应
无附件权限时响应省略 `proofFileUrls`;DRAFT、已删除或不存在的账户按不可读处理,不返回草稿详情。
#### 错误响应
```json
{
"code": 395001,
"message": "供应商不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 完整账号沿用基础查看权限,证明附件使用独立权限和同步审计。
- 账户必须属于未删除供应商且状态为 PENDING、ACTIVE 或 DISABLED。
- 无附件权限时后端读取阶段即排除附件字段,不是先读取后隐藏。
### 4. 查询供应商审批记录 `GET /admin/supplier/items/{supplierId}/approval-records/page`
**VO**: `SupplierApprovalRecordPageReqVO / PageResult<SupplierApprovalRecordRespVO>`
#### 使用场景
统一查看供应商主体与收款账户的变更历史,并区分完整新记录和不可恢复的历史掩码记录。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `page` / `limit` | Query | Integer | 否 | 正整数,`limit` 不超过 200 | 分页参数 |
| `approvalLogId` | Query | String | 否 | 正整数 ID 字符串 | 精确筛选审批 |
| `operationType` | Query | String | 否 | `CREATE/UPDATE/ENABLE/DISABLE/DELETE` | 操作类型 |
| `targetType` | Query | String | 否 | `SUPPLIER/ACCOUNT` | 本次增加的目标筛选 |
| `fieldName` / `status` | Query | String | 否 | 当前接口枚举 | 字段或状态筛选 |
| `from` / `to` | Query | String | 否 | 必须成对,`yyyy-MM-dd HH:mm:ss` | 时间范围 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `records[].targetType` | String | `SUPPLIER` 或 `ACCOUNT` |
| `records[].targetId` | String/null | 主体记录为 null,账户记录为账户 ID |
| `records[].oldValue` / `newValue` | String/null | 完整中文业务摘要,附件仍按独立权限投影 |
| `records[].valueAvailability` | String | `FULL` 或 `LEGACY_MASKED_UNRECOVERABLE` |
| `records[].oldValueMasked` / `newValueMasked` | String/null | 废弃兼容别名 |
| `total` / `page` / `pageSize` | Integer | 分页元数据 |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/approval-records/page?page=1&limit=20&targetType=ACCOUNT
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [{
"supplierId": "2092800000000000001",
"operationType": "ENABLE",
"targetType": "ACCOUNT",
"targetId": "2092800000000000011",
"oldValue": "账户状态:待审批;收款账号:6222021234567890",
"newValue": "账户状态:已生效;收款账号:6222021234567890",
"valueAvailability": "FULL",
"status": "已生效",
"operatorName": "测试管理员",
"operatorRole": "超级管理员",
"createTime": "2026-08-27 11:32:00"
}],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 降级响应
无匹配记录返回 `records: []` 和 `total: 0`。历史记录只保存掩码且无法恢复原值时,`valueAvailability` 返回 `LEGACY_MASKED_UNRECOVERABLE`,不会猜测或拼造完整值。
#### 错误响应
```json
{
"code": 400,
"message": "目标类型仅支持SUPPLIER或ACCOUNT",
"data": null,
"success": false
}
```
#### 业务边界
- 要求 `supplier:approval:read` 及供应商数据范围权限。
- 证明附件只有独立权限存在且同步审计成功时才进入完整摘要。
- 分页内主体记录和账户记录按创建时间、记录 ID 稳定排序。
### 5. 创建供应商注册草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO / SupplierWriteRespVO`
#### 使用场景
创建供应商草稿并可同时保存一个初始收款账户;成功响应直接回显该账户的完整账号摘要。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `fullName` | Body | String | 是 | 非空白,最长 500 字符 | 供应商全称 |
| `taxNo` | Body | String | 是 | 6 至 64 字符 | 主体证件号 |
| `types` | Body | Array | 否 | 草稿可为空,非空不得重复 | 供应商类型,首项成为主类型 |
| `mainCooperation` | Body | String | 是 | 非空白 | 主要合作内容 |
| `initialAccounts` | Body | Array | 否 | 最多 1 项 | 初始收款账户完整输入 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 新供应商 ID |
| `status` | String | `DRAFT` |
| `initialAccounts[].accountId` | String | 初始账户 ID |
| `initialAccounts[].accountNo` | String | 完整银行账号 |
| `initialAccounts[].accountNoMask` | String | 废弃兼容别名,与完整账号同值 |
| `initialAccounts[].status` | String | 创建时为 `DRAFT` |
| `updateTime` | String | 并发版本时间 |
#### 请求示例
```json
{
"fullName": "示例旅行服务有限公司",
"taxNo": "91350211M000100Y46",
"types": [{"typeCode": "HOTEL"}],
"mainCooperation": "酒店资源合作",
"initialAccounts": [{
"accountType": "CORPORATE",
"bankName": "示例银行",
"accountNo": "6222021234567890",
"proofFileUrls": ["https://files.example.com/supplier/account-proof.pdf"],
"settleMode": "PREPAY",
"invoiceType": "NONE"
}]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"supplierNo": null,
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [{
"accountId": "2092800000000000011",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"status": "DRAFT"
}],
"updateTime": "2026-08-27 11:30:00"
}
}
```
#### 空数据 / 降级响应
省略 `initialAccounts` 或传空数组时成功创建并返回 `initialAccounts: []`。草稿可传 `types: []`,此时详情的主类型字段为 null。
#### 错误响应
```json
{
"code": 395002,
"message": "无权执行该供应商写操作",
"data": null,
"success": false
}
```
#### 业务边界
- 仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有 `supplier:create` 的身份可写;`ADMIN` 明确拒绝。
- 初始账户最多一项,完整请求原子成功或失败,失败不留下主体或子项。
- 新写入和新审计使用完整业务值,但普通日志、异常、Trace 和跨服务消息不得携带这些值。
### 6. 更新供应商 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO / SupplierWriteRespVO`
#### 使用场景
增量更新供应商草稿或可变字段,并获得稳定的主类型与初始账户摘要回显。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `types` | Body | Array | 否 | 省略表示不改;空数组表示清空草稿类型 | 类型完整快照 |
| `changeReason` | Body | String | 是 | 非空白 | 变更原因 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 乐观并发版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` / `status` | String | 供应商 ID 和当前状态 |
| `initialAccounts[].accountNo` | String | 既有初始账户完整账号 |
| `initialAccounts[].accountNoMask` | String | 废弃兼容别名 |
| `updateTime` | String | 更新后的并发版本 |
#### 请求示例
```json
{
"types": [{"typeCode": "HOTEL", "isPrimary": true}],
"changeReason": "调整主合作类型",
"expectedUpdateTime": "2026-08-27 11:30:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [{
"accountId": "2092800000000000011",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"status": "DRAFT"
}],
"updateTime": "2026-08-27 11:35:00"
}
}
```
#### 空数据 / 降级响应
省略 `types` 保持现有类型;草稿传 `types: []` 会清空类型并在详情返回 null 主类型。响应没有初始账户时固定返回空数组。
#### 错误响应
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 仅可信 `FINANCE`、`SUPER_ADMIN` 且拥有 `supplier:update` 的身份可写。
- 非空类型集合稳定保留且恰有一个主类型;提交审批时仍要求至少一个类型。
- 并发版本、状态或权限不满足时失败且零写入。
### 7. 提交供应商注册 `POST /admin/supplier/items/{supplierId}/submit`
**VO**: `SupplierSubmitReqVO / SupplierApprovalCommandRespVO`
#### 使用场景
提交完整注册表单并进入审批;审批响应直接携带本次注册初始账户的完整摘要。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 草稿供应商 |
| `fullName` / `taxNo` | Body | String | 是 | 完整注册表单约束 | 主体信息 |
| `types` | Body | Array | 是 | 至少一项且不得重复 | 第一项为主类型 |
| `initialAccounts` | Body | Array | 否 | 最多一项;省略可保留既有草稿账户 | 初始账户完整快照 |
| `expectedUpdateTime` | Body | String | 是 | 必须等于当前版本 | 并发围栏 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `approvalLogId` / `requestNo` | String | 审批记录与幂等请求号 |
| `provider` / `approvalStatus` / `syncStatus` | String | 审批通道、结果与本地应用状态 |
| `initialAccounts[].accountId` | String | 本次注册关联账户 ID |
| `initialAccounts[].accountNo` | String | 完整银行账号 |
| `initialAccounts[].accountNoMask` | String | 废弃兼容别名 |
| `initialAccounts[].status` | String | 审批通过后为 `ACTIVE` |
#### 请求示例
```json
{
"fullName": "示例旅行服务有限公司",
"taxNo": "91350211M000100Y46",
"types": [{"typeCode": "HOTEL"}],
"mainCooperation": "酒店资源合作",
"licenseImageUrl": "https://files.example.com/supplier/license.jpg",
"qualifications": [{
"qualType": "BUSINESS_LICENSE",
"certNo": "LIC-2026-001",
"imageUrl": "https://files.example.com/supplier/license.jpg",
"permanentValid": true
}],
"expectedUpdateTime": "2026-08-27 11:35:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"approvalLogId": "2092800000000000021",
"requestNo": "SUP-REQ-EXAMPLE",
"provider": "LOCAL_AUTO",
"approvalStatus": "APPROVED",
"syncStatus": "APPLIED",
"initialAccounts": [{
"accountId": "2092800000000000011",
"accountNo": "6222021234567890",
"accountNoMask": "6222021234567890",
"status": "ACTIVE"
}],
"submittedAt": "2026-08-27 11:36:00",
"finishedAt": "2026-08-27 11:36:00"
}
}
```
#### 空数据 / 降级响应
没有初始账户时返回 `initialAccounts: []`。提交仍必须包含非空类型和当前完整注册资料,不因草稿阶段允许空类型而放宽。
#### 错误响应
```json
{
"code": 395008,
"message": "请至少选择一个类型并设置唯一主类型",
"data": null,
"success": false
}
```
#### 业务边界
- 仅可信 `FINANCE`、`SUPER_ADMIN` 且同时拥有更新和提交权限的身份可执行。
- 审批准备、结果应用、主体状态、账户状态和完整审计保持原有事务与幂等语义。
- 权限、状态、摘要或并发围栏失败时不允许部分写入。
## 四、契约约束与正确调用方式
| 场景 | 正确处理 |
|---|---|
| 主类型绑定 | 使用 `primaryTypeCode`;类型列表使用 `types[].isPrimary`,不要自行取第一项猜测 |
| 完整字段 | 使用 `tax_no`、`legalRepresentativeIdNo`、`contactPhone`、`certNo`、`accountNo` |
| 废弃别名 | `legalRepresentativeIdNoMask`、`contactPhoneMask`、`certNoMask`、`accountNoMask` 仅作旧客户端兼容 |
| 证明附件无权限 | `proofFileUrls` 字段不存在;不要把缺字段当接口异常或空附件 |
| 草稿账户 | 从写响应 `initialAccounts` 获取;账户读取接口只返回 PENDING/ACTIVE/DISABLED |
| 业务失败 | HTTP 状态之外必须检查 `code`、`success`、`message` 和 `data` |
管理端不得把业务请求体中的身份或角色作为授权依据,也不得缓存其他管理员读取到的完整敏感值供当前会话复用。
## 五、数据库行为
| 外部动作 | 可观察结果 |
|---|---|
| 创建/更新供应商 | 主体、联系人、资质、类型和初始账户在同一业务事务中成功或失败 |
| 注册提交 | 审批结果成功应用后主体和初始账户进入可读生效状态,响应返回完整账户摘要 |
| 新增或变更敏感值 | 后续授权读取返回与提交一致的完整业务值,唯一冲突或非法值整次失败 |
| 失败请求 | 未认证、无权、缺参、非法状态、并发冲突和审计失败均不留下部分业务写入 |
| 历史数据 | 可恢复历史值继续读取;只剩不可逆掩码的审批历史显式标记为不可恢复 |
这些是接口可观察行为;调用方不依赖具体存储结构,也不应自行维护明文/密文兼容状态。
## 六、边界行为
- 未登录经 Gateway 返回 `401`。
- `ADMIN` 可在既有查看权限与数据范围内读取完整业务值,但写入返回 `395002`。
- `FINANCE`、`SUPER_ADMIN` 仍需同时拥有对应平台权限才能写,角色名称本身不是唯一授权条件。
- 附件独立权限不足时字段省略;同步审计失败时请求失败,不降级泄露附件。
- 列表 `limit=201`、缺少必填字段或非法状态返回业务失败,并保持零写入。
- Snowflake ID 继续按字符串处理;时间格式继续为 `yyyy-MM-dd HH:mm:ss`。
## 六.5、枚举 / 数据字典
### `targetType` / `valueAvailability`
| 字段 | 值 | 中文与说明 |
|---|---|---|
| `targetType` | `SUPPLIER` | 供应商主体变更,`targetId` 为 null |
| `targetType` | `ACCOUNT` | 收款账户变更,`targetId` 为账户 ID |
| `valueAvailability` | `FULL` | 完整业务前后值可用 |
| `valueAvailability` | `LEGACY_MASKED_UNRECOVERABLE` | 历史只剩不可逆掩码,不推测原值 |
### 账户可读状态
| 值 | 中文 | 说明 |
|---|---|---|
| `PENDING` | 待审批 | 账户可在管理端读取 |
| `ACTIVE` | 已生效 | 正常可用账户 |
| `DISABLED` | 已停用 | 保留只读历史信息 |
| `DRAFT` | 草稿 | 不进入账户列表和账户详情 |
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| 税号、法人证件号、电话、证照号 | 主要返回掩码或混合口径 | 授权读取返回完整值 |
| `accountNo` | 可能为空或仅依赖 `accountNoMask` | 返回完整账号 |
| `*Mask` | 表示掩码 | 废弃兼容别名,暂与完整值同值 |
| `proofFileUrls` | 权限语义分散 | 独立权限 + 同步审计;无权时字段省略 |
| `types[].isPrimary` | 主类型回显不稳定 | 明确 Boolean 标记 |
| `primaryTypeCode/Name` | 不存在 | 根对象直接返回,空类型草稿为 null |
| `initialAccounts` | 写/提交响应摘要不完整 | 返回账户 ID、完整账号和状态 |
| 审批记录 | 主体与账户口径分散 | 统一目标类型、目标 ID、完整值和可用性 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 编辑页初始化 | 可能需要用掩码字段或猜测主类型 | 可直接绑定完整值与权威主类型 |
| 附件读取 | 容易把空值与无权混淆 | 无权省略字段,审计失败整体失败 |
| 草稿账户展示 | 读取接口与草稿状态语义不清 | 写响应展示草稿摘要,读取接口只展示可读状态 |
| 历史审批 | 无法区分完整值与不可逆掩码 | 通过 `valueAvailability` 明确区分 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。既有字段名保留,废弃别名在兼容窗口内继续返回。
- **前端是否必须同步上线**: 建议尽快。应切换到无 `Mask` 字段、接入主类型字段,并正确处理 `proofFileUrls` 缺失。
- **前端 workaround 清理点**: 删除自行猜主类型、对 `accountNoMask` 二次掩码、把附件缺字段强制转空数组等兼容逻辑。
- **敏感展示责任**: 完整值只在已授权业务页面按最小必要范围展示,不写入前端日志、埋点、错误上报或持久缓存。
## 七、不影响范围
- **仅影响**: 管理后台供应商基本信息、结算账户、注册提交与审批记录消费契约。
- **零影响**:
- 不新增或修改 Gateway 路由。
- 不修改角色、菜单、按钮或数据范围定义。
- 不修改小程序、C 端、订单、产品、车队接口。
- 不修改文件上传接口、Redis、MQ 或跨服务 DTO。
- 不允许 DRAFT 账户通过账户查询接口提前暴露。
## 八、测试环境已验证
最终后端提交 `aa735fb8531a3463b2b460965245e07ed8697775` 已通过 Deploy Panel 任务 `7a48028c` 发布 `hl-resource-service` 双实例,真实 TEST 身份经 Gateway 验证:
```text
历史兼容:完整值与数据库一致,税号关键字搜索命中 ✓
SUPER_ADMIN:创建草稿、详情完整值、注册审批、账号及附件审计读取 ✓
ADMIN:详情与完整账号可读,proofFileUrls 省略,写入拒绝 395002 ✓
未认证 401、缺参 400、limit=201 为 400、非法状态 400 ✓
新写主体/联系人/资质/账户及新审计均为完整值,失败请求零写入 ✓
合成供应商完成精确清理:17 张相关表检查、业务残留 0、失败标记残留 0 ✓
验收会话主动失效,敏感读取操作审计按审计规则保留 ✓
```
获批真实账号没有 `FINANCE` 角色,因此没有伪造该身份;`FINANCE` 与 `SUPER_ADMIN` 的服务端写权限同构由后端自动化测试覆盖,真实 TEST 写入使用 `SUPER_ADMIN`,并用真实 `ADMIN` 验证拒绝路径。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6478 | #6436 | 完整字段、主类型、权限、迁移与兼容读写 | ✅ 主交付 |
| #6483 | #6481 | 修复 TEST 排序规则下回填精确比较 | ✅ 补充修复 |
| #6486 | #6484 | 支持历史 Unicode 税号回填 | ✅ 补充修复 |
## 十、相关文档
- [主工单 #6436](https://git.1814.love:8443/wx/HL/issues/6436)
- [主 PR #6478](https://git.1814.love:8443/wx/HL/pulls/6478)
- [补充工单 #6481](https://git.1814.love:8443/wx/HL/issues/6481) / [PR #6483](https://git.1814.love:8443/wx/HL/pulls/6483)
- [补充工单 #6484](https://git.1814.love:8443/wx/HL/issues/6484) / [PR #6486](https://git.1814.love:8443/wx/HL/pulls/6486)
- 后端详细 API 说明:`docs/supplier/API-CHANGE-6436.html`
## 关联 / 联系人
### 链接
- **Issue**: [#6436](https://git.1814.love:8443/wx/HL/issues/6436)
- **PR**: [#6478](https://git.1814.love:8443/wx/HL/pulls/6478)
- **最终 TEST 提交**: [aa735fb8](https://git.1814.love:8443/wx/HL/commit/aa735fb8531a3463b2b460965245e07ed8697775)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,499 @@
---
schema: "hl-changelog/v2"
ticket: "6474"
title: "供应商详情分离变更记录与审批流水"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "199147de"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6519 已合并 dev-v3;补充缺陷 PR #6534 已恢复旧兼容查询。hl-resource-service 已由 Deploy Panel 任务 9e69fd2c 精确发布提交 aec1de2d 至 TEST,并以真实 SUPER_ADMIN、ADMIN 和受限 CUSTOMIZER 身份完成 Gateway 只读验收。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 供应商管理:详情分离变更记录与审批流水
供应商详情新增两个相互独立的只读分页接口:“变更记录”只返回供应商主体发生过的业务变化,“审批记录”只返回供应商主体审批事实。两个列表独立计数、独立筛选、独立排序,收款账户记录不会混入。
旧 `/admin/supplier/items/{supplierId}/approval-records/page` 继续保留,用于仍需主体与账户变更混合列表的兼容场景;本次不删除、不改名,也不要求现有调用方同步切换。
## 一、背景
旧审批记录接口承载的是主体与收款账户变更的兼容混合列表,不能同时满足详情页“业务变更”和“审批过程”两种独立展示语义。若管理端在本地拆分或二次计数,会出现分页总数不准确、账户记录混入主体历史、审批技术字段误展示等问题。
本次由后端直接提供两个稳定的业务白名单视图:
- “变更记录”展示操作类型、变更前后业务摘要、原因、生命周期状态、操作人、历史角色和发生时间。
- “审批记录”展示审批业务、申请人、状态、动作、审批人安全展示值、意见、提交时间和完成时间。
- 两个接口都在查询前校验可信管理身份、角色和 `supplier:approval:read` 权限。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 查询供应商主体变更记录 | GET | `/admin/supplier/items/{supplierId}/change-records/page` | 新增只读接口 | 独立分页返回主体变更业务摘要 |
| 2 | 查询供应商主体审批流水 | GET | `/admin/supplier/items/{supplierId}/approval-history/page` | 新增只读接口 | 独立分页返回主体审批过程与结果 |
## 三、接口详情
### 1. 查询供应商主体变更记录 `GET /admin/supplier/items/{supplierId}/change-records/page`
**VO**: `SupplierChangeRecordPageReqVO` / `SupplierChangeRecordRespVO`
#### 使用场景
管理端进入供应商详情的“变更记录”页签时调用。服务端完成主体记录筛选、分页和业务摘要投影;前端不要从旧混合列表中再次筛选主体记录,也不要自行拼接前后值摘要。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
| `page` | Query | Integer | 否 | 默认 `1`,最小 `1` | 当前页;兼容别名 `pageNo` |
| `pageSize` | Query | Integer | 否 | 默认 `20`,范围 `1..100` | 每页条数 |
| `operationType` | Query | String | 否 | `CREATE`、`UPDATE`、`ENABLE`、`DISABLE`、`DELETE` | 操作类型精确筛选 |
| `fieldName` | Query | String | 否 | 非空白,最长 64 字符 | 发生变化的业务字段精确筛选 |
| `status` | Query | String | 否 | 生命周期编码 | 按变更后的供应商状态筛选 |
| `from` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss` | 与 `to` 成对传入,按发生时间筛选,含边界 |
| `to` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss`,不得早于 `from` | 与 `from` 成对传入,含边界 |
| `sortBy` | Query | String | 否 | `occurredAt` 或 `changeLogId`,默认 `occurredAt` | 服务端白名单排序字段 |
| `sortDirection` | Query | String | 否 | `ASC` 或 `DESC`,不区分大小写,默认 `DESC` | 排序方向 |
#### 出参 `Result<PageResult<SupplierChangeRecordRespVO>>`
分页对象固定包含 `records`、`total`、`page`、`pageSize`。
| 字段 | 类型 | 说明 |
|---|---|---|
| `records` | Array | 当前页主体变更记录;无数据时为 `[]` |
| `total` | Integer | 符合筛选条件的主体变更总数,不包含账户记录 |
| `page` | Integer | 当前页码 |
| `pageSize` | Integer | 当前页容量 |
| `records[].operationType` | String | 操作类型编码 |
| `records[].oldValue` | String | 变更前中文业务摘要;空值使用明确占位 |
| `records[].newValue` | String | 变更后中文业务摘要;空值使用明确占位 |
| `records[].valueAvailability` | String | `FULL` 或 `LEGACY_MASKED_UNRECOVERABLE` |
| `records[].changeReason` | String | 业务变更原因或稳定占位 |
| `records[].status` | String/null | 变更后的供应商生命周期中文名 |
| `records[].operatorName` | String | 操作人展示名;系统操作为“系统”,依赖降级为“未知管理员” |
| `records[].operatorRole` | String | 操作发生时的角色快照中文名 |
| `records[].occurredAt` | String | 变更发生时间,格式 `yyyy-MM-dd HH:mm:ss` |
响应不会返回变更日志 ID、管理员内部 ID、原始审计 JSON、TraceId、账户 ID、证明附件或其他主体敏感快照字段。
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/change-records/page?page=1&pageSize=20&operationType=UPDATE&sortBy=occurredAt&sortDirection=DESC
Authorization: Bearer <admin-token>
```
GET 请求无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [
{
"operationType": "UPDATE",
"oldValue": "供应商全称:示例旅行服务公司",
"newValue": "供应商全称:示例旅行服务有限公司",
"valueAvailability": "FULL",
"changeReason": "修正供应商主体名称",
"status": "合作中",
"operatorName": "示例管理员",
"operatorRole": "超级管理员",
"occurredAt": "2026-08-27 15:20:00"
}
],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 降级响应
筛选结果为空仍返回成功分页,不回退到旧混合列表:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [],
"total": 0,
"page": 1,
"pageSize": 20
}
}
```
历史记录无法恢复完整原值时,仍返回已有安全摘要,并明确标记:
```json
{
"operationType": "UPDATE",
"oldValue": "138****8000",
"newValue": "139****9000",
"valueAvailability": "LEGACY_MASKED_UNRECOVERABLE",
"changeReason": "历史记录",
"status": "合作中",
"operatorName": "未知管理员",
"operatorRole": "管理员",
"occurredAt": "2026-07-01 10:00:00"
}
```
#### 错误响应
非法筛选、分页、排序或时间范围返回参数错误,例如只传 `from`:
```json
{
"code": 400,
"message": "from和to必须同时传入",
"success": false,
"data": null
}
```
供应商不存在或已删除:
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 要求 Gateway 登录态、可信管理员身份、允许的读角色以及 `supplier:approval:read` 平台权限;任一门禁失败时不查询历史数据。
- 代码角色矩阵沿用 `ADMIN`、`FINANCE`、`SUPER_ADMIN`;角色允许不等于拥有平台权限,两项必须同时满足。
- 只读取供应商主体变更,不包含收款账户变更或审批流水。
- `oldValue`、`newValue` 是后端形成的中文业务摘要,不是可回填编辑表单的结构化快照。
- User 姓名依赖异常只影响 `operatorName`,分页仍成功且不会以管理员内部 ID 降级。
- 接口只读,成功、空数据和失败场景均不修改供应商、审批、缓存或消息状态。
### 2. 查询供应商主体审批流水 `GET /admin/supplier/items/{supplierId}/approval-history/page`
**VO**: `SupplierApprovalHistoryPageReqVO` / `SupplierApprovalHistoryRespVO`
#### 使用场景
管理端进入供应商详情的“审批记录”页签时调用。该列表只表达主体审批申请、过程和结果,不包含主体字段变更摘要,也不包含收款账户审批。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
| `page` | Query | Integer | 否 | 默认 `1`,最小 `1` | 当前页;兼容别名 `pageNo` |
| `pageSize` | Query | Integer | 否 | 默认 `20`,范围 `1..100` | 每页条数 |
| `bizType` | Query | String | 否 | 大写字母开头,仅大写字母、数字、下划线,最长 32 字符 | 审批业务类型精确筛选 |
| `approvalStatus` | Query | String | 否 | 大写字母开头,仅大写字母、数字、下划线,最长 20 字符 | 审批状态精确筛选 |
| `action` | Query | String | 否 | 大写字母开头,仅大写字母、数字、下划线,最长 32 字符 | 审批动作或结果精确筛选 |
| `from` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss` | 与 `to` 成对传入,按提交时间筛选,含边界 |
| `to` | Query | String | 条件必填 | `yyyy-MM-dd HH:mm:ss`,不得早于 `from` | 与 `from` 成对传入,含边界 |
| `sortBy` | Query | String | 否 | `submittedAt` 或 `finishedAt`,默认 `submittedAt` | 服务端白名单排序字段 |
| `sortDirection` | Query | String | 否 | `ASC` 或 `DESC`,不区分大小写,默认 `DESC` | 排序方向 |
#### 出参 `Result<PageResult<SupplierApprovalHistoryRespVO>>`
分页对象固定包含 `records`、`total`、`page`、`pageSize`。
| 字段 | 类型 | 说明 |
|---|---|---|
| `records` | Array | 当前页主体审批记录;无数据时为 `[]` |
| `total` | Integer | 符合筛选条件的主体审批总数,不包含账户审批 |
| `page` | Integer | 当前页码 |
| `pageSize` | Integer | 当前页容量 |
| `records[].bizType` | String | 审批业务类型编码 |
| `records[].bizTypeName` | String | 审批业务类型中文名;未知编码显示“其他供应商审批” |
| `records[].applicantName` | String | 申请人展示名;系统申请为“系统”,依赖降级为“未知申请人” |
| `records[].approvalStatus` | String | 审批状态编码 |
| `records[].approvalStatusName` | String | 审批状态中文名;未知编码显示“未知状态” |
| `records[].action` | String/null | 审批动作或结果编码 |
| `records[].actionName` | String | 审批动作中文名;未知编码显示“其他动作” |
| `records[].approverName` | String | 安全展示值:“系统”“待审批”或“外部审批人” |
| `records[].opinion` | String | 审批意见;无意见时为 `—` |
| `records[].submittedAt` | String | 提交时间;历史缺失时使用该审批事实的创建时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `records[].finishedAt` | String/null | 完成时间;审批中为 `null`,格式 `yyyy-MM-dd HH:mm:ss` |
响应不会返回审批日志 ID、申请人内部 ID、requestNo、spNo、审批模板 ID、企微用户 ID、候选或详情摘要、候选或详情 JSON/密文、apply/sync 技术状态。
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/approval-history/page?page=1&pageSize=20&approvalStatus=APPROVED&sortBy=submittedAt&sortDirection=DESC
Authorization: Bearer <admin-token>
```
GET 请求无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [
{
"bizType": "PROFILE_CREATE",
"bizTypeName": "供应商建档审批",
"applicantName": "示例管理员",
"approvalStatus": "APPROVED",
"approvalStatusName": "已通过",
"action": "SYSTEM_AUTO_APPROVE",
"actionName": "系统自动通过",
"approverName": "系统",
"opinion": "—",
"submittedAt": "2026-08-27 14:00:00",
"finishedAt": "2026-08-27 14:00:01"
}
],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 降级响应
不存在符合条件的审批事实时返回成功空分页:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [],
"total": 0,
"page": 1,
"pageSize": 20
}
}
```
申请人姓名依赖不可用时,该记录仍正常返回,`applicantName` 为 `未知申请人`;不会回退为内部管理员 ID。
#### 错误响应
非法枚举、分页、排序或反向时间范围返回参数错误,例如:
```json
{
"code": 400,
"message": "from不能晚于to",
"success": false,
"data": null
}
```
无业务读取权限时返回拒绝结果,且不查询审批历史:
```json
{
"code": 403,
"message": "无权访问供应商数据",
"success": false,
"data": null
}
```
#### 业务边界
- 权限条件与变更记录接口相同:可信管理身份、`ADMIN`/`FINANCE`/`SUPER_ADMIN` 读角色和 `supplier:approval:read` 平台权限缺一不可。
- 只读取供应商主体审批,不包含收款账户审批或主体字段变更记录。
- 时间筛选基于提交时间;历史提交时间为空时使用该审批事实的创建时间。
- 申请人名称每页最多批量补全一次;User 服务异常、空响应或缺失用户时安全降级,整页不返回 500。
- 审批人只返回业务安全展示值,不暴露企微或外部审批系统标识。
- 接口只读,不推进审批状态,不触发审批回调,也不产生缓存、消息或配置副作用。
## 四、契约约束与正确调用方式
### 详情页接入映射
| 页面区域 | 正确接口 | 数据范围 |
|---|---|---|
| 变更记录 | `GET /admin/supplier/items/{supplierId}/change-records/page` | 仅主体变更 |
| 审批记录 | `GET /admin/supplier/items/{supplierId}/approval-history/page` | 仅主体审批 |
| 旧兼容混合列表 | `GET /admin/supplier/items/{supplierId}/approval-records/page` | 主体与账户变更,契约不变 |
### ✅ 正确 / ❌ 错误调用对照
| 场景 | 调用 / 结果 |
|---|---|
| ✅ 分别加载两个页签 | 两条新接口分别维护自己的 `page`、`pageSize`、筛选和 `total` |
| ✅ 查询完整时间区间 | 同时传 `from=2026-08-01 00:00:00` 与 `to=2026-08-31 23:59:59` |
| ✅ 兼容旧页面 | 继续调用旧 `approval-records/page`,无需因本次新增接口修改 |
| ❌ 在前端拆旧混合列表 | 分页后再过滤会得到错误总数,也不能生成审批流水 |
| ❌ 只传一侧时间 | 返回 `400`,不执行历史查询 |
| ❌ 把 `supplierId` 转为 Number | 可能丢失精度;ID 必须始终按 String 传输和比较 |
调用方必须同时检查 `code`、`message`、`success` 和 `data`,不能只用 HTTP 状态判断业务成功。
## 六、边界行为
- 未登录或登录态失效:统一结果业务码 `401`,不进入供应商查询。
- 已登录但角色或平台权限不满足:业务码 `403`,不读取历史。
- `supplierId<=0`、`page<1`、`pageSize` 不在 `1..100`、非法筛选或排序:业务码 `400`。
- 供应商不存在或已软删除:业务码 `395001`。
- `from`、`to` 必须同时传入,且 `from<=to`;时间边界包含起止时刻。
- 请求超过最后一页:返回原请求页码、空 `records` 和真实 `total`,不自动改页。
- 两个新接口的 `total` 分别统计自己的数据集,互不借用;账户变更和账户审批都不进入。
- 旧 `approval-records/page` 的路径、请求、响应及主体/账户混合语义保持兼容。
## 六.5、枚举 / 数据字典
### `operationType`(变更记录操作类型)
**所属字段**: `SupplierChangeRecordPageReqVO.operationType` / `SupplierChangeRecordRespVO.operationType` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `CREATE` | 创建 | 创建供应商主体 |
| `UPDATE` | 更新 | 更新主体业务字段 |
| `ENABLE` | 启用 | 恢复可用状态 |
| `DISABLE` | 停用 | 暂停或禁用合作 |
| `DELETE` | 删除 | 软删除业务事实 |
### `status`(变更后的供应商生命周期)
**所属字段**: `SupplierChangeRecordPageReqVO.status` | **类型**: `String`
| 值 | 中文展示 |
|---|---|
| `DRAFT` | 草稿 |
| `VETTING` | 注册审核中 |
| `ACTIVE` | 合作中 |
| `SUSPENDED` | 暂停合作 |
| `FROZEN` | 已冻结 |
| `BLACKLIST` | 黑名单 |
| `ARCHIVED` | 已归档 |
### `valueAvailability`(变更摘要可用性)
**所属字段**: `SupplierChangeRecordRespVO.valueAvailability` | **类型**: `String`
| 值 | 中文 | 调用方处理 |
|---|---|---|
| `FULL` | 完整业务摘要可用 | 正常展示 `oldValue` / `newValue` |
| `LEGACY_MASKED_UNRECOVERABLE` | 历史原值不可恢复 | 展示现有摘要,并标注其为历史脱敏值;不要提示用户重试 |
### `bizType`(主体审批业务类型)
**所属字段**: `SupplierApprovalHistoryPageReqVO.bizType` / `SupplierApprovalHistoryRespVO.bizType` | **类型**: `String`
| 值 | 中文展示 | 说明 |
|---|---|---|
| `PROFILE_CREATE` | 供应商建档审批 | 注册或建档审批 |
| `STATUS_CHANGE` | 供应商状态变更审批 | 生命周期状态变更审批 |
| 其他合法大写编码 | 其他供应商审批 | 为历史及后续业务保留兼容 |
### `approvalStatus`(主体审批状态)
**所属字段**: `SupplierApprovalHistoryPageReqVO.approvalStatus` / `SupplierApprovalHistoryRespVO.approvalStatus` | **类型**: `String`
| 值 | 中文展示 |
|---|---|
| `PENDING` | 审批中 |
| `APPROVED` | 已通过 |
| `REJECTED` | 已驳回 |
| `CANCELED` / `CANCELLED` | 已撤销 |
| `FAILED` | 失败 |
| 其他合法大写编码 | 未知状态 |
### `action`(主体审批动作或结果)
**所属字段**: `SupplierApprovalHistoryPageReqVO.action` / `SupplierApprovalHistoryRespVO.action` | **类型**: `String`
| 值 | 中文展示 |
|---|---|
| `SUBMIT` | 已提交 |
| `SYSTEM_AUTO_APPROVE` | 系统自动通过 |
| `APPLY_FAIL` | 结果应用失败 |
| `APPROVE` | 通过 |
| `REJECT` | 驳回 |
| `REVOKE` | 撤销 |
| `CANCEL` | 取消 |
| 其他合法大写编码 | 其他动作 |
## 七、不影响范围
- **仅新增**:供应商详情的两个管理端只读分页契约。
- **保持兼容**:旧 `approval-records/page` 继续返回主体与账户变更混合列表。
- **零影响**:供应商详情、创建、更新、提交、归档、暂停合作、拉黑、删除及收款账户管理接口。
- **零影响**:供应商写权限、数据范围、状态机、审批提交/结果应用、事务、锁、幂等、审计与软删除语义。
- **零影响**:数据库结构与历史数据;本次无 migration、数据回填或破坏性数据操作。
- **零影响**:Gateway 顶级路由、Nacos 配置、Redis、MQ 和跨服务写链路。
- 后端仓库未修改任何管理端前端源码;管理端接入状态保持 `pending`。
## 八、测试环境已验证
- 本地自动化:主功能定向测试 79 项零失败;补充空上下文兼容测试套件 51 项零失败;最终 `hl-resource-service` 全量 2158 项零失败、零错误(38 项条件跳过);`GatewayRouteAuditTest` 4 项零失败。
- 合并:主 PR #6519 合并提交 `3dc80ad69630127d91fa973a682051b2ef5c41d8`;验收发现旧兼容接口空上下文缺陷后,补充工单 #6532 / PR #6534 以最小修复合入,最新合并提交为 `aec1de2db2b4ed07d78877c1ccd4e532919bdaf3`。
- TEST 精确部署:Deploy Panel 任务 `9e69fd2c` 成功发布 `aec1de2db2b4ed07d78877c1ccd4e532919bdaf3`;构建退出码 0,任务期 10 次有效采样均至少 2 个运行进程、2 个健康启用 Nacos 实例,零不可用采样,部署窗日志无失败标记。
- 真实 Gateway:使用现有真实 `SUPER_ADMIN` 与同账号可切换的 `ADMIN` 身份,两条新接口的成功分页、字段白名单、独立总数、筛选、排序、边界参数和旧接口兼容均通过;`CUSTOMIZER` 返回 `403`,未认证返回 `401`。
- TEST 数据覆盖:目标供应商存在 18 条主体变更事实和 1 条主体审批事实;对应账户变更、账户审批均为 0,验证两个新接口没有跨域混页。审批样本为 `APPROVED` / `SYSTEM_AUTO_APPROVE`;环境没有外部审批人样本,未伪造业务数据。
- 身份说明:代码角色矩阵仍包含 `ADMIN`、`FINANCE`、`SUPER_ADMIN`。TEST 角色目录存在 `FINANCE`,但当前获批真实账号不能切换到该角色;按工单确认使用现有 `SUPER_ADMIN`(并覆盖 `ADMIN`)替代 FINANCE 实测,不创建、不修改、不伪造财务账号。
- 清理:验收全程只读,供应商数据指纹前后一致,业务测试数据创建数为 0;真实会话均已失效处理,无数据库、Redis、MQ 或临时配置需要清理。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6486 | #6436 | 冻结供应商授权完整值与历史摘要语义 | ✅ 有效,本次沿用 |
| #6519 | #6474 | 新增主体变更记录与主体审批流水独立分页 | ✅ 本次主功能 |
| #6534 | #6532 | 修复旧兼容接口投影空上下文时的空指针 | ✅ 有效,保障历史兼容 |
## 十、相关文档
- 关联 Issue:[#6474](https://git.1814.love:8443/wx/HL/issues/6474)
- 关联 PR:[#6519](https://git.1814.love:8443/wx/HL/pulls/6519)
- 补充缺陷 Issue:[#6532](https://git.1814.love:8443/wx/HL/issues/6532)
- 补充缺陷 PR:[#6534](https://git.1814.love:8443/wx/HL/pulls/6534)
- 管理端接入:将“变更记录”“审批记录”分别切换到两条新接口;前端引用待回填。
## 撤回
1. 管理端先停止请求两个新路径,并恢复使用旧 `approval-records/page`,避免代码回退窗口产生请求失败。
2. 从最新 `dev-v3` 创建独立回退分支,对主功能合并执行 `git revert -m 1 --no-edit 3dc80ad69630127d91fa973a682051b2ef5c41d8`,验证后经独立 PR 合入。
3. #6532 的空上下文修复可独立保留,它修复的是旧兼容接口且不依赖两个新路径。若明确要求连同该修复一起撤回,再按从新到旧顺序执行 `git revert -m 1 --no-edit aec1de2db2b4ed07d78877c1ccd4e532919bdaf3`;这样会重新引入旧兼容接口在特定记录上的 500 风险,不作为推荐方案。
4. 使用 Deploy Panel 两阶段客户端,仅滚动部署 `hl-resource-service`;本次无数据库、配置、Redis 或 MQ 恢复步骤,也无不可逆数据影响。
5. 撤回后经 Gateway 验证两个新路径不可用、旧兼容接口按选定回退范围正常,复测未认证和越权,并确认 Resource 双实例、Nacos、日志及零写入。
6. 同步发布本 Changelog 的撤回说明;不得仅改文件名表达状态。
## 关联 / 联系人
### 链接
- **Issue**: [#6474](https://git.1814.love:8443/wx/HL/issues/6474)
- **PR**: [#6519](https://git.1814.love:8443/wx/HL/pulls/6519)
- **Merge commit**: [`3dc80ad6`](https://git.1814.love:8443/wx/HL/commit/3dc80ad69630127d91fa973a682051b2ef5c41d8)
- **补充 PR**: [#6534](https://git.1814.love:8443/wx/HL/pulls/6534)
- **TEST 目标提交**: [`aec1de2d`](https://git.1814.love:8443/wx/HL/commit/aec1de2db2b4ed07d78877c1ccd4e532919bdaf3)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,347 @@
---
schema: "hl-changelog/v2"
ticket: "6476"
title: "供应商详情补齐地址备注并统一表单校验"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "6635a2ae"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6517 已合并 dev-v3(合并提交 a952a04c);hl-resource-service 已随 dev-v3 精确提交 b269e5fd 由 Deploy Panel 任务 938ca09e 发布 TEST。真实 TEST 身份已验证 address/remark 创建、详情、更新回显,创建/更新同口径校验、权限失败零写入及测试数据清理。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 供应商管理:详情补齐地址备注并统一表单校验
供应商详情接口现在完整返回主体的 `address` 和 `remark`,管理端可用同一份详情快照初始化查看页与编辑表单。创建、提交和更新原本已有的业务字段与校验未增加新的必填项;本工单用契约测试和真实 TEST 验收固定三条写链路的一致口径。
本次没有新增接口路径、权限点或业务错误码。
## 一、背景
创建和更新请求已经接受地址、备注等主体字段,但基础信息详情此前没有回传 `address`、`remark`,导致管理端打开编辑页时无法完整还原已保存表单。同时,创建、提交和更新分属不同请求模型,消费方需要一个明确、可验证的共同校验口径。
本次处理后:
- 详情响应补齐主体地址与内部备注。
- 创建、提交、更新对共同主体字段、类型、联系人、资质和合同继续执行同一业务规则。
- 更新只额外要求变更原因、主体并发版本,以及被修改子项的 ID/版本。
- #6436/#6499 已交付的授权完整值语义保持不变,不重新引入脱敏值或“留空表示保留旧敏感值”的旧约定。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应新增字段 | 根对象新增 `address`、`remark`,用于完整初始化查看与编辑表单 |
创建草稿、提交注册和更新资料的路径、请求字段及错误码没有结构性变化,因此不作为新增接口列入上表;其稳定调用规则在第四节集中说明。
## 三、接口详情
### 1. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
管理端进入供应商详情或编辑页时调用。响应是主体、类型、联系人、资质、合同及并发版本的完整快照;编辑页应直接使用其中的 `address`、`remark` 和 `updateTime`,不得用本地空值覆盖。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
#### 出参 `Result<SupplierBasicInfoRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 供应商 ID |
| `fullName` / `shortName` | String/null | 供应商全称与简称 |
| `tax_no` | String | 完整主体证件号;JSON 字段名保持 `tax_no` |
| `legalRepresentative` | String/null | 法定代表人姓名 |
| `legalRepresentativeIdNo` | String/null | 完整法人居民身份证号 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | String/null | 法人证件正反面永久文件地址 |
| `contactPhone` | String/null | 完整公司联系电话 |
| `establishDate` | String/null | 成立日期,格式 `yyyy-MM-dd` |
| `registeredCapital` / `businessScope` | String/null | 注册资本与经营范围 |
| `address` | String/null | 本次新增回显:注册地址或经营地址 |
| `staffScale` | String/null | 人员规模字典值 |
| `mainCooperation` | String | 主要合作内容 |
| `remark` | String/null | 本次新增回显:供应商主体内部备注 |
| `types` | Array | 类型完整集合;每项包含 `isPrimary` |
| `primaryTypeCode` / `primaryTypeName` | String/null | 当前主类型 |
| `contacts` / `qualifications` / `contracts` | Array | 联系人、资质和合同完整集合;现有项包含字符串 ID 与 `updateTime` |
| `status` | String | 当前供应商状态 |
| `updateTime` | String | 主体并发版本,格式 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"fullName": "示例旅行服务有限公司",
"shortName": "示例旅行",
"tax_no": "91350211M000100Y46",
"legalRepresentative": "示例法人",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdNoMask": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://files.example.com/supplier/id-front.jpg",
"legalRepresentativeIdCardBackUrl": "https://files.example.com/supplier/id-back.jpg",
"contactPhone": "13800138000",
"contactPhoneMask": "13800138000",
"establishDate": "2020-01-01",
"registeredCapital": "100万元",
"businessScope": "境内旅游服务",
"address": "福建省厦门市示例路 1 号",
"staffScale": "LT50",
"mainCooperation": "酒店与景区资源合作",
"remark": "重点合作供应商",
"types": [{"typeCode": "HOTEL", "typeName": "酒店", "isPrimary": true}],
"primaryTypeCode": "HOTEL",
"primaryTypeName": "酒店",
"contacts": [],
"qualifications": [],
"contracts": [],
"status": "DRAFT",
"updateTime": "2026-08-27 15:30:00"
}
}
```
#### 空数据 / 降级响应
历史供应商未填写地址或备注时,字段明确返回 `null`;无类型、联系人、资质或合同时,相应集合返回空数组,不把缺失数据当成接口异常:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"address": null,
"remark": null,
"types": [],
"primaryTypeCode": null,
"primaryTypeName": null,
"contacts": [],
"qualifications": [],
"contracts": [],
"updateTime": "2026-08-27 15:30:00"
}
}
```
#### 错误响应
供应商不存在、已删除或超出数据范围时不返回空详情:
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 要求可信管理身份、`supplier:view` 平台权限和供应商数据范围;登录态不能代替业务权限。
- `address`、`remark` 属于响应的向后兼容增加;旧客户端忽略未知字段即可继续运行。
- `legalRepresentativeIdNoMask`、`contactPhoneMask` 等废弃兼容别名继续与完整值字段同值;新代码使用无 `Mask` 字段。
- Long ID 一律按字符串保存、比较和传输。
- 本接口只读,不修改主体版本、缓存或审批状态。
## 四、契约约束与正确调用方式
以下三个既有写接口没有新增路径或字段,但共同业务口径由本工单固定:
- 创建草稿:`POST /admin/supplier/items/add`
- 提交注册:`POST /admin/supplier/items/{supplierId}/submit`
- 更新资料:`PUT /admin/supplier/items/{supplierId}/update`
### 共同主体字段规则
| 字段 | 创建/提交 | 更新 | 统一约束 |
|---|---|---|---|
| `fullName` | 必填 | 可选 | 最长 500 字符 |
| `shortName` | 可选 | 可选 | 最长 300 字符 |
| `taxNo` | 必填 | 可选,且仅允许在状态规则内修改 | 6 至 64 位字母、数字或展示分隔符;身份证/统一社会信用代码还校验日期或校验位 |
| `legalRepresentative` | 可选 | 可选 | 最长 500 字符 |
| `legalRepresentativeIdNo` | 可选 | 可选 | 18 位合法居民身份证号;与正反面地址按完整三字段组合校验 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | 可选 | 可选 | 公网 HTTPS 永久文件地址,单项最长 1000 字符 |
| `contactPhone` | 可选 | 可选 | 7 至 20 位合法电话字符 |
| `establishDate` | 可选 | 可选 | `yyyy-MM-dd`,不得晚于当前日期 |
| `registeredCapital` | 可选 | 可选 | 最长 50 字符 |
| `businessScope` | 可选 | 可选 | 最长 500 字符 |
| `address` | 可选 | 可选 | 最长 500 字符;创建和更新同口径 |
| `staffScale` | 可选 | 可选 | `LT50`、`R50_200`、`R200_500`、`GT500` |
| `mainCooperation` | 必填 | 可选 | 创建/提交不能为空白;更新传值时按同一业务含义保存 |
| `licenseImageUrl` | 可选 | 可选 | 最长 500 字符,并与营业执照资质归一化 |
| `remark` | 可选 | 可选 | 供应商主体内部备注,不等同于审批意见或 `changeReason` |
集合上限与语义保持不变:`types` 最多 15 项;`contacts`、`qualifications`、`contracts` 各最多 100 项;`initialAccounts` 最多 1 项。非空类型必须来自生效字典且不得重复,非空联系人集合必须形成唯一默认联系人,资质和合同继续执行类型、日期、金额、归属和完整快照校验。
更新场景额外要求:
- `changeReason` 必填、非空白、最长 500 字符,并进入业务审计。
- `expectedUpdateTime` 必须等于详情最新 `updateTime`。
- 已有联系人、资质、合同随完整快照更新时,必须带回对应字符串 ID 和子项 `updateTime`。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload / 结果 |
|---|---|
| ✅ 更新地址和备注 | `{"address":"福建省厦门市示例路 2 号","remark":"地址已确认","changeReason":"更新地址和备注","expectedUpdateTime":"2026-08-27 15:30:00"}` |
| ✅ 只更新备注 | `{"remark":"新的内部备注","changeReason":"补充内部备注","expectedUpdateTime":"2026-08-27 15:30:00"}` |
| ❌ 地址超过 500 字符 | 创建、更新均返回 `400`,零写入 |
| ❌ 成立日期晚于当前日期 | 创建、更新均返回 `400`,零写入 |
| ❌ 更新缺少 `changeReason` | 返回 `400`,零写入 |
| ❌ 使用旧 `expectedUpdateTime` | 返回 `395014`,零写入;必须刷新详情后由用户确认 |
### 正确更新顺序
1. 先查询详情,保存字符串 ID、子项版本和根对象 `updateTime`。
2. 用户提交时只发送实际变更字段,并附非空 `changeReason`、最新 `expectedUpdateTime`。
3. 更新成功后采用响应里的新版本;需要完整表单时重新查询详情。
4. 遇到 `395014` 先刷新,不得自动用旧表单覆盖他人修改。
## 五、数据库行为
- 成功创建、提交或更新时,主体与本次携带的类型、联系人、资质、合同及业务审计按聚合事务一起成功。
- 地址和备注保存后,详情读取与变更审计使用同一业务值;`remark` 不会被提升为审批意见或变更原因。
- 请求校验、权限、状态、归属或并发版本失败时零写入,主体 `updateTime` 不变化。
- 本次没有数据库结构变更、Flyway migration 或历史数据回填;旧记录的地址/备注原值保持不变。
- 不新增 Redis、MQ、Feign 或跨服务副作用。
## 六、边界行为
- 未登录或 Token 失效:`401`,不把无认证响应当作正向验收。
- ADMIN 等无写权限角色调用创建、提交或更新:`395002`,零写入;已有详情仍按读取权限返回。
- 供应商不存在或已删除:`395001`。
- 并发版本过期:`395014`,调用方刷新详情后重提。
- 地址超过 500 字符、未来成立日期、必填字段缺失或组合不完整:`400`,零写入。
- 历史 `address`、`remark` 为空:详情返回 `null`,不报错、不猜测默认值。
- `ARCHIVED` 等不可修改状态继续由既有状态门禁拒绝更新。
- 业务失败可能仍使用 HTTP 200,调用方必须同时判断响应体 `code`、`success`、`data`。
## 六.5、枚举 / 数据字典
### `staffScale`(供应商人员规模稳定值)
**所属字段**: `SupplierDraftSaveReqVO.staffScale / SupplierUpdateReqVO.staffScale / SupplierBasicInfoRespVO.staffScale` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `LT50` | 50 人以下 | 小于 50 人 |
| `R50_200` | 50 至 200 人 | 50 至 200 人档 |
| `R200_500` | 200 至 500 人 | 200 至 500 人档 |
| `GT500` | 500 人以上 | 大于 500 人 |
`types[].typeCode`、`contacts[].contactRole`、`qualifications[].qualType` 继续从对应生效数据字典读取,不在客户端硬编码展示名。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| 详情根对象 `address` | 未返回,已保存值无法用于表单回填 | 返回保存的地址;未填写为 `null` |
| 详情根对象 `remark` | 未返回,已保存值无法用于表单回填 | 返回主体内部备注;未填写为 `null` |
| 创建/提交/更新请求字段 | 已存在 | 无新增字段、无新增必填项 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 打开编辑页 | 详情缺少地址和备注,表单初始化不完整 | 一次详情调用可完整初始化主体字段 |
| 共同字段校验 | 实现存在,但缺少跨创建/更新契约锁定 | 自动化和 TEST 验收固定创建/更新同口径 |
| 完整敏感值回显 | 按 #6436/#6499 返回授权完整值 | 保持不变 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。响应新增两个可空字段,旧客户端可忽略;请求无新增必填项。
- **前端是否必须同步上线**: 为闭合编辑体验需要接入 `address`、`remark`,但不构成旧客户端继续运行的硬门禁。
- **前端 workaround 清理点**: 删除编辑页对地址、备注的本地空值兜底,改为使用详情真实值。
- **QA 重点**: 新建后打开详情、更新后刷新详情、地址长度 500/501、今天/未来成立日期、ADMIN 越权、旧版本并发冲突。
## 七、不影响范围
- **仅影响**: 管理后台供应商详情/编辑表单的主体地址、内部备注回填,以及已有写接口校验口径的契约固定。
- **零影响**:
- Gateway 路由和认证级别。
- 供应商生命周期状态机、审批、收款账户、证明附件、资源关系及归档语义。
- 数据库结构、历史数据、Nacos 配置、Redis、MQ、Feign 和其他服务。
- 小程序、C 端、Web、H5、桌面端接口。
- #6436/#6499 的授权完整值和废弃 `*Mask` 兼容语义。
## 八、测试环境已验证
- 本地自动化:供应商定向测试 49 项通过;最新 `dev-v3` 上 `hl-resource-service` Reactor 全量 2,153 项,0 失败、0 错误、38 项条件跳过。
- 部署:Deploy Panel API 任务 `938ca09e` 终态 `success`;目标与实际提交均为 `b269e5fdca2e1d29e0336314a909885e9d1f9d16`,包含本工单合并提交 `a952a04c`。
- 采样:部署任务期 8 个有效采样;每个采样至少 2 个运行进程、2 个健康启用 Nacos 实例,零观测不可用采样。该证据只证明采样点,不代表采样间绝对连续。
- 真实 Gateway:SUPER_ADMIN 创建完整表单后,详情精确回显 `address`、`remark`、完整授权字段、主类型及联系人/资质/合同 ID 和版本;更新后 API、数据库主体与更新审计一致。
- 失败路径:创建/更新的地址超长均返回 `400`;创建/更新的未来成立日期均返回 `400`;ADMIN 更新返回 `395002`;所有失败均验证零写入、版本不变。
- 清理:临时供应商先经业务删除接口软删除,确认主体已删除且活动子项为 0;随后按本次唯一标记精确清理 8 行测试数据,17 张关联表回读,剩余供应商 0 行;测试会话已注销并验证失效。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6478 / #6483 / #6486 | #6436 | 供应商授权完整值、主类型及同日修正 | ✅ 有效,本次保持 |
| #6517 | #6476 | 详情补齐地址备注并固定表单校验契约 | ✅ 最新 |
## 十、相关文档
- 关联 Issue:[#6476](https://git.1814.love:8443/wx/HL/issues/6476)
- 关联 PR:[#6517](https://git.1814.love:8443/wx/HL/pulls/6517)
- 相关完整值契约:`changelogs-v2/2026-08/27_6436_供应商敏感字段完整回显与主类型-修改接口-管理后台.md`
- 管理端接入:读取详情 `address`、`remark`,更新时保留最新 `expectedUpdateTime`;前端引用待回填。
## 撤回
1. 从最新 `dev-v3` 创建独立回退分支,执行:
```bash
git revert -m 1 --no-edit a952a04cf80f24d6c50cbec4b992c82f8580beba
```
2. 运行供应商定向测试、`hl-resource-service` 全量测试和差异检查,经独立 PR 合入。
3. 使用同一 Deploy Panel 两阶段客户端,仅滚动部署 `hl-resource-service`,并绑定批准回退分支的精确提交。
4. 管理端停止依赖详情响应中的 `address`、`remark`,再撤回本 Changelog;既有请求字段和完整值契约保持不变。
5. 无数据库、配置、Redis 或 MQ 恢复步骤;已保存的地址和备注数据保留,不做破坏性回填或删除。
6. 撤回后经 Gateway 复测详情、创建、更新、未认证、越权、并发冲突和失败零写入,并确认 Resource 双实例与 Nacos 健康。
## 关联 / 联系人
### 链接
- **Issue**: [#6476](https://git.1814.love:8443/wx/HL/issues/6476)
- **PR**: [#6517](https://git.1814.love:8443/wx/HL/pulls/6517)
- **实现提交**: [`3cd07d27`](https://git.1814.love:8443/wx/HL/commit/3cd07d27dc876f17af1751ec7ef9de2ddbd71949)
- **Merge commit**: [`a952a04c`](https://git.1814.love:8443/wx/HL/commit/a952a04cf80f24d6c50cbec4b992c82f8580beba)
- **TEST 目标提交**: [`b269e5fd`](https://git.1814.love:8443/wx/HL/commit/b269e5fdca2e1d29e0336314a909885e9d1f9d16)
### 联系人
- **后端负责人**: @lc
- **管理端负责人**: @mmg(`frontend_status: pending`)
@@ -0,0 +1,308 @@
---
schema: "hl-changelog/v2"
ticket: "6518"
title: "供应商草稿创建即生成编号"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "d546912c"
target_release: "v2.1"
verified_at: "2026-08-27"
status_note: "PR #6531 已合并 dev-v3(合并提交 66aed70a);hl-resource-service 已由 Deploy Panel 任务 e977bf7b 精确发布该提交。真实 TEST 身份完成 47 项 Gateway 断言,验证创建即返回 SUP{supplierId}、列表/详情/更新稳定回显、权限失败零写入、审计事实和业务清理。管理端需停止把新建草稿 supplierNo 当作空值或等待审批后再取。"
updated_at: "2026-08-27"
base: "dev-v3"
---
# 🔧 供应商管理:草稿创建即生成编号
`POST /admin/supplier/items/add` 成功后,响应中的 `supplierNo` 现在立即为非空字符串,格式固定为 `SUP{supplierId}`。该编号与本次创建的供应商绑定,后续列表、详情、更新、提交及审批流程均保持同一值。
请求结构、权限点、状态机和业务错误码没有新增或删除。
## 一、背景
此前供应商编号在审批通过时才补齐,因此新建草稿阶段的创建响应、列表和详情可能返回 `supplierNo: null`。管理端若需要展示或引用编号,只能等待审批或使用临时占位。
本次发布后:
- 新建草稿成功即取得正式编号,不再存在“先创建、后编号”的等待窗口。
- 编号格式为字符串 `SUP{supplierId}`;例如 `supplierId="2094000000000000001"` 对应 `supplierNo="SUP2094000000000000001"`。
- 编号在更新、提交、审批和状态变化中不重算、不覆盖。
- 草稿删除后原编号不再分配给其他供应商。
- 发布前已经存在且编号为空的历史记录仍可能返回 `null`;其审批通过时继续兼容补齐。调用方不能自行推导或写入编号。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 创建供应商注册草稿 | POST | `/admin/supplier/items/add` | 响应行为修改 | 成功响应的 `supplierNo` 从草稿期可空改为立即返回 `SUP{supplierId}` |
列表、详情、更新和审批相关响应中的 `supplierNo` 字段结构未变化;对于本次发布后创建的供应商,它们会稳定返回创建响应中的同一编号。
## 三、接口详情
### 1. 创建供应商注册草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO` → `Result<SupplierWriteRespVO>`
#### 使用场景
管理端首次保存供应商注册表单。创建成功后可立即展示、复制或继续传递后端返回的正式 `supplierNo`,无需等待提交审批。
#### 入参
本次没有新增请求字段,`supplierNo` 也不允许由客户端提交。
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `fullName` | Body | String | 是 | 非空,最长 500 | 供应商法定全称 |
| `shortName` | Body | String/null | 否 | 最长 300 | 供应商简称 |
| `taxNo` | Body | String | 是 | 6 至 64 位字母、数字或展示分隔符 | 主体证件号 |
| `types` | Body | Array/null | 否 | 最多 15 项;非空时类型值有效且不得重复 | 供应商类型完整集合 |
| `legalRepresentative` | Body | String/null | 否 | 最长 500 | 法定代表人 |
| `legalRepresentativeIdNo` | Body | String/null | 否 | 为空或合法 18 位居民身份证号 | 法人证件号 |
| `legalRepresentativeIdCardFrontUrl` / `legalRepresentativeIdCardBackUrl` | Body | String/null | 否 | 单项最长 1000 | 法人证件正反面永久地址 |
| `contactPhone` | Body | String/null | 否 | 7 至 20 位合法电话字符 | 公司联系电话 |
| `establishDate` | Body | String/null | 否 | `yyyy-MM-dd`,不得晚于当天 | 成立日期 |
| `registeredCapital` | Body | String/null | 否 | 最长 50 | 注册资本文本 |
| `businessScope` / `address` | Body | String/null | 否 | 单项最长 500 | 经营范围与地址 |
| `staffScale` | Body | String/null | 否 | `LT50`、`R50_200`、`R200_500`、`GT500` | 人员规模 |
| `mainCooperation` | Body | String | 是 | 非空 | 主要合作内容 |
| `licenseImageUrl` | Body | String/null | 否 | 最长 500 | 营业执照影像快捷字段 |
| `remark` | Body | String/null | 否 | - | 供应商内部备注 |
| `contacts` | Body | Array/null | 否 | 最多 100 项 | 联系人完整集合;非空时继续执行角色和唯一默认联系人规则 |
| `qualifications` | Body | Array/null | 否 | 最多 100 项 | 资质完整集合;继续执行类型、证号和有效期规则 |
| `contracts` | Body | Array/null | 否 | 最多 100 项 | 合同完整集合;继续执行日期、金额和状态规则 |
| `initialAccounts` | Body | Array/null | 否 | 最多 1 项 | 初始收款账户集合 |
| `duplicateConfirmToken` | Body | String/null | 否 | 最长 256,一次性使用 | 存在近似主体时按后端返回值二次确认 |
| `expectedUpdateTime` | Body | String/null | 否 | 创建草稿时不传 | 仅已有草稿提交审批时使用 |
#### 出参 `Result<SupplierWriteRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `supplierId` | String | 新建供应商 ID;必须按字符串保存和传输 |
| `supplierNo` | String | 本次行为变化:创建成功立即返回 `SUP{supplierId}`,非空且全生命周期稳定 |
| `status` | String | 新建草稿固定为 `DRAFT` |
| `onboardingStage` | String | 新建草稿固定为 `PROFILE_DRAFT` |
| `initialAccounts` | Array | 初始收款账户摘要;未提交时通常为空数组 |
| `updateTime` | String | 当前并发版本,格式 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
POST /admin/supplier/items/add
Authorization: Bearer <admin-token>
Content-Type: application/json
```
```json
{
"fullName": "示例草原旅行服务有限公司",
"shortName": "示例草原旅行",
"taxNo": "91350211M000100Y46",
"mainCooperation": "酒店与景区资源合作",
"types": [],
"contacts": [],
"qualifications": [],
"contracts": [],
"initialAccounts": []
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2094000000000000001",
"supplierNo": "SUP2094000000000000001",
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [],
"updateTime": "2026-08-27 16:24:30"
}
}
```
#### 空数据 / 降级响应
创建接口没有“成功但 data 为空”的降级分支:创建成功必须返回完整 `SupplierWriteRespVO`,失败则返回明确业务错误。编号的历史空数据兼容只出现在发布前遗留记录的列表或详情读取中;这类尚未补齐的记录仍可能出现:
```json
{
"supplierId": "2089000000000000001",
"supplierNo": null,
"status": "DRAFT"
}
```
管理端可为这种历史空值显示 `—`,但不得本地生成或回写编号。
#### 错误响应
请求缺少必填字段或字段不合法时,业务失败且不创建草稿:
```json
{
"code": 400,
"message": "统一社会信用代码不能为空",
"success": false,
"data": null
}
```
真实身份没有 `supplier:create` 写权限时:
```json
{
"code": 395002,
"message": "无供应商写入权限",
"success": false,
"data": null
}
```
#### 业务边界
- 外部请求必须经过 Gateway;未认证返回业务码 `401`,不能把该负向结果当作正向验收。
- 写操作仅允许既有授权角色并再次校验 `supplier:create`;普通 `ADMIN` 被拒绝且零写入。
- `supplierNo` 只在整个创建成功后返回;任何字段校验、权限、重复主体确认或持久化失败都不会留下可见的半成品编号。
- 创建成功后,列表、详情和更新响应必须返回同一 `supplierNo`。
- 客户端只消费后端响应,不能提交、覆盖、重算、截断或把编号转成数字。
- 业务失败可能仍使用 HTTP 200,必须同时判断响应体 `code`、`success` 和 `data`。
## 四、契约约束与正确调用方式
### 正确消费顺序
1. 提交创建请求并确认 `code=200`、`success=true`。
2. 将 `data.supplierId`、`data.supplierNo`、`data.updateTime` 全部按字符串保存。
3. 页面立即展示 `data.supplierNo`;刷新列表或详情时核对同一字段,不再等待审批。
4. 后续更新继续传最新 `expectedUpdateTime`,但不得把 `supplierNo` 放进请求体。
### ✅ 正确 / ❌ 错误行为对照
| 场景 | 正确行为 |
|---|---|
| ✅ 新草稿创建成功 | 直接显示响应 `supplierNo`,例如 `SUP2094000000000000001` |
| ✅ 列表或详情刷新 | 使用接口返回的同一编号,不做本地拼接 |
| ✅ 历史草稿仍为空 | 显示 `—` 并等待后端兼容补齐,不写回猜测值 |
| ❌ 本地生成编号 | 禁止用时间、计数器或客户端缓存生成 |
| ❌ 把编号当数字 | 禁止移除 `SUP`、转数值或做算术运算 |
| ❌ 删除后复用 | 禁止把已删除供应商的旧编号分配给新供应商 |
## 五、数据库行为
- 创建成功时,供应商草稿、正式编号、可选子项和创建审计作为一个业务整体生效;响应中的编号与后续读取一致。
- 创建失败时,供应商和编号都不产生可见结果,不返回部分成功。
- 更新、提交或审批不改变已生成编号。
- 草稿删除后不再出现在正常列表和详情中,但其编号不会重新分配。
- 本次不要求调用方执行数据迁移,也不改变现有请求字段。
## 六、边界行为
- 未认证:业务码 `401`,零写入。
- 无写权限:业务码 `395002`,零写入。
- 必填字段缺失、格式或组合不合法:业务码 `400`,零写入。
- 发现近似主体且未完成二次确认:业务码 `395007`,按响应中的一次性确认信息继续既有流程。
- 发布后新草稿:`supplierNo` 必为非空 `SUP{supplierId}`。
- 发布前历史空编号:读取时仍允许 `null`;审批通过后按兼容规则补齐。
- 更新、提交、审批、暂停、拉黑等后续操作:编号保持不变。
- 软删除后再创建:新供应商获得新的 `supplierId` 和新的 `supplierNo`,不复用旧编号。
## 六.5、枚举 / 数据字典
本次没有新增枚举或数据字典。`status`、`onboardingStage`、供应商类型、联系人角色、资质类型等继续沿用现有取值;编号格式不是字典值。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| 创建响应 `data.supplierNo` | 新草稿通常为 `null`,审批通过后才补齐 | 创建成功立即返回非空 `SUP{supplierId}` |
| `supplierId`、`status`、`onboardingStage`、`initialAccounts`、`updateTime` | 已存在 | 字段和类型不变 |
| 创建请求 | 不接受 `supplierNo` | 仍不接受 `supplierNo`,请求无新增字段 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 新建后展示编号 | 需要空值占位或等待审批 | 创建成功即可展示正式编号 |
| 列表、详情、更新回显 | 草稿期可能为空 | 对发布后新建草稿稳定返回创建时编号 |
| 提交与审批 | 审批通过时生成编号 | 保留创建时编号;只为历史空值兼容补齐 |
| 删除后再次创建 | 无明确前端口径 | 新供应商取得新编号,旧编号不复用 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。字段已存在,只把发布后新草稿的值从 `null` 收紧为稳定非空字符串。
- **前端是否必须同步上线**: 需要适配体验。旧客户端仍可运行,但会继续把已经可用的编号显示为空或等待审批。
- **前端 workaround 清理点**: 删除“草稿编号为空”“审批通过后再刷新编号”的新建流程兜底,创建成功后直接使用 `data.supplierNo`。
- **历史兼容**: 仍保留对发布前空编号记录的 `null` 展示兼容,前端不要把所有历史数据强断言为非空。
- **QA 重点**: 创建响应、立即列表/详情、更新后详情、未认证、ADMIN 越权、非法请求零写入、软删除后新编号不复用。
## 七、不影响范围
- **仅影响**: 管理后台供应商草稿创建成功后的编号生成时点和后续稳定回显。
- **零影响**:
- 创建请求字段、供应商类型可选语义、联系人/资质/合同/初始账户规则。
- Gateway 路由、认证方式、权限码和数据范围。
- 供应商生命周期状态机、审批级数、暂停、拉黑、归档和资源关系行为。
- 小程序、C 端、Web、H5、桌面端接口。
- 其他服务接口、缓存和消息契约。
## 八、测试环境已验证
- 本地自动化:供应商编号、审批兼容、完整回显、事务边界、权限和迁移契约定向测试 61 项通过;`hl-resource-service` 全量 2,157 项零失败、零错误,38 项仓库既有条件跳过;Gateway 路由审计 4 项通过。
- 部署:Deploy Panel API 任务 `e977bf7b` 终态 `success`;目标与实际提交均为 `66aed70a2c9919046b9e4362b9db3219858ec6e1`。
- 可用性采样:任务期 6 个有效采样均至少有 2 个运行进程、2 个健康启用实例,零观测不可用采样;该证据只证明采样点,不代表采样间绝对连续。
- 真实 Gateway:SUPER_ADMIN 创建草稿后,响应立即满足 `supplierNo=SUP{supplierId}`;列表、详情、更新响应保持同一编号;独立变更记录同时存在 CREATE/UPDATE 事实。
- 失败路径:未认证请求被 Gateway 拒绝;真实 ADMIN 创建返回 `395002`;非法税号返回 `400`;两类失败均验证零可见写入。TEST 未配置可用 FINANCE 身份,其角色等价性由服务端权限测试覆盖,未伪造身份。
- 删除与不复用:首个草稿经业务 API 软删除并确认列表、详情不可见;随后创建的新草稿获得不同编号。
- 清理:两条临时草稿均经业务 API 软删除并确认不可见,测试角色已恢复,会话已注销;未执行物理删除、缓存或 MQ 操作,仅保留正常的创建、更新、删除审计事实。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6274 | #6273 | 审批记录响应增加 `supplierNo` 字段 | ✅ 有效,本次收紧新草稿取值时点 |
| #6344 | #6343 | 新建和修改允许供应商类型为空 | ✅ 有效,请求语义不变 |
| #6517 | #6476 | 详情补齐地址备注并统一表单校验 | ✅ 有效,详情继续稳定回显 |
| #6531 | #6518 | 草稿创建即生成唯一供应商编号 | ✅ 最新 |
## 十、相关文档
- 关联 Issue:[#6518](https://git.1814.love:8443/wx/HL/issues/6518)
- 关联 PR:[#6531](https://git.1814.love:8443/wx/HL/pulls/6531)
- 历史编号响应契约:`changelogs-v2/2026-08/24_6273_供应商审批记录补充供应商编号-修改接口-管理后台.md`
- 管理端接入:创建成功立即读取并展示 `data.supplierNo`;历史空值仍保留 `—` 兼容,前端引用待回填。
## 撤回
1. 管理端先恢复“新草稿编号可能为空”的兼容展示,不再依赖创建响应立即有值。
2. 后端从最新 `dev-v3` 创建独立回退分支,revert #6518 合并提交 `66aed70a2c9919046b9e4362b9db3219858ec6e1`,验证后经独立 PR 合入并重新发布 `hl-resource-service`。
3. 无接口字段删除或请求结构回退;发布窗口内已生成的编号默认保留,不执行批量清空或复用。
4. 撤回后经 Gateway 复测创建响应可空、历史审批补齐、列表/详情/更新兼容、未认证与越权零写入。
5. 以新的 Changelog 提交标记本契约撤回,不改写已发布提交历史。
## 关联 / 联系人
### 链接
- **Issue**: [#6518](https://git.1814.love:8443/wx/HL/issues/6518)
- **PR**: [#6531](https://git.1814.love:8443/wx/HL/pulls/6531)
- **实现提交**: [`69665d72`](https://git.1814.love:8443/wx/HL/commit/69665d7249b5d269e56c36abbfeafbdbbbe72d45)
- **Merge commit**: [`66aed70a`](https://git.1814.love:8443/wx/HL/commit/66aed70a2c9919046b9e4362b9db3219858ec6e1)
### 联系人
- **后端负责人**: @lc
- **管理端负责人**: 待认领(`frontend_status: pending`)
@@ -0,0 +1,524 @@
---
schema: "hl-changelog/v2"
ticket: "6544"
title: "供应商合同独立登记"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "55a3a056"
target_release: "v2.1"
verified_at: "2026-08-29"
status_note: "PR #6559、#6591、#6606 已合并 dev-v3;hl-resource-service 已通过 Deploy Panel 任务 ca2fe4d7 部署最终提交 c579c87014f56452fea2fce5075b31ae6fd12a35。#6591 的写后回读、395052–395058、金额 scale 等值与草稿合同存在门禁均已进入 TEST。#6544 基线曾完成独立合同写链路实证;本次最终提交因运行时身份缺少 supplier:update,按用户确认冻结权限依赖的真实写复测,未配置权限、未发业务写请求,不把缺失权限记为通过。"
updated_at: "2026-08-29"
base: "dev-v3"
---
# 供应商管理:合同独立登记
供应商合同从注册聚合中拆出,改为独立登记、独立更新、独立软删除的维护边界。合同不再随供应商建档审批提交,审批通过与否都不影响合同登记;已登记合同在详情回显中继续随供应商返回。
## 一、背景
旧版把合同作为供应商注册聚合的一部分随审批走(#6397),审批候选 v2 起不再携带 contracts。为支持签约后可随时登记线下合同、按业务状态独立更新/作废,本次新增三个独立合同写接口:登记(add)、完整替换更新(update)、软删除(del)。合同不参与供应商生命周期状态机,不受建档/审批/归档状态推进约束;删除草稿供应商时若仍有未删除合同会被拒绝(需先独立删合同)。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 独立登记供应商合同 | POST | `/admin/supplier/items/{supplierId}/contracts/add` | 新增写接口 | 在既有供应商下登记一份线下合同,不进入建档审批 |
| 2 | 独立更新供应商合同 | PUT | `/admin/supplier/items/{supplierId}/contracts/{contractId}/update` | 新增写接口 | 带乐观版本整份替换合同全部业务字段 |
| 3 | 独立删除供应商合同 | DELETE | `/admin/supplier/items/{supplierId}/contracts/{contractId}/del` | 新增写接口 | 带乐观版本软删除,审计原因必填 |
## 三、接口详情
### 1. 独立登记供应商合同 `POST /admin/supplier/items/{supplierId}/contracts/add`
**VO**: `SupplierContractCreateReqVO` / `SupplierContractRespVO`
#### 使用场景
管理端在供应商详情「合同」区域新增一条线下合同。合同登记与供应商审批解耦,登记成功立即在详情合同列表随时间返回 `updateTime` 并发版本。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
| `contractName` | Body | String | 是 | 最长 500 字符 | 合同名称 |
| `contractNo` | Body | String | 否 | 最长 100 字符 | 合同编号;可空字符串 |
| `contractType` | Body | String | 是 | `FRAME` / `SINGLE_TRIP` / `PURCHASE` | 合同类型 |
| `signDate` | Body | String | 否 | `yyyy-MM-dd` | 合同签署日期 |
| `startDate` | Body | String | 是 | `yyyy-MM-dd` | 有效期开始日期 |
| `endDate` | Body | String | 是 | `yyyy-MM-dd`,不得早于 `startDate` | 有效期结束日期 |
| `amount` | Body | String | 否 | 非负,最多 10 位整数 2 位小数 | 合同金额;按字符串传输与比较 |
| `pricingMode` | Body | String | 否 | 最长 100 字符 | 计价方式说明 |
| `settleCycle` | Body | String | 否 | 最长 32 字符 | 结算周期 |
| `status` | Body | String | 是 | `DRAFT` / `ACTIVE` / `EXPIRED` | 可维护的合同状态 |
| `scanFileUrl` | Body | String | 否 | 最长 1000 字符 | 合同扫描件永久文件地址 |
| `remark` | Body | String | 否 | 最长 500 字符 | 合同备注 |
| `changeReason` | Body | String | 是 | 最长 500 字符 | 本次登记原因,写入供应商变更审计 |
#### 出参 `Result<SupplierContractRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `contractId` | String | 合同 ID(雪花,保证转字符串传输) |
| `contractName` | String | 合同名称 |
| `contractNo` | String/null | 合同编号 |
| `contractType` | String | 合同类型 |
| `signDate` | String/null | 合同签署日期 |
| `startDate` | String | 有效期开始日期 |
| `endDate` | String | 有效期结束日期 |
| `amount` | String | 合同金额(保证转字符串传输,保留两位小数场景由后端规范) |
| `pricingMode` | String/null | 计价方式 |
| `settleCycle` | String/null | 结算周期 |
| `status` | String | 合同状态;历史终止合同可能返回 `TERMINATED` |
| `scanFileUrl` | String/null | 合同扫描件地址 |
| `remark` | String/null | 合同备注 |
| `updateTime` | String | 当前并发版本,格式 `yyyy-MM-dd HH:mm:ss`;请原样用于下一次 update/del |
#### 请求示例
```http
POST /admin/supplier/items/2091381911266967553/contracts/add
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"contractName": "2026年度框架合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "线下签署后登记",
"changeReason": "线下签署后登记"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2093378327078154241",
"contractName": "2026年度框架合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "线下签署后登记",
"updateTime": "2026-08-29 00:41:00"
},
"traceId": null
}
```
#### 空数据 / 降级响应
登记成功后 `data` 恒为完整合同对象(不会返回 null)。金额为空时 `amount` 返回 null,前端按无金额展示,不要把 null 当 `"0.00"`:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2093378327078154241",
"contractName": "无金额合同",
"contractNo": null,
"contractType": "PURCHASE",
"signDate": null,
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"amount": null,
"pricingMode": null,
"settleCycle": null,
"status": "DRAFT",
"scanFileUrl": null,
"remark": null,
"updateTime": "2026-08-29 00:41:00"
}
}
```
#### 错误响应
供应商不存在或已软删除:
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
已归档供应商拒绝合同写入(不查询、不锁行、不落审计):
```json
{
"code": 395031,
"message": "已归档供应商仅允许查看",
"success": false,
"data": null
}
```
正常 HTTP 请求先经过 Bean Validation;缺必填字段或长度超限仍返回 `400` 和中文字段文案。`395052`–`395056` 是 Service/事务层防旁路校验的冻结错误码,不替代 Controller 的 `400`:
```json
{
"code": 400,
"message": "合同名称不能为空",
"success": false,
"data": null
}
```
#### 业务边界
- 权限:可信管理员身份 + `FINANCE`/`SUPER_ADMIN` 写角色 + `supplier:update` 平台权限;`ADMIN` 或任一平台权限门禁失败均不产生数据库写。
- 合同登记只写 `supplier_contract` 单表与一条 `CREATE` 审计事实,不推进供应商主体版本,不进审批。
- 幂等:同一管理员 + 同一供应商 + 同一规范化载荷 5 秒窗口内重复提交只执行一次;锁键按供应商分区。
- 事务内先锁供应商行(FOR UPDATE),改合同必在事务中完成,失败整体回滚。
- `supplierId`、`contractId`、`amount` 必须按字符串处理,禁止转 JavaScript Number。
### 2. 独立更新供应商合同 `PUT /admin/supplier/items/{supplierId}/contracts/{contractId}/update`
**VO**: `SupplierContractUpdateReqVO`(继承 Create 全字段 + `expectedUpdateTime`) / `SupplierContractRespVO`
#### 使用场景
管理端对已登记合同做整份替换更新。请求体必须携带目标合同当前 `updateTime` 作为乐观版本围栏;服务端版本不匹配时整体拒绝且不产生任何写。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 Number |
| `contractId` | Path | String | 是 | 正整数 ID 字符串 | 目标合同;不得转为 Number |
| *(Create 全部字段)* | Body | String | 同新增 | 同新增 | 整份替换所需全字段 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 目标合同当前并发版本,来自详情或上次写响应 |
#### 出参 `Result<SupplierContractRespVO>`
与新增相同,`updateTime` 返回新版本(严格晚于旧版本),请用该值继续后续围栏。
| 字段 | 类型 | 说明 |
|---|---|---|
| `contractId` | String | 合同 ID(字符串) |
| (其余字段) | - | 同新增出参,`updateTime` 为新并发版本 |
#### 请求示例
```http
PUT /admin/supplier/items/2091381911266967553/contracts/2093378327078154241/update
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"contractName": "2026年度框架合同(变更)",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-10-01",
"endDate": "2027-08-31",
"amount": "1500.00",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "续约调整",
"changeReason": "续约调整",
"expectedUpdateTime": "2026-08-29 00:41:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2093378327078154241",
"contractName": "2026年度框架合同(变更)",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-08-29",
"startDate": "2026-10-01",
"endDate": "2027-08-31",
"amount": "1500.00",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/1.pdf",
"remark": "续约调整",
"updateTime": "2026-08-29 00:41:01"
}
}
```
#### 空数据 / 降级响应
更新接口无空数据场景。请求体携带的整份字段(含空值)会覆盖原值:`contractNo`、`signDate`、`pricingMode`、`settleCycle`、`scanFileUrl`、`remark` 传 null 或空串会被清空;`amount` 传 null 会清除金额。清空能力只在更新接口生效,新增不触发。
#### 错误响应
版本不匹配/已过期(前端收到后请重新拉详情取最新 `updateTime` 再重试):
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"success": false,
"data": null
}
```
合同不存在、已删除或不属于路径供应商(跨供应商同码隐藏):
```json
{
"code": 395051,
"message": "供应商合同不存在",
"success": false,
"data": null
}
```
请求与当前内容完全一致(无实际变化,不推进版本):
```json
{
"code": 400,
"message": "未检测到实际变化",
"success": false,
"data": null
}
```
#### 业务边界
- 同一供应商下同一合同的 update 与 del 共享锁键,串行化后版本围栏在事务内校验。
- `payloadChanged` 用值比较(金额忽略小数位 scale),纯金额 `1200.5` 与 `1200.50` 等价,不会误判为变更。
- 更新推进合同自身 `updateTime`(秒级严格递增),不推进供应商主体版本。
- 5 秒幂等窗口按双 ID + 载荷摘要去重;锁键串行化同行写。
- 审计记录完整前后快照差异(`contracts` 字段组)。
### 3. 独立删除供应商合同 `DELETE /admin/supplier/items/{supplierId}/contracts/{contractId}/del`
**VO**: `SupplierContractDeleteReqVO` / 无返回体
#### 使用场景
对已登记合同作废软删除。删除后 `deletedAt` 写入当前时间,查询与详情不再返回;与同一合同 update 共享锁键和版本围栏。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `contractId` | Path | String | 是 | 正整数 ID 字符串 | 目标合同 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 目标合同当前并发版本 |
| `changeReason` | Body | String | 是 | 最长 500 字符 | 本次删除原因,写入审计 |
#### 出参 `Result<Void>`
成功时 `data` 为 null:
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | 恒为 `200` 表示成功 |
| `data` | null | 删除接口无返回体 |
#### 请求示例
```http
DELETE /admin/supplier/items/2091381911266967553/contracts/2093378327078154241/del
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"expectedUpdateTime": "2026-08-29 00:41:01",
"changeReason": "线下合同作废"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": null
}
```
#### 空数据 / 降级响应
删除成功恒返回上述结构;无空数据分支。删除后合同立即从查询/详情消失,如需保留展示请走更新改状态而非删除。
#### 错误响应
版本不匹配:
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"success": false,
"data": null
}
```
合同不存在或已删除:
```json
{
"code": 395051,
"message": "供应商合同不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 删除是软删除,不物理清理业务合同数据;删除与完整删除审计同事务提交或回滚。
- 删除草稿供应商时若存在未删除合同,档案删除会被拒绝(提示先独立删合同,错误码见「六、边界行为」)。
- 已删除合同的 `expectedUpdateTime` 不再有效,误传原版本返回合同不存在。
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | 调用 / 结果 |
|---|---|
| ✅ 新增后立即保存返回值 | 保存 `updateTime` 用于后续 update/del 版本围栏 |
| ✅ 更新时整份提交 | 传全字段;想清空的字段显式传 null 或空串 |
| ✅ 金额按字符串 | `"amount": "1200.50"`,`contractId`/`supplierId` 恒为字符串 |
| ✅ 并发被拒后重试 | 重新 GET 详情取最新 `updateTime`,再带新版本重试 |
| ❌ 把 ID/金额转 Number | 可能丢精度;必须按字符串传输与比较 |
| ❌ 只传变更字段 | update 是整份替换语义;缺字段会被清空 |
| ❌ 依赖审批候选带合同 | 审批候选 v2 起不携带 `contracts`;合同一律走独立接口维护 |
### 关键提示(当前 TEST 构建)
- 已登记的合同通过详情接口随供应商返回的 `contracts` 数组回显,前端不必重复维护列表状态。
- **B1 已修复并部署**:登记(add)插入后按合同 ID 回读数据库持久化实体,响应与 CREATE 审计使用同一真实秒级 `updateTime`;该值可直接用于下一次 update/del 版本围栏。
## 五、数据库行为
- 写操作仅影响 `supplier_contract` 一行(新增 insert / 更新 update / 删除软删),以及 `supplier_change_log` 一条对应合同审计事实(`fieldName=contract`,前后完整快照差异)。
- 三个写接口均不修改 `supplier_main`(供应商主体版本不变),不进审批流,不写缓存、MQ 或跨服务数据。
- 更新与删除仅在版本围栏通过后产生写;版本不匹配时不产生任何数据库副作用。
- 软删除使用统一 `deleted_at` 逻辑删除,物理行保留;查询与详情自动过滤。
## 六、边界行为
- 未登录/登录失效:业务码 `401`;角色或平台权限不足:`403`,不查询不写入。
- `supplierId`/`contractId` 非法(非数字、超长、<=0):`400`;供应商不存在或已软删除:`395001`。
- 已归档供应商:`395031`(写入一律拒绝)。
- 未知或非生命周期状态供应商:`395005`(状态机门禁先行失败)。
- 版本不匹配:`395014`;请求无实际变化:`395057`。
- 删除草稿供应商仍有未删除合同:`395058`「已有合同登记,请先独立删除合同」。
- Controller 的 Bean Validation/绑定失败仍返回 `400`;Service/事务层防旁路校验使用 `395052`–`395056`,其中缺失合同并发版本文案为「预期更新时间不能为空」。
## 六.5、枚举 / 数据字典
### `contractType`(合同类型)
**所属字段**: `SupplierContractCreateReqVO.contractType` / `SupplierContractRespVO.contractType` | **类型**: `String`
| 值 | 说明 |
|---|---|
| `FRAME` | 框架合同 |
| `SINGLE_TRIP` | 单团单合同 |
| `PURCHASE` | 采购合同 |
### `status`(合同状态)
**所属字段**: `SupplierContractCreateReqVO.status` / `SupplierContractRespVO.status` | **类型**: `String`
| 值 | 说明 |
|---|---|
| `DRAFT` | 草稿 |
| `ACTIVE` | 生效中(可维护) |
| `EXPIRED` | 已过期(可维护) |
| `TERMINATED` | 已终止(仅历史回显旧值,写接口不允许设置) |
## 七、不影响范围
- **仅新增**:三个独立合同写接口;合同登记与审批、状态机、归档完全解耦。
- **保持兼容**:供应商建档/更新/提交/审批/归档/暂停/拉黑/删除接口契约不变;详情回显 `contracts` 数组不变。
- **零影响**:审批候选 v2 起不含 `contracts`(v1 候选中的 contracts 字段被兼容忽略);不阻断既有审批流。
- **零影响**:收款账户、资质、资源关联、历史/审批流水等供应商子域。
- **零影响**:Gateway 路由(沿用 `/admin/supplier/**`)、数据库结构(无迁移)、Redis、MQ、Feign 契约。
## 八、测试环境已验证
- 本地自动化:合同命令/事务/校验器定向测试(SupplierContractTransactionServiceTest、SupplierContractValidatorTest、SupplierContractServiceTest、SupplierAggregateWriterTest、SupplierErrorCodeContractTest)51 项零失败;每日审查修复后 `hl-resource-service` 全量 2181 项零失败、零错误(38 项条件跳过)。
- 主 PR #6559、审查修复 PR #6591 与中文版本文案补充 PR #6606 均已合入 `dev-v3`;最终 Resource 合并提交为 `d7e455492648b085445691ccde4a13198d4cd6c6`。
- TEST 部署:Deploy Panel 任务 `ca2fe4d7` 成功,预期/实际提交均为 `c579c87014f56452fea2fce5075b31ae6fd12a35`;双进程、Nacos 两实例、6 个任务期采样和两类日志门禁通过,零观测不可用采样。
- 最终自动化:合同定向 73 项、供应商真实 MySQL Testcontainers 398 项、Resource 全量 2181 项均为 0 失败/0 错误;全量保留 38 项既有条件跳过。
- Gateway 边界:#6544 基线写链路已有真实 TEST 实证;最终部署回读身份为 `SUPER_ADMIN`,但当前平台权限不含 `supplier:update`。用户明确冻结该权限依赖的写复测,本轮没有配置/绕过权限,也没有发合同业务写请求;权限与事务成功/拒绝路径以最终自动化证据补充,不宣称运行时缺失权限已通过。
- 数据清理:本轮最终部署和冻结验收未创建供应商、合同、缓存、MQ 或审批合成数据;TEST 身份已登出。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|---|---|---|---|
| #6559 | #6544 | 独立合同登记三写接口 + 边界拆分 | ✅ 本次主功能 |
| #6591 | #6604 | 合同模块每日审查修复 7 项(写后读回/错误码/VO 文档/金额比较) | ✅ 已部署 |
| #6606 | #6604 | 合同并发版本校验中文文案与全局错误码审计修复 | ✅ 已部署 |
| #6519 | #6474 | 供应商变更/审批分离(前置演进) | ✅ 不受影响 |
## 十、相关文档
- 关联 Issue:[#6544](https://git.1814.love:8443/wx/HL/issues/6544)
- 关联 PR:[#6559](https://git.1814.love:8443/wx/HL/pulls/6559) / [#6591](https://git.1814.love:8443/wx/HL/pulls/6591) / [#6606](https://git.1814.love:8443/wx/HL/pulls/6606)
- 管理端接入:详情页「合同」区改为直接调 add/update/del 三接口;提交审批的候选不再携带 contracts。
## 撤回
1. 管理端停止请求三个 contract 写路径,并清除详情页合同编辑入口。
2. 从最新 `dev-v3` 建独立回退分支,按依赖逆序回退 `d7e455492648b085445691ccde4a13198d4cd6c6`、`9eb9c96c09975a3ccf4966a257cc77bdf9f743b9` 与 `be0fe8125197b759459c975d211319e10cad150c`,验证后经独立 PR 合入。
3. 仅需回退写接口时可先保留回显逻辑,只移除 add/update/del 路由与前端入口。
4. 使用 Deploy Panel 两阶段客户端仅滚动部署 `hl-resource-service`;本次无数据库、配置、Redis 或 MQ 恢复步骤。
5. 撤回后经 Gateway 验证三个写路径不可用、详情 `contracts` 回显按选定回退范围正常,并确认零写入。
6. 同步发布本 Changelog 的撤回说明;不得仅改文件名表达状态。
## 关联 / 联系人
### 链接
- **Issue**: [#6544](https://git.1814.love:8443/wx/HL/issues/6544)
- **PR**: [#6559](https://git.1814.love:8443/wx/HL/pulls/6559) / [#6591](https://git.1814.love:8443/wx/HL/pulls/6591) / [#6606](https://git.1814.love:8443/wx/HL/pulls/6606)
- **Merge commit**: [`be0fe8125`](https://git.1814.love:8443/wx/HL/commit/be0fe8125197b759459c975d211319e10cad150c) / [`9eb9c96c0`](https://git.1814.love:8443/wx/HL/commit/9eb9c96c09975a3ccf4966a257cc77bdf9f743b9) / [`d7e455492`](https://git.1814.love:8443/wx/HL/commit/d7e455492648b085445691ccde4a13198d4cd6c6)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,55 @@
---
schema: "hl-changelog/v2"
ticket: "6620"
title: "供应商编辑页主体证件号未绑定 tax_no"
consumer: "admin"
author: "lc(GIT)"
change_type: "前端缺陷"
backend_status: "not_required"
gateway_status: "not_required"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "9d5dcbab"
target_release: "v2.1"
verified_at: "2026-08-29"
status_note: "hl-admin 端实际绑定 formData.taxNo(非 socialCreditCode,该定位对端不符);经用户拍板采用「tax_no 回显、DRAFT 可改、非 DRAFT 锁定」折中,非 DRAFT 恒不携带防 395033,未按字面「保持可编辑」。契约库记 002 tax_no 脱敏与本条称完整值不一致,回填已加脱敏守卫兼容。"
updated_at: "2026-08-29"
base: "dev-v3"
---
# 供应商编辑页主体证件号未绑定 `tax_no`
后端契约没有变化。打开已有供应商时,详情响应已经返回完整 `tax_no`,管理端需在表单适配层消费该字段。
## 接口与字段
- 接口:`GET /admin/supplier/items/{supplierId}/basic-info/view`
- 响应字段:`data.tax_no`,类型为 `string`,值为完整主体证件号。
- 创建、更新请求字段仍为 `taxNo`。响应与请求字段命名不同,前端不得改读 `data.taxNo` 或用 `socialCreditCode` 代替响应字段。
## 前端处理要求
1. 详情加载后,将非空 `data.tax_no` 映射为“主体证件号”输入框的实际值,并保持可编辑。
2. 详情值必须覆盖表单初始空值;初始化或重置逻辑不得在映射后再次清空。
3. 只有接口实际返回空值时才显示空输入框;占位提示不能代替已有证件号。
4. 保存时继续按既有写契约提交 `taxNo`,不要新增或要求后端返回重复字段。
## 后端核验结论
- 响应 VO 将 Java 属性 `taxNo` 固定序列化为 JSON 字段 `tax_no`。
- 查询服务直接取供应商主体的 `taxNo` 写入响应,持久化读取兼容密文和历史明文。
- 供应商回显、查询、Controller 契约与聚合写入相关测试共 60 项通过,失败、错误和跳过均为 0。
- #6499 / PR #6505 已在 TEST 验证详情返回的 `tax_no` 与数据库原值一致;当前 `dev-v3` 已包含该实现。本条目不引入后端代码、接口、数据库、配置、Redis 或 MQ 变更。
## 只读前端定位
现有管理端表单显示字段绑定为 `socialCreditCode`,详情适配逻辑没有把后端 `tax_no` 映射到该输入框,因此页面会保留初始空值。该定位仅用于联调交接,本工单未修改前端源码。
## 撤回
如需撤回本联调通知,只需回退本 Changelog 文件;既有后端 `tax_no` 契约和运行数据不受影响。
## 关联 / 联系人
- Issue:[#6620](https://git.1814.love:8443/wx/HL/issues/6620)
- 后端联系人:@lc
@@ -0,0 +1,389 @@
---
schema: "hl-changelog/v2"
ticket: "6643"
title: "供应商编辑页地址营业执照与备注回显"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "bab3673b"
target_release: "v2.1"
verified_at: "2026-08-29"
status_note: "PR #6645 已合并 dev-v3;hl-resource-service 已通过 Deploy Panel 任务 954419f9 部署提交 d206d1d577a3e3f8cec228e73e139fcb36c00fd5。真实 TEST Gateway 已验证 address、licenseImageUrl、remark 原值回显、更新后回读、空地址保留、营业执照顶层字段权威读取、395014 零写入及测试草稿清理。"
updated_at: "2026-08-29"
base: "dev-v3"
---
# 🔧 供应商编辑页地址、营业执照与备注回显
供应商编辑页应直接使用详情根对象的 `address`、`licenseImageUrl`、`remark` 初始化表单。其中 `licenseImageUrl` 是营业执照上传控件的权威字段,不得再从 `qualifications[].imageUrl` 推导或覆盖。
本次没有新增接口路径、请求必填项、权限点或业务错误码。
## 一、关键变化
| 字段 | 修改前 | 修改后 |
|---|---|---|
| `data.address` | 已有详情字段,但编辑和空值保留规则缺少本次回归锁定 | 返回已保存地址;提交非空新值后可回读,更新时留空保持原值 |
| `data.licenseImageUrl` | 详情根对象没有稳定的营业执照权威回显,调用方可能从资质数组取值 | 详情根对象稳定返回营业执照;只读取顶层权威值,不再从资质数组反向派生 |
| `data.remark` | 已有详情字段,但编辑回显和更新规则缺少本次回归锁定 | 返回已保存内部备注;提交非空新值后可回读 |
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应修改 | 根对象稳定返回 `address`、`licenseImageUrl`、`remark` |
| 2 | 更新供应商资料 | PUT | `/admin/supplier/items/{supplierId}/update` | 行为修改 | 三字段按增量更新和并发版本规则保存;写后重新查询可得到新值 |
## 三、接口详情
### 1. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
管理端进入供应商详情或编辑页时获取完整表单快照。营业执照上传控件必须绑定根对象 `data.licenseImageUrl`;`qualifications[]` 继续用于独立资质列表,不是该控件的取值来源。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商;不得转为 JavaScript Number |
无 Query 参数,无请求体。
#### 出参 `Result<SupplierBasicInfoRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.address` | String/null | 注册地址或经营地址;未填写时为 `null` |
| `data.licenseImageUrl` | String/null | 营业执照影像权威地址;未填写时为 `null` |
| `data.remark` | String/null | 供应商主体内部备注;不等同于审批意见或变更原因 |
| `data.qualifications[].imageUrl` | String/null | 单项资质影像;与根对象营业执照字段分别读取 |
| `data.updateTime` | String | 主体并发版本,格式 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
GET /admin/supplier/items/2092800000000000001/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"supplierNo": "SUP2092800000000000001",
"fullName": "示例旅行服务有限公司",
"shortName": "示例旅行",
"tax_no": "91350211M000100Y46",
"legalRepresentative": "示例法人",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdNoMask": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": null,
"legalRepresentativeIdCardBackUrl": null,
"contactPhone": "13800138000",
"contactPhoneMask": "13800138000",
"establishDate": "2020-01-01",
"registeredCapital": "100万元",
"businessScope": "境内旅游服务",
"address": "福建省厦门市示例路 2 号",
"staffScale": "LT50",
"mainCooperation": "酒店与景区资源合作",
"licenseImageUrl": "https://files.example.com/supplier/business-license-new.jpg",
"remark": "地址与营业执照已复核",
"status": "DRAFT",
"creditLevel": "B",
"totalScore": null,
"types": [],
"primaryTypeCode": null,
"primaryTypeName": null,
"contacts": [],
"qualifications": [
{
"qualificationId": "2092800000000000011",
"qualType": "BUSINESS_LICENSE",
"qualTypeName": "营业执照",
"certNo": null,
"certNoMask": null,
"imageUrl": "https://files.example.com/supplier/business-license-new.jpg",
"expiryDate": null,
"permanentValid": true,
"daysUntilExpiry": null,
"validityStatus": "VALID",
"validityStatusName": "有效",
"isRequired": false,
"expired": false,
"updateTime": "2026-08-29 14:40:01"
}
],
"contracts": [],
"updateTime": "2026-08-29 14:40:02"
}
}
```
#### 空数据 / 降级响应
未填写三字段时返回明确的 `null`,前端显示空控件即可,不要用资质数组补写营业执照:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"address": null,
"licenseImageUrl": null,
"remark": null,
"qualifications": [],
"updateTime": "2026-08-29 14:40:02"
}
}
```
#### 错误响应
供应商不存在、已删除或超出可见范围:
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 要求可信管理身份、可读角色、`supplier:view` 平台权限和既有数据范围。
- 根对象 `licenseImageUrl` 是编辑页营业执照的唯一权威回显;即使 `qualifications[]` 中同类型影像不同,也不得覆盖根对象值。
- 历史记录没有可用营业执照时返回 `null`,不猜测默认图片。
- 本接口只读,不推进版本、不触发审批或其他业务副作用。
### 2. 更新供应商资料 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO / SupplierWriteRespVO`
#### 使用场景
管理端保存供应商编辑表单。请求只发送实际修改字段,并携带详情中的最新 `updateTime` 和本次 `changeReason`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `address` | Body | String | 否 | 最长 500 字符 | 非空时更新;省略、`null`、空串或纯空格时保留原值 |
| `licenseImageUrl` | Body | String | 否 | 最长 500 字符 | 省略时保留;传非空值时更新权威营业执照;传空串时清空 |
| `remark` | Body | String | 否 | - | 非空时更新;省略、`null`、空串或纯空格时保留原值 |
| `changeReason` | Body | String | 是 | 去空白后非空,最长 500 字符 | 本次资料变更原因 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 详情最新主体并发版本 |
其他既有可选字段和集合快照规则保持不变。
#### 出参 `Result<SupplierWriteRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 供应商 ID |
| `data.supplierNo` | String | 供应商业务编号 |
| `data.status` | String | 当前生命周期状态 |
| `data.onboardingStage` | String | 当前注册阶段 |
| `data.initialAccounts` | Array | 初始收款账户摘要 |
| `data.updateTime` | String | 写入后的新主体并发版本 |
#### 请求示例
```http
PUT /admin/supplier/items/2092800000000000001/update
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"address": "福建省厦门市示例路 2 号",
"licenseImageUrl": "https://files.example.com/supplier/business-license-new.jpg",
"remark": "地址与营业执照已复核",
"changeReason": "更新供应商编辑资料",
"expectedUpdateTime": "2026-08-29 14:40:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2092800000000000001",
"supplierNo": "SUP2092800000000000001",
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [],
"updateTime": "2026-08-29 14:40:02"
}
}
```
写响应不重复返回 `address`、`licenseImageUrl`、`remark`。前端需要完整表单时,应使用新 `updateTime` 重新查询详情。
#### 空数据 / 降级响应
成功更新时 `data` 恒为写入结果对象,不返回 `null`。当前请求没有实际变化时不会伪造成功或推进版本,而是返回业务失败:
```json
{
"code": 400,
"message": "未检测到实际变化",
"success": false,
"data": null
}
```
#### 错误响应
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"success": false,
"data": null
}
```
收到 `395014` 后先重新获取详情,由用户确认后再提交;不得用旧表单自动覆盖。
#### 业务边界
- 要求可信管理身份、`FINANCE` 或 `SUPER_ADMIN` 写角色及 `supplier:update` 平台权限。
- 成功写入后推进主体 `updateTime`;权限、参数、状态或并发失败时三字段及版本均不变化。
- 顶层 `licenseImageUrl` 更新时会继续兼容同步营业执照资质影像;反向只修改 `qualifications[].imageUrl` 不会改写顶层权威字段。
- `remark` 是主体内部备注;`changeReason` 是本次变更审计原因,两者不得互相替代。
- 统一响应可能以 HTTP 200 承载业务失败,调用方必须同时判断 `code`、`success`、`message` 和 `data`。
## 四、契约约束与正确调用方式
### 编辑页初始化
```text
地址输入框 ← data.address
营业执照上传控件 ← data.licenseImageUrl
内部备注输入框 ← data.remark
并发版本 ← data.updateTime
```
不得把 `qualifications.find(item => item.qualType === "BUSINESS_LICENSE").imageUrl` 作为营业执照上传控件的回显值或顶层字段兜底。
### 更新规则对照
| 场景 | 请求 | 结果 |
|---|---|---|
| 更新三个字段 | 发送三个非空新值 + 原因 + 最新版本 | 更新成功;重新查询返回三个新值 |
| 地址留空,修改备注 | `address: " "`,`remark` 为新值 | 地址保持原值,备注更新 |
| 省略营业执照 | 不发送 `licenseImageUrl` | 顶层营业执照保持原值 |
| 清空营业执照 | `licenseImageUrl: ""` | 顶层营业执照清空,兼容影像同步清空 |
| 只改资质数组影像 | 发送 `qualifications[]`,不发送顶层字段 | 资质影像独立变化,顶层营业执照保持原值 |
| 使用旧版本 | 发送过期 `expectedUpdateTime` | 返回 `395014`,零写入 |
## 五、数据库行为
本节只描述接口可观察结果,不要求前端感知存储结构:
- 三字段更新成功后,再次查询详情返回新值,且主体 `updateTime` 推进。
- `address` 或 `remark` 留空时保存其他字段,重新查询仍返回原地址或原备注。
- 顶层 `licenseImageUrl` 更新成功后,详情根对象与兼容营业执照资质影像均返回新值。
- 只更新资质数组影像时,详情根对象 `licenseImageUrl` 保持原值。
- 权限、参数、状态或 `395014` 并发失败时,三字段和主体版本均不变化。
- 部署时已对可识别的历史营业执照影像完成一次性兼容初始化;之后详情根对象不再从资质数组动态派生。
## 六、边界行为
- 未登录或 Token 无效由 Gateway 拒绝,不产生业务写入。
- 无 `supplier:view` 时详情读取失败;无 `supplier:update` 或不属于允许写角色时更新返回 `395002`,零写入。
- 供应商不存在、已删除或不可见时返回 `395001`。
- `address` 或 `licenseImageUrl` 超过各自长度上限时返回 `400`,零写入。
- 缺少 `changeReason`、缺少 `expectedUpdateTime` 或没有实际变化时返回 `400`,零写入。
- 版本过期返回 `395014`;客户端必须刷新详情,不能自动覆盖。
- `ARCHIVED` 等不可修改状态继续由既有状态门禁拒绝。
- 业务失败可能仍使用 HTTP 200,客户端必须检查统一响应体。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| `data.address` | 字段已存在,但本次编辑回归未锁定 | 原值、更新值及留空保留规则均已锁定 |
| `data.licenseImageUrl` | 根对象没有稳定权威回显 | 根对象返回可空权威值,不从 `qualifications[]` 反向派生 |
| `data.remark` | 字段已存在,但本次编辑回归未锁定 | 原值和更新值均可稳定回读 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 编辑页初始化营业执照 | 可能从资质数组搜索影像 | 固定读取根对象 `licenseImageUrl` |
| 更新营业执照 | 根对象回读来源不稳定 | 提交顶层字段后,写后查询返回同一新值 |
| 单独修改资质影像 | 可能被误当成主体营业执照 | 不改变根对象营业执照权威值 |
## 六.7、影响评估
- **是否破坏向后兼容**:否。详情根对象增加可空字段并固定既有字段行为;旧客户端可忽略未知字段。
- **前端是否必须同步上线**:需要。营业执照上传控件必须改为读取并提交顶层 `licenseImageUrl`;地址和备注继续直接绑定根对象字段。
- **前端 workaround 清理点**:删除从 `qualifications[]` 搜索营业执照影像并覆盖表单值的逻辑。
- **QA 重点**:已有值回显、三字段更新后刷新、地址留空保留、资质影像与顶层营业执照相互独立、旧版本失败零写入。
## 七、不影响范围
- **仅影响**:管理后台供应商详情与编辑表单的地址、营业执照和内部备注。
- **零影响**:
- 接口路径、Gateway 路由和认证级别。
- 供应商既有角色、平台权限和数据范围。
- 生命周期状态机、审批、合同、收款账户和资源关系。
- 既有业务错误码、Redis、MQ、Feign 和其他服务。
- 小程序、C 端、Web、H5 和桌面端接口。
## 八、测试环境已验证
- 本地:供应商定向 62 项通过;`hl-resource-service` Reactor 全量 2,186 项通过,0 失败、0 错误,38 项既有条件跳过。
- 合并:后端 PR [#6645](https://git.1814.love:8443/wx/HL/pulls/6645) 已合并,合并提交为 `d206d1d577a3e3f8cec228e73e139fcb36c00fd5`。
- 部署:Deploy Panel API 任务 `954419f9` 成功,TEST 目标与实际提交均为上述合并提交;任务期 6 个采样均至少保持 2 个进程与 2 个健康启用实例匹配,0 个不可用采样。
- 真实 Gateway:唯一测试草稿依次验证原值回显、资质影像独立变化时顶层营业执照不变、三字段更新后回读、空地址保留、旧版本 `395014` 零写入及未认证拒绝。
- 清理:测试草稿已通过业务删除接口软删除并确认详情不可查询;仅保留系统规定的脱敏业务审计事实。
## 九、撤回
如后端撤回本次契约,前端在回退部署前停止依赖顶层 `licenseImageUrl`,并以同步发布的撤回 Changelog 为准;不得自行恢复未冻结的资质数组反向覆盖逻辑。地址与备注仍按既有 #6476 契约处理。
## 十、相关文档
- 关联 Issue:[#6643](https://git.1814.love:8443/wx/HL/issues/6643)
- 关联 PR:[#6645](https://git.1814.love:8443/wx/HL/pulls/6645)
- 既有地址与备注契约:[#6476](./27_6476_供应商详情补齐地址备注并统一表单校验-修改接口-管理后台.md)
- 当前前端状态:已消费(`frontend_status: verified`)。前端本就从顶层控件读执照、从未从资质数组推导;本次补齐编辑态 `licenseImageUrl` 顶层权威回显(快照比对:改携带新值/清空携带空串/未动不携带)。address/remark 沿用既有 #6476 语义(remark 回填快照比对、address 级联不回显),与本条一致未改动。
## 关联 / 联系人
### 链接
- **Issue**: [#6643](https://git.1814.love:8443/wx/HL/issues/6643)
- **PR**: [#6645](https://git.1814.love:8443/wx/HL/pulls/6645)
- **Merge commit**: [`d206d1d5`](https://git.1814.love:8443/wx/HL/commit/d206d1d577a3e3f8cec228e73e139fcb36c00fd5)
- **既有地址/备注契约**: [#6476](./27_6476_供应商详情补齐地址备注并统一表单校验-修改接口-管理后台.md)
### 联系人
- **后端负责人**: @lc
- **管理端负责人**: 待认领(`frontend_status: pending`)
@@ -0,0 +1,389 @@
---
schema: "hl-changelog/v2"
ticket: "6654"
title: "供应商合同字段可空与编辑信息入口"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "5fe4e6fa"
target_release: "v2.1"
verified_at: "2026-08-29"
status_note: "PR #6665 已合并 dev-v3;hl-resource-service 已通过 Deploy Panel 任务 3dde08b9 部署提交 65ac86e9312b22dd2fc7313049a693fdb0ccad59。真实 TEST Gateway 已验证合同业务字段全空登记、补全、清空、删除,changeReason 必填失败零写入,expectedUpdateTime 省略成功及过期值拒绝;测试草稿已清理。合同与结算 table 的页面顺序和消费映射待前端处理。"
updated_at: "2026-08-29"
base: "dev-v3"
---
# 供应商合同字段可空与编辑信息入口
供应商合同继续走独立登记接口,不并入供应商档案聚合。合同的 12 个业务字段现在全部可空,`changeReason` 继续必填;合同更新、删除的 `expectedUpdateTime` 改为可选,但一旦提供,过期版本仍会被拒绝。
管理端新建供应商时可完全不登记合同,取得 `supplierId` 后再补录;编辑页通过基本信息详情读取合同,通过既有账户接口读取和维护结算信息。页面按“资质证照 → 合同信息 → 结算信息”排列属于前端消费工作,当前状态为待前端处理。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 独立登记供应商合同 | POST | `/admin/supplier/items/{supplierId}/contracts/add` | 修改请求 | 12 个合同业务字段全部可空,`changeReason` 仍必填 |
| 2 | 独立更新供应商合同 | PUT | `/admin/supplier/items/{supplierId}/contracts/{contractId}/update` | 修改请求 | 合同业务字段与新增一致且可清空,`expectedUpdateTime` 可选 |
| 3 | 独立删除供应商合同 | DELETE | `/admin/supplier/items/{supplierId}/contracts/{contractId}/del` | 修改请求 | `expectedUpdateTime` 可选,`changeReason` 仍必填 |
| 4 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 修改响应语义 | `contracts[]` 的全部业务字段允许返回 `null` |
## 三、接口详情
### 1. 独立登记供应商合同 `POST /admin/supplier/items/{supplierId}/contracts/add`
**VO**: `SupplierContractCreateReqVO / SupplierContractRespVO`
#### 使用场景
供应商已经创建但合同资料尚未齐全时,可先登记一条纯合同记录,之后再补充。若暂时不需要合同记录,供应商新建请求直接省略废弃的 `contracts` 字段即可。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `contractName` | Body | String | 否 | 最长 500 字符 | 合同名称 |
| `contractNo` | Body | String | 否 | 最长 100 字符 | 合同编号 |
| `contractType` | Body | String | 否 | `FRAME` / `SINGLE_TRIP` / `PURCHASE` | 合同类型 |
| `signDate` | Body | String | 否 | `yyyy-MM-dd` | 签署日期 |
| `startDate` | Body | String | 否 | `yyyy-MM-dd` | 有效期开始;仅与同时提供的结束日期做区间校验 |
| `endDate` | Body | String | 否 | `yyyy-MM-dd` | 有效期结束;两端都提供时不得早于开始日期 |
| `amount` | Body | String | 否 | 非负,最多 10 位整数和 2 位小数 | 合同金额 |
| `pricingMode` | Body | String | 否 | 最长 100 字符 | 计价方式 |
| `settleCycle` | Body | String | 否 | 最长 32 字符 | 结算周期说明 |
| `status` | Body | String | 否 | `DRAFT` / `ACTIVE` / `EXPIRED` | 合同状态 |
| `scanFileUrl` | Body | String | 否 | 最长 1000 字符 | 扫描件永久地址 |
| `remark` | Body | String | 否 | 最长 500 字符 | 合同备注 |
| `changeReason` | Body | String | 是 | 去空白后非空,最长 500 字符 | 审计原因,不属于可空业务字段 |
#### 出参 `Result<SupplierContractRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.contractId` | String | 新合同雪花 ID |
| `data.contractName` 等 12 个业务字段 | 对应类型/null | 未填写的字段返回 `null` |
| `data.updateTime` | String | 服务端合同版本,格式 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```json
{
"changeReason": "合同资料暂缺,先登记记录"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2094000000000000001",
"contractName": null,
"contractNo": null,
"contractType": null,
"signDate": null,
"startDate": null,
"endDate": null,
"amount": null,
"pricingMode": null,
"settleCycle": null,
"status": null,
"scanFileUrl": null,
"remark": null,
"updateTime": "2026-08-29 16:10:00"
}
}
```
#### 空数据 / 降级响应
请求体除 `changeReason` 外可以不包含任何字段;成功后返回合同对象,业务字段全部为 `null`。不要把 `null` 自动替换为默认合同类型、状态、日期或金额。
#### 错误响应
缺少审计原因时失败且不新增合同:
```json
{ "code": 400, "message": "变更原因不能为空", "success": false, "data": null }
```
#### 业务边界
- 要求可信管理员、`FINANCE`/`SUPER_ADMIN` 写角色和 `supplier:update` 平台权限。
- 合同登记不进入供应商审批,不推进供应商主体 `updateTime`。
- `POST /admin/supplier/items/add` 和供应商资料更新中的废弃 `contracts` 字段仍被忽略;前端必须先获得 `supplierId`,再按需调用本接口。
### 2. 独立更新供应商合同 `PUT /admin/supplier/items/{supplierId}/contracts/{contractId}/update`
**VO**: `SupplierContractUpdateReqVO / SupplierContractRespVO`
#### 使用场景
补齐、修改或清空一条已登记合同。该接口执行完整替换:未传或传 `null` 的业务字段会保存为 `null`,不是“保持原值”。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `contractId` | Path | String | 是 | 正整数 ID 字符串 | 目标合同 |
| 新增接口的 12 个业务字段 | Body | 对应类型 | 否 | 与新增一致 | 完整替换;省略即清空对应字段 |
| `changeReason` | Body | String | 是 | 去空白后非空,最长 500 字符 | 审计原因 |
| `expectedUpdateTime` | Body | String | 否 | `yyyy-MM-dd HH:mm:ss` | 提供时必须等于当前合同版本;省略时由服务端锁串行更新 |
#### 出参 `Result<SupplierContractRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.contractId` | String | 合同雪花 ID |
| `data.contractName` 等 12 个业务字段 | 对应类型/null | 完整替换后的值,允许为 `null` |
| `data.updateTime` | String | 更新后的合同版本 |
#### 请求示例
```json
{
"contractName": "2026 年度框架合同",
"contractType": "FRAME",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"status": "ACTIVE",
"changeReason": "补齐已签署合同"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2094000000000000001",
"contractName": "2026 年度框架合同",
"contractType": "FRAME",
"startDate": "2026-09-01",
"endDate": "2027-08-31",
"status": "ACTIVE",
"updateTime": "2026-08-29 16:10:01"
}
}
```
#### 空数据 / 降级响应
仅发送 `changeReason` 可把 12 个业务字段全部清空。若替换后的业务载荷与当前记录完全相同,返回“未检测到合同实际变化”,不会伪造新版本。
#### 错误响应
提供过期版本时继续失败且零写入:
```json
{ "code": 395014, "message": "数据已被修改,请刷新后重试", "success": false, "data": null }
```
#### 业务边界
- `expectedUpdateTime` 省略不等于关闭并发保护;分布式合同锁和数据库行锁仍串行化同一合同写入。
- 客户端若选择发送版本,必须使用最近一次详情或写响应中的 `updateTime`。
- `changeReason` 始终必填,失败时合同和审计均不写入。
### 3. 独立删除供应商合同 `DELETE /admin/supplier/items/{supplierId}/contracts/{contractId}/del`
**VO**: `SupplierContractDeleteReqVO / Void`
#### 使用场景
删除不再保留的合同登记。删除为软删除,并记录完整审计原因。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `contractId` | Path | String | 是 | 正整数 ID 字符串 | 目标合同 |
| `expectedUpdateTime` | Body | String | 否 | `yyyy-MM-dd HH:mm:ss` | 提供时执行版本匹配 |
| `changeReason` | Body | String | 是 | 去空白后非空,最长 500 字符 | 删除审计原因 |
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | 成功时为 `200` |
| `success` | Boolean | 成功时为 `true` |
| `data` | null | 删除成功不返回业务对象 |
#### 请求示例
```json
{
"changeReason": "合同登记作废"
}
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "success": true, "data": null }
```
#### 空数据 / 降级响应
`expectedUpdateTime` 可省略,但请求体不能省略,且必须包含非空 `changeReason`。
#### 错误响应
```json
{ "code": 400, "message": "变更原因不能为空", "success": false, "data": null }
```
#### 业务边界
- 提供的过期 `expectedUpdateTime` 仍返回 `395014`,不删除、不写审计。
- 删除成功后基本信息详情的 `contracts[]` 不再返回该记录。
- 权限、锁、幂等、审计和软删除边界均保持原有实现。
### 4. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
进入供应商编辑页时读取主体、资质和合同。前端将 `data.contracts` 绑定到“合同信息”table。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
无 Query 参数,无请求体。
#### 出参 `Result<SupplierBasicInfoRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.contracts` | Array | 当前未软删除合同,按 `contractId` 升序 |
| `data.contracts[].contractId` | String | 合同雪花 ID |
| `data.contracts[]` 的 12 个业务字段 | 对应类型/null | 合同登记未填写时返回 `null` |
| `data.contracts[].updateTime` | String | 合同当前版本 |
#### 请求示例
```http
GET /admin/supplier/items/2094000000000000000/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2094000000000000000",
"contracts": [
{
"contractId": "2094000000000000001",
"contractName": null,
"contractType": null,
"startDate": null,
"endDate": null,
"status": null,
"updateTime": "2026-08-29 16:10:00"
}
]
}
}
```
#### 空数据 / 降级响应
没有合同登记时 `data.contracts` 返回空数组;单份合同没有填写的业务字段返回 `null`,二者含义不同。
#### 错误响应
```json
{ "code": 395001, "message": "供应商不存在", "success": false, "data": null }
```
#### 业务边界
- 只读接口要求可信管理员、可读角色和 `supplier:view` 平台权限。
- 查询不推进主体或合同版本,不触发审批和写副作用。
- 合同 table 的新增、编辑、删除分别调用前三个独立写接口,不把 `contracts` 回传给供应商聚合更新接口。
## 四、契约约束与正确调用方式
- 新建供应商可完全省略合同;若需登记,先调用 `POST /admin/supplier/items/add` 获取字符串 `supplierId`,再调用合同新增接口。
- 编辑页合同读取使用 `GET /admin/supplier/items/{supplierId}/basic-info/view` 的 `data.contracts`。
- 编辑页结算读取继续使用 `GET /admin/supplier/items/{supplierId}/account-info/list`;新增账户继续使用 `POST /admin/supplier/items/{supplierId}/bank-accounts/add`,其审批和主体状态门禁不变。
- 新建的 `initialAccounts[]` 与独立账户新增的 `accounts[]` 继续复用同一 `SupplierBankAccountReqVO`,可填写项一致:`accountType`、`bankName`、`bankBranch`、`accountNo`、`proofFileUrls`、`settleMode`、`accountPeriod`、`invoiceType`、`taxRate`。新建供应商最多携带 1 项初始账户。
- `supplierId`、`contractId`、`accountId` 和 `amount` 按字符串处理,禁止转为 JavaScript Number。
## 五、数据库行为
- Flyway migration `V20260829_002` 将 `supplier_contract.contract_name`、`contract_type`、`start_date`、`end_date`、`status` 从 `NOT NULL` 调整为可空。
- 合同创建、更新、删除继续在本服务 schema 内完成;审计与业务写同事务提交或回滚。
- 没有跨 schema 写入,没有新增 Redis、MQ、配置或路由变更。
## 六、边界行为
- 业务字段为空不等于审计字段可空:合同新增、更新、删除均必须有 `changeReason`。
- `expectedUpdateTime` 仅在合同更新和删除中可省略;提供时仍执行秒级版本比较。
- 合同日期只在 `startDate` 与 `endDate` 同时存在时校验先后顺序。
- 合同枚举字段为空时不校验;非空时仍只接受既有枚举值。
- 账户查询和新增的既有状态、审批、权限及敏感附件读取规则未改变。
## 六.6、修改前后对比
| 项目 | 修改前 | 修改后 |
|---|---|---|
| 合同名称、类型、开始日、结束日、状态 | 必填 | 可空 |
| 其余 7 个合同业务字段 | 可空 | 仍可空 |
| `changeReason` | 新增、更新、删除均必填 | 继续必填 |
| `expectedUpdateTime` | 更新、删除必填 | 更新、删除可选;提供过期值仍拒绝 |
| 结算信息字段模型 | 新建与独立账户新增复用同一 VO | 不变,编辑页继续复用既有账户接口 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否;原先完整载荷继续有效,旧客户端继续发送版本也有效。
- **前端是否必须同步上线**: 是;需要增加合同/结算 table、允许合同业务控件为空,并保留 `changeReason` 必填。
- **前端 workaround 清理点**: 删除合同业务字段的前端强制必填;不要删除 `changeReason` 校验。
## 七、不影响范围
- 不修改供应商新建、更新、提交请求中的废弃 `contracts` 聚合字段语义。
- 不修改结算账户字段、审批、状态机、默认账户、权限或接口路径。
- 不修改供应商主体的 `changeReason`、`expectedUpdateTime` 必填规则。
- 不新增业务错误码、Gateway 路由、Redis、MQ 或 Nacos 配置。
## 八、测试环境已验证
- 合同信息整体省略不影响供应商草稿创建;空业务字段合同可登记并由详情读回。
- 合同可在不传 `expectedUpdateTime` 时补全、清空和删除;传入过期版本返回并发失败且零写入。
- 新增、更新、删除缺少 `changeReason` 均失败且零写入。
- 编辑供应商后合同和初始结算账户摘要保持;结算读取入口可正常访问。
- TEST 验收产生的供应商草稿、合同和初始账户已软删除,并通过详情与结算入口读回确认不可见。
## 十、相关文档
- Issue: [#6654](https://git.1814.love:8443/wx/HL/issues/6654)
- PR: [#6665](https://git.1814.love:8443/wx/HL/pulls/6665)
- 合并提交: `65ac86e9312b22dd2fc7313049a693fdb0ccad59`
## 关联 / 联系人
- **后端负责人**: @lc
- **前端状态**: 已消费(`frontend_status: verified`)。页面「资质→合同→结算」排列现状已符(资质/合同在基本信息区、结算独立账户 Tab),DetailModal 合同表格对全 null 已 EMPTY/renderStatusTag 兜底安全;实质改动在 SupplierContractEditModal——12 业务字段全可空(去 contractName/contractType/status/startDate 必填、status 不再默认 DRAFT)、日期仅两端同填校验、expectedUpdateTime 编辑仅拿到版本才携带,changeReason 仍必填。
@@ -0,0 +1,359 @@
---
schema: "hl-changelog/v2"
ticket: "6669"
title: "供应商草稿结算信息编辑与可选字段清空"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "6678b97f"
target_release: "v2.1"
verified_at: "2026-08-29"
status_note: "#6669 与补充工单 #6676 已合并 dev-v3;最终提交 af5ea05df3d7490c97e6f4329c145a4014396bee 已由 Deploy Panel 任务 9be3a694 部署 TEST。真实 Gateway 已验证草稿结算创建、回读、修改、六个可选字段清空、整项清空、并发失败零写入和数据清理。当前状态:待前端处理。"
updated_at: "2026-08-29"
base: "dev-v3"
---
# 供应商草稿结算信息编辑与可选字段清空
供应商编辑接口新增 `initialAccounts` 完整快照。新建时登记的初始结算信息现在可在草稿编辑页读回、修改或清空;`changeReason` 和 `expectedUpdateTime` 继续必填。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 编辑供应商 | PUT | `/admin/supplier/items/{supplierId}/update` | 修改请求与响应 | 新增可选 `initialAccounts` 完整快照,仅草稿态可维护 |
| 2 | 查询供应商账户列表 | GET | `/admin/supplier/items/{supplierId}/account-info/list` | 修改响应语义 | 草稿供应商可读回其 `DRAFT` 初始账户 |
| 3 | 查询收款账户详情 | GET | `/admin/supplier/bank-accounts/{accountId}/view` | 修改响应语义 | 所属供应商为草稿时可读回 `DRAFT` 账户详情 |
## 三、接口详情
### 1. 编辑供应商 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO / SupplierWriteRespVO`
#### 使用场景
在供应商草稿编辑页维护与新建供应商相同的一项初始结算信息。省略 `initialAccounts` 不修改结算信息,空数组清空,1 项执行完整替换。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `initialAccounts` | Body | Array | 否 | 最多 1 项 | 省略不修改;`[]` 清空;1 项完整替换 |
| `initialAccounts[].accountType` | Body | String | 项内是 | `CORPORATE` / `PERSONAL` | 账户类型 |
| `initialAccounts[].bankName` | Body | String | 项内是 | 最长 500 字符 | 开户银行 |
| `initialAccounts[].accountNo` | Body | String | 项内是 | 规范化后 8 至 32 位数字 | 收款账号 |
| `initialAccounts[].bankBranch` | Body | String/null | 否 | 最长 500 字符 | 省略或 `null` 可清空 |
| `initialAccounts[].proofFileUrls` | Body | Array/null | 否 | 最多 20 项 HTTPS 地址 | 省略、`null` 或 `[]` 可清空 |
| `initialAccounts[].settleMode` | Body | String/null | 否 | `PREPAY` / `MONTHLY` / `SINGLE` | 可清空 |
| `initialAccounts[].accountPeriod` | Body | String/null | 否 | 最长 50 字符 | 仅月结时填写,可清空 |
| `initialAccounts[].invoiceType` | Body | String/null | 否 | `SPECIAL` / `NORMAL` / `NONE` | 可清空 |
| `initialAccounts[].taxRate` | Body | String/null | 否 | 0% 至 100%,最多两位小数 | 可清空 |
| `changeReason` | Body | String | 是 | 去空白后非空,最长 500 字符 | 审计原因,继续必填 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 供应商主体并发版本,继续必填 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 供应商雪花 ID |
| `data.status` | String | 当前为 `DRAFT` |
| `data.initialAccounts` | Array | 保存后的 0 或 1 项账户摘要 |
| `data.updateTime` | String | 新的供应商并发版本 |
雪花 ID 均按字符串处理。
#### 请求示例
```json
{
"changeReason": "维护草稿结算信息",
"expectedUpdateTime": "2026-08-29 17:01:00",
"initialAccounts": [
{
"accountType": "PERSONAL",
"bankName": "示例银行",
"accountNo": "6222000012345678"
}
]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2094000000000000000",
"status": "DRAFT",
"initialAccounts": [
{
"accountId": "2094000000000000001",
"accountType": "PERSONAL",
"bankName": "示例银行",
"bankBranch": null,
"accountNo": "6222000012345678",
"settleMode": null,
"accountPeriod": null,
"invoiceType": null,
"taxRate": null
}
],
"updateTime": "2026-08-29 17:01:01"
}
}
```
#### 空数据 / 降级响应
- 省略 `initialAccounts`:不修改现有初始账户。
- 传 `initialAccounts: []`:软删除草稿初始账户,写响应返回空数组。
- 1 项中省略 6 个可选字段:`bankBranch`、`proofFileUrls`、`settleMode`、`accountPeriod`、`invoiceType`、`taxRate` 均清空;附件在读取响应中表现为空数组。
#### 错误响应
缺少审计或并发字段时失败且账户零写入:
```json
{ "code": 400, "message": "变更原因不能为空", "success": false, "data": null }
```
```json
{ "code": 400, "message": "expectedUpdateTime不能为空", "success": false, "data": null }
```
过期版本继续返回既有并发错误:
```json
{ "code": 395014, "message": "数据已被他人修改,请刷新后重试", "success": false, "data": null }
```
#### 业务边界
- `initialAccounts` 仅允许供应商为 `DRAFT` 时维护;否则返回既有状态错误 `395005`,账户零写入。
- 同账号完整替换保留原 `accountId`;账号全局唯一、事务、行锁、幂等和账户审计保持不变。
- 生效后的账户继续走独立新增与审批接口,供应商编辑不得绕过账户状态机。
### 2. 查询供应商账户列表 `GET /admin/supplier/items/{supplierId}/account-info/list`
**VO**: `SupplierAccountInfoRespVO / SupplierBankAccountRespVO`
#### 使用场景
进入供应商编辑页时加载“结算信息”table。供应商为草稿时,列表包含其初始 `DRAFT` 账户。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
无 Query 参数,无请求体。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 供应商雪花 ID |
| `data.bankAccounts` | Array | 当前可读账户列表 |
| `data.bankAccounts[].status` | String | 草稿初始账户为 `DRAFT` |
| `data.bankAccounts[].isDefault` | String | 草稿初始账户为 `NO` |
| `data.updateTime` | String | 供应商主体并发版本 |
#### 请求示例
```http
GET /admin/supplier/items/2094000000000000000/account-info/list
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2094000000000000000",
"bankAccounts": [
{
"accountId": "2094000000000000001",
"accountType": "PERSONAL",
"bankName": "示例银行",
"bankBranch": null,
"accountNo": "6222000012345678",
"proofFileUrls": [],
"settleMode": null,
"accountPeriod": null,
"invoiceType": null,
"taxRate": null,
"status": "DRAFT",
"isDefault": "NO"
}
],
"updateTime": "2026-08-29 17:01:01"
}
}
```
#### 空数据 / 降级响应
没有可读账户时 `data.bankAccounts` 返回空数组。可选文本字段为空时返回 `null`;已获附件读取权限且附件为空时 `proofFileUrls` 返回空数组。
#### 错误响应
```json
{ "code": 395001, "message": "供应商不存在", "success": false, "data": null }
```
#### 业务边界
- 仅当供应商当前为 `DRAFT` 时才扩展读取草稿账户;其他状态的既有可见范围不变。
- 证明附件继续受独立权限和同步敏感读取审计约束;无权时不序列化附件原值。
- 查询不推进供应商或账户版本,不产生业务写副作用。
### 3. 查询收款账户详情 `GET /admin/supplier/bank-accounts/{accountId}/view`
**VO**: `SupplierBankAccountDetailRespVO`
#### 使用场景
草稿编辑页需要查看单个初始账户完整字段时,按列表返回的字符串 `accountId` 查询详情。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `accountId` | Path | String | 是 | 正整数 ID 字符串 | 目标账户 |
无 Query 参数,无请求体。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.accountId` | String | 账户雪花 ID |
| `data.accountType` 等账户字段 | 对应类型/null | 完整账户业务值,6 个可选字段允许为空 |
| `data.status` | String | 草稿初始账户为 `DRAFT` |
| `data.isDefault` | String | 草稿初始账户为 `NO` |
| `data.updateTime` | String | 账户当前版本 |
#### 请求示例
```http
GET /admin/supplier/bank-accounts/2094000000000000001/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"accountId": "2094000000000000001",
"accountType": "PERSONAL",
"bankName": "示例银行",
"bankBranch": null,
"accountNo": "6222000012345678",
"proofFileUrls": [],
"settleMode": null,
"accountPeriod": null,
"invoiceType": null,
"taxRate": null,
"status": "DRAFT",
"isDefault": "NO",
"updateTime": "2026-08-29 17:01:01"
}
}
```
#### 空数据 / 降级响应
目标账户不存在、已软删除或不在当前供应商状态允许的读取范围时,不返回部分对象;统一按不存在失败关闭。
#### 错误响应
```json
{ "code": 395001, "message": "供应商不存在", "success": false, "data": null }
```
#### 业务边界
- 草稿详情可见性由所属供应商当前状态决定,不能仅凭 `accountId` 绕过主体边界。
- 完整账号沿用当前管理端授权语义;证明附件仍需独立权限与同步审计。
- 详情查询不改变账户、供应商、审批或默认账户状态。
## 四、契约约束与正确调用方式
- 编辑页先调用账户列表接口加载结算 table,再把用户实际编辑后的 0 或 1 项完整快照放入供应商更新请求的 `initialAccounts`。
- 用户未操作结算区域时可以省略 `initialAccounts`,避免无意义改写;明确清空时必须发送空数组。
- 保存必须同时发送非空 `changeReason` 与最近读取到的供应商 `expectedUpdateTime`。
- 账户业务字段使用与新建供应商一致的字段名;`supplierId`、`accountId` 按字符串处理,禁止转为 JavaScript Number。
## 五、数据库行为
- 草稿账户完整替换会把省略的 6 个可选字段持久化为空;重新查询不再回读旧值。
- 空数组对草稿初始账户执行软删除;供应商、账户和审计仍在同一服务事务内提交或回滚。
- 本次没有新增 migration、跨 schema 写入、Redis、MQ 或配置行为。
## 六、边界行为
- `accountType`、`bankName`、`accountNo` 仍为账户项必填字段;“可清空”只适用于其余 6 个可选字段。
- `MONTHLY` 与 `accountPeriod`、开票类型与税率的既有组合校验继续生效。
- 更新完整快照时未提供的可选字段保存为空,不是保持数据库旧值。
- 写失败时供应商、账户和审计均在同一事务回滚;过期版本和非草稿状态均零写入。
## 六.6、修改前后对比
| 项目 | 修改前 | 修改后 |
|---|---|---|
| 供应商编辑请求 | 无 `initialAccounts`,不能维护新建时的初始结算信息 | 可选完整快照:省略不改、空数组清空、1 项替换 |
| 草稿账户列表/详情 | `DRAFT` 初始账户不在管理端账户读取范围 | 所属供应商为 `DRAFT` 时可读回 |
| 可选字段清空 | 实体值虽置空,但默认更新策略可能保留数据库旧值 | 6 个可选字段显式持久化为空 |
| 审计与并发字段 | `changeReason`、`expectedUpdateTime` 必填 | 继续必填 |
## 六.7、影响评估
- **是否向后兼容**:是;不发送 `initialAccounts` 的原调用方保持原更新语义。
- **前端是否需要接入**:是;编辑页结算 table 需调用账户列表并按完整快照保存。
- **状态机影响**:无;仅草稿态开放聚合维护,其他状态继续走独立账户审批。
- **撤回影响**:撤回后编辑页应停止发送 `initialAccounts`,草稿账户也不再通过账户列表/详情读回。
## 七、不影响范围
- 不修改非草稿供应商的独立账户新增、审批、默认账户和账户状态机。
- 不修改合同独立登记接口、供应商提交审批流程或合同字段可空契约。
- 不新增错误码、数据库 migration、Gateway 路由、Redis、MQ、Nacos 或配置变更。
- 不修改任何前端源码;页面 table 排列和字段消费由前端按本契约处理。
## 八、测试环境已验证
- 新建草稿携带完整初始结算信息后,列表可读回相同字段。
- 草稿编辑把完整账户改为仅保留三个必填字段后,6 个可选字段保存并重新回读为空,账户 ID 保持不变。
- 缺少 `changeReason`、缺少 `expectedUpdateTime` 和使用过期版本均失败且账户零写入。
- 发送空数组后写响应和列表均为空;测试供应商删除后详情不可读,所有可恢复测试数据已清理。
## 十、相关文档
- Issue [#6669](https://git.1814.love:8443/wx/HL/issues/6669)
- 补充 Issue [#6676](https://git.1814.love:8443/wx/HL/issues/6676)
- PR [#6674](https://git.1814.love:8443/wx/HL/pulls/6674),合并提交 `35dba63d6a92159d933b38385fee057f617bdfed`
- PR [#6677](https://git.1814.love:8443/wx/HL/pulls/6677),合并提交 `af5ea05df3d7490c97e6f4329c145a4014396bee`
## 关联 / 联系人
- **后端负责人**:@lc
- **前端负责人**:@mmg
- **当前状态**:已消费(`frontend_status: verified`)。SupplierEditModal 结算 Tab 放开为「新建 || (编辑 && DRAFT)」,草稿编辑拉账户列表回读初始账户(含 accountId,accountNo 完整值可回填),buildEditBody 仅 DRAFT 按快照比对携带 initialAccounts(未动省略/清空 `[]`/1 项整份替换保留 accountId,6 可选字段+proofFileUrls 省略即显式清空),非 DRAFT 不开放不携带。
@@ -0,0 +1,208 @@
---
schema: "hl-changelog/v2"
ticket: "6684"
title: "供应商草稿编辑变更原因可选且不写变更记录"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "646a1565"
target_release: "v2.1"
verified_at: "2026-08-30"
status_note: "PR #6686 已合并 dev-v3,合并提交 ae7226ac8e2a23f0ce79ddda1a3a892542b81996 已由 Deploy Panel 任务 da7ffb3a 精确部署 TEST。真实 Gateway 已验证 DRAFT 草稿不传 changeReason 可成功保存、详情回读状态与修改值正确、主体变更记录仍为 0,并已删除合成草稿。当前状态:待前端处理。"
updated_at: "2026-08-30"
base: "dev-v3"
---
# 🔧 供应商草稿编辑变更原因可选且不写变更记录
供应商当前状态为 `DRAFT` 时,编辑接口不再要求填写 `changeReason`,并且本次草稿编辑不会新增主体变更记录。其他状态继续要求非空变更原因并保留原审计行为。
管理端需要按详情返回的当前状态调整表单校验:草稿隐藏或取消“变更原因”必填,非草稿继续必填。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 编辑供应商 | PUT | `/admin/supplier/items/{supplierId}/update` | 修改请求校验与写入行为 | `DRAFT` 可省略 `changeReason` 且不新增主体变更记录;其他状态不变 |
## 三、接口详情
### 1. 编辑供应商 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO / SupplierWriteRespVO`
#### 使用场景
在供应商编辑页继续完善尚未提交审批的草稿资料。调用方先读取供应商详情取得当前 `status` 和 `updateTime`,再按当前状态决定是否发送变更原因。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `changeReason` | Body | String | 条件必填 | 最长 500 字符 | 服务端当前状态为 `DRAFT` 时可省略或为空;其他状态去空白后必须非空 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 最近一次详情或写响应返回的并发版本 |
| `fullName`、`shortName` 等资料字段 | Body | 对应类型 | 否 | 沿用既有字段规则 | 仅发送本次需要修改的字段;示例使用 `remark` |
请求体不接收客户端自报的供应商状态;是否为草稿由服务端锁定并读取当前供应商后判定。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Number | 业务码;成功为 `200` |
| `success` | Boolean | 业务是否成功 |
| `data.supplierId` | String | 供应商 ID |
| `data.supplierNo` | String | 供应商业务编号 |
| `data.status` | String | 保存后的当前状态;本场景为 `DRAFT` |
| `data.onboardingStage` | String | 草稿为 `PROFILE_DRAFT` |
| `data.initialAccounts` | Array | 当前初始结算账户摘要,未登记时为空数组 |
| `data.updateTime` | String | 保存后的新并发版本 |
#### 请求示例
`DRAFT` 草稿请求体完全不发送 `changeReason`:
```json
{
"remark": "补充草稿内部备注",
"expectedUpdateTime": "2026-08-30 08:15:29"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2093855088501035010",
"supplierNo": "SUP2093855088501035010",
"status": "DRAFT",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [],
"updateTime": "2026-08-30 08:15:45"
}
}
```
#### 空数据 / 降级响应
- `DRAFT` 时省略 `changeReason`、传 `null` 或只传空白,不会因变更原因被拒绝。
- 省略其他可选资料字段仍表示“不修改该字段”,不自动清空既有值。
- 依赖服务异常继续按既有失败关闭语义返回,不把未完成写入伪装成成功。
#### 错误响应
非 `DRAFT` 供应商省略或传空白 `changeReason`,继续返回原业务错误且零写入:
```json
{
"code": 400,
"message": "变更原因不能为空",
"success": false,
"data": null
}
```
并发版本过期继续返回既有错误:
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"success": false,
"data": null
}
```
#### 业务边界
- 接口继续要求有效管理端身份、既有可信角色及 `supplier:update` 服务端权限。
- 草稿判定使用服务端锁定后读取到的持久状态,不能通过请求体伪造 `DRAFT` 绕过非草稿审计。
- `DRAFT` 成功编辑只更新草稿资料与并发版本,不新增主体变更记录。
- 非 `DRAFT` 的原因必填、主体变更记录、事务、聚合锁、幂等和乐观并发规则均不变。
- 参数、权限、状态或并发校验失败时不产生部分资料写入或变更记录。
## 四、契约约束与正确调用方式
1. 进入编辑页先调用 `GET /admin/supplier/items/{supplierId}/basic-info/view`,使用返回的 `status` 和 `updateTime`。
2. `status=DRAFT` 时可以不渲染变更原因输入框,或取消其必填校验;保存请求可完全省略 `changeReason`。
3. `status` 不是 `DRAFT` 时继续收集非空 `changeReason`,不要把草稿规则扩展到其他状态。
4. 每次成功保存后使用响应中的新 `updateTime` 作为下一次编辑的 `expectedUpdateTime`。
| 场景 | payload | 结果 |
|---|---|---|
| `DRAFT` 省略原因 | `{ "remark": "补充资料", "expectedUpdateTime": "2026-08-30 08:15:29" }` | 成功,不新增主体变更记录 |
| `DRAFT` 显式空原因 | `{ "changeReason": "", "expectedUpdateTime": "2026-08-30 08:15:29" }` | 成功,不新增主体变更记录 |
| 非 `DRAFT` 省略原因 | `{ "remark": "调整资料", "expectedUpdateTime": "2026-08-30 08:15:29" }` | `400`,变更原因不能为空 |
| 非 `DRAFT` 提供原因 | `{ "changeReason": "更新登记资料", "expectedUpdateTime": "2026-08-30 08:15:29" }` | 按既有编辑与审计规则处理 |
## 五、数据库行为
| 服务端当前状态 | 资料保存 | 主体变更记录 |
|---|---|---|
| `DRAFT` | 按既有增量编辑和并发规则保存 | 不新增本次编辑记录 |
| 非 `DRAFT` 且原因有效 | 按既有编辑规则保存 | 继续新增完整变更记录并保留原因 |
| 非 `DRAFT` 且原因为空 | 不保存 | 不新增记录 |
本次不新增数据库 migration,不改历史记录,不执行跨服务写入,也不引入 Redis、MQ 或配置写行为。
## 六、边界行为
- 未登录请求继续由 Gateway 拒绝;本次不改变认证级别。
- 无供应商写权限、供应商不存在、状态不允许或并发版本过期时继续按既有错误语义失败。
- `changeReason` 长度上限仍为 500 字符;非草稿只传空白等同未填写。
- 草稿成功编辑后,详情回读应保持 `status=DRAFT` 并返回本次修改值与新版本。
- 草稿的主体变更记录分页在本次保存前后数量保持不变。
## 六.6、修改前后对比
| 项目 | 修改前 | 修改后 |
|---|---|---|
| `DRAFT` 的 `changeReason` | 请求模型无条件必填,省略即返回“变更原因不能为空” | 可省略、为 `null` 或空白 |
| `DRAFT` 编辑审计 | 每次成功编辑都会新增主体变更记录 | 不新增主体变更记录 |
| 非 `DRAFT` 的 `changeReason` | 必填 | 继续必填 |
| 非 `DRAFT` 编辑审计 | 成功后记录完整变化与原因 | 保持不变 |
## 六.7、影响评估
- **是否破坏向后兼容**:否;原来已发送 `changeReason` 的草稿请求继续可用,非草稿契约不变。
- **前端是否必须同步上线**:是;管理端当前草稿编辑表单仍把“变更原因”显示为必填,需要按详情 `status` 取消草稿必填。
- **前端 workaround 清理点**:删除 `DRAFT` 草稿保存前对 `changeReason` 的必填拦截;不得删除非草稿校验。
- **响应与错误码影响**:成功响应字段不变;只移除草稿缺少原因时的 `400`,不新增错误码。
## 七、不影响范围
- 不修改供应商创建、注册提交、草稿删除、暂停、拉黑、归档、合同、账户或资源关系接口。
- 不修改非草稿资料编辑的变更原因与主体变更记录语义。
- 不修改 Gateway 路由、认证策略、角色或权限点。
- 不新增数据库结构、历史数据回填、Redis、MQ、Nacos 或配置变更。
- 本工单仅交付后端和接口 Changelog,不修改任何前端源码或资源。
## 八、测试环境已验证
- 合并提交 `ae7226ac8e2a23f0ce79ddda1a3a892542b81996` 已由 Deploy Panel 任务 `da7ffb3a` 精确部署到 TEST,Resource 双实例与 Nacos 注册均健康。
- 通过真实 TEST Gateway 创建合成 `DRAFT` 草稿,并用完全不含 `changeReason` 的请求修改 `remark`;返回 `code=200`、`success=true`、`status=DRAFT` 和新的 `updateTime`。
- 保存后再次查询详情,`remark` 为本次修改值且状态仍为 `DRAFT`。
- 保存前后查询该供应商的 `UPDATE` 主体变更记录均返回 `total=0`、`records=[]`。
- 验收后通过草稿删除接口清理合成数据;详情返回“供应商不存在”,按供应商编号查询列表为空。
## 十、相关文档
- Issue [#6684](https://git.1814.love:8443/wx/HL/issues/6684)
- PR [#6686](https://git.1814.love:8443/wx/HL/pulls/6686)
- 合并提交 [ae7226ac8e2a23f0ce79ddda1a3a892542b81996](https://git.1814.love:8443/wx/HL/commit/ae7226ac8e2a23f0ce79ddda1a3a892542b81996)
## 关联 / 联系人
### 联系人
- **后端负责人**:@lc
- **当前状态**:已消费(`frontend_status: verified`)。SupplierEditModal 变更原因必填改仅非 DRAFT(mainLocked)挂载,DRAFT 取消必填、留空省略 changeReason 不写变更记录;buildEditBody 按「非 DRAFT 恒携带/DRAFT 填了才携带」;「至少一个实际变化字段」判定剔除元字段后看业务字段。非 DRAFT 继续必填拦截。
@@ -0,0 +1,141 @@
---
schema: "hl-changelog/v2"
ticket: "6710"
title: "供应商联系人快照版本字段兼容"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "95e1f917"
target_release: "v2.1"
verified_at: "2026-08-30"
status_note: "PR #6712 已合并 dev-v3,合并提交 7f3f944bf361b7ae41424c43b29262fb634fe791 已精确部署 TEST。后端后改判「当前前端已回传 updateTime 无需修改代码」,但前端实证:修复前 cleanContactRow 对已有联系人仅发 contactId 不带任何版本,必触发 400「联系人快照项不合法」;本次前端补回传条目级版本(commit 95e1f917),修复后方可正常保存。前端按实际修复记 verified。"
updated_at: "2026-08-30"
base: "dev-v3"
---
# 供应商联系人快照版本字段兼容
后端现已兼容详情联系人字段 `contacts[].updateTime`。当前前端已经回传该字段,无需修改代码;本记录仅保留历史审计,不构成前端任务。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 编辑供应商 | PUT | `/admin/supplier/items/{supplierId}/update` | 请求兼容 | 已有联系人可用 `updateTime` 作为条目级并发版本 |
## 三、接口详情
### 1. 编辑供应商 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO / SupplierContactMergeReqVO / SupplierWriteRespVO`
#### 使用场景
保留已有联系人并在一次编辑中新增多个联系人。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 主体并发版本 |
| `contacts` | Body | Array | 否 | 最多 100 项 | 联系人完整快照 |
| `contacts[].contactId` | Body | String | 已有项是 | 正整数 | 为空表示新增 |
| `contacts[].expectedUpdateTime` / `updateTime` | Body | String | 已有项二选一 | 时间格式同上 | 条目级并发版本 |
姓名和联系电话允许与已有联系人或同批新增联系人重复。新增项仍按既有规则提供姓名、电话和角色,不传 ID 与版本。
#### 出参 `Result<SupplierWriteRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.updateTime` | String | 保存后的主体并发版本 |
#### 请求示例
```json
{"expectedUpdateTime":"2026-08-30 10:04:18","contacts":[{"contactId":"2093000000000000001","contactName":"张三","contactPhone":"18501941408","contactRole":"contentBus","updateTime":"2026-08-30 10:04:19"},{"contactName":"张三","contactPhone":"18501941408","contactRole":"contentMoney"}]}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"updateTime":"2026-08-30 10:04:29"}}
```
#### 空数据 / 降级响应
- 省略 `contacts` 或传 `null`:联系人不变。
- 依赖异常:沿用既有失败关闭语义,不产生部分写入。
#### 错误响应
版本过期时:
```json
{"code":395014,"message":"数据已被他人修改,请刷新后重试","success":false,"data":null}
```
已有联系人未携带任一版本字段时,仍返回 `400`、`联系人快照项不合法`。
#### 业务边界
- 已有联系人仍须提供 `contactId` 和有效版本;本次没有取消并发校验。
- 姓名或联系电话重复本身不是错误条件。
- 权限、状态、默认联系人及角色字典规则均不变。
## 四、契约约束与正确调用方式
1. 主体详情 `updateTime` 放入请求顶层 `expectedUpdateTime`。
2. 已有联系人的 `updateTime` 可原样回传;新增联系人不传 ID 和版本。
3. 保存后重新读取详情,使用最新版本继续编辑。
## 五、数据库行为
- 不新增数据库结构或姓名、电话唯一约束。
- 校验失败时联系人和主体均不写入。
## 六、边界行为
- 未登录、无权限、供应商不存在或版本过期时沿用既有错误。
- `contacts=[]` 沿用既有清空语义。
- 业务失败可能仍为 HTTP 200,调用方同时判断 `code` 和 `success`。
## 六.6、修改前后对比
| 项目 | 修改前 | 修改后 |
|---|---|---|
| 联系人版本字段 | 仅识别 `expectedUpdateTime` | 同时识别 `expectedUpdateTime`、`updateTime` |
| 姓名、电话重复 | 允许 | 继续允许 |
## 六.7、影响评估
- **是否破坏向后兼容**:否。
- **前端是否必须同步上线**:否,当前前端已回传 `updateTime`。
- **前端 workaround 清理点**:无。
- **响应与错误码影响**:无。
## 七、不影响范围
- 不修改其他供应商接口、权限或状态机。
- 不修改数据库结构、配置、Redis 或 MQ。
- 不修改任何前端代码。
## 八、测试环境已验证
- 详情 `updateTime` 原样回传后,可保留已有联系人并新增多个联系人。
- 多个联系人姓名、电话重复时保存成功。
- 过期版本返回 `395014` 且零写入;验收草稿已清理。
## 十、相关文档
- [Issue #6710](https://git.1814.love:8443/wx/HL/issues/6710)
- [PR #6712](https://git.1814.love:8443/wx/HL/pulls/6712)
## 关联 / 联系人
- **后端负责人**:@lc
- **当前状态**:已消费(`frontend_status: verified`)。SupplierEditModal fillForm 回填详情联系人条目级 updateTime、createContactRow 新增行置 null;cleanContactRow 已有项(contactId 存在)加发 expectedUpdateTime=row.updateTime 原样回传,新增行仍不发版本;快照比对两侧同经 cleanContactRow,未改动不误携带。spec +3 例累计 71 例绿,commit 95e1f917 已推 v2.1。注:后端后改判「无需前端处理」系基于修复后前端,修复前旧前端仅发 contactId 必 400,本修复为必要。
@@ -0,0 +1,805 @@
---
schema: "hl-changelog/v2"
ticket: "6717"
title: "车队关联供应商并展示供应商全名"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "4faf23bb"
target_release: "v2.1"
verified_at: "2026-08-30"
status_note: "PR #6753 已合并 dev-v3(merge commit 447b92f5);Deploy Panel 任务 2fe21f84(hl-fleet-service,2026-08-30 17:20)与 f29a293f(hl-resource-service,2026-08-30 17:22)均已部署测试服成功。真实 TEST 身份已验证 5 条负向链路全绿 + 列表字段结构正确,测试数据已清理。车队列表操作列的「供应商」按钮需下线,改为在车队新增/编辑弹窗选择供应商。前端已消费(frontend_status: verified):车队列表下线操作列「供应商」入口并新增供应商列透传 supplierName 快照;新增/编辑弹窗内嵌供应商 NSelect(远程搜索 FLEET+ACTIVE、可清空、编辑回填并补快照选项防显原始 ID),提交体携带 supplierId 字符串;601108/601109/601110/601111 按 bizCode 精确提示,新建不选供应商落 DISABLED 并提示。前端 commit 4faf23bb 已推 v2.1。"
updated_at: "2026-08-30"
base: "dev-v3"
---
# 车队管理:车队关联供应商并展示供应商全名
> **存放目录**:
> - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/2026-08/`
>
> **服务**: hl-fleet-service(端口 8094)/ hl-resource-service(内部依赖)
> **PR**: #6753
> **Issue**: #6717
> **日期**: 2026-08-30
> **影响范围**: 管理后台「车辆管理 - 车队管理」列表/新增/编辑/启用
车队管理新增供应商归属字段(可空),供应商只能随车队新增/编辑一起提交,不再提供单独配置入口。
---
## ⚠️ 关键变化
- 车队列表操作列的「供应商」按钮(`views/fleet/teams/index.vue` 弹窗 `SupplierResourceRelModal`)**需要下线**;供应商关联改为在车队新增/编辑弹窗里选择。
- 「不选供应商不能启动,只能停用」:未关联供应商的车队调用启用接口会被 601108 拦截;新建时不选供应商则车队落停用态。
- 已有订单(名下车辆存在非取消派单)的车队,**不允许更换或清除供应商**;允许首次绑定。
---
## 一、背景
车队此前没有供应商归属字段;供应商域(`supplier_main`,类型字典 `supplier_type`)已支持车队类型供应商(FLEET),但车队主数据与供应商档案之间未建立关联。工单 #6717 要求车队管理列表展示供应商全名,并在车队新增/编辑时明确供应商归属。
**最终口径(2026-08-30 用户确认,覆盖工单原文「必选」语义)**:
1. 供应商字段**不必填**(可选);不选 → 不能启用,只能停用。
2. 车队已有订单时,不允许**更换**或**清除**供应商(含已有订单的历史车队豁免存量,不动存量数据)。
3. 去掉单独配置供应商接口入口:前端车队列表的「供应商」按钮下线;供应商关联只随车队新增/编辑一起提交。
4. 供应商全名以**写时快照**存于 `fleet_team.supplier_name`;列表/详情零 Feign 读,供应商改名后陈旧、重新编辑可刷新。
5. 供应商资格校验走跨服务 Feign(fleet → resource `GET /internal/supplier/{supplierId}/fleet-eligibility`):主体存在 + 状态 ACTIVE + 类型关联含 FLEET。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 分页查询车队 | GET | `/admin/fleet/teams/page` | 响应新增字段 | `records[]` 增加 `supplierId`(String/null) + `supplierName`(String/null) |
| 2 | 查询车队详情 | GET | `/admin/fleet/teams/{fleetTeamId}` | 响应新增字段 | 同 #1 |
| 3 | 新增车队 | POST | `/admin/fleet/teams` | 请求体新增可选字段 + 响应新增字段 | 请求体加 `supplierId`(Long 字符串/可空);响应同 #2 |
| 4 | 编辑车队 | PUT | `/admin/fleet/teams/{fleetTeamId}` | 请求体新增可选字段 + 响应新增字段 + 失败语义 | 请求体加 `supplierId`;更换/清除时已有订单被 601111 拦截;清除且当前 ACTIVE 会强制落 DISABLED |
| 5 | 启用车队 | POST | `/admin/fleet/teams/{fleetTeamId}/enable` | 失败语义新增 | `supplierId==null` 时抛 601108「车队未关联供应商,不能启用」 |
---
## 三、接口详情
### 1. 分页查询车队 `GET /admin/fleet/teams/page`
**VO**: `FleetTeamPageReqVO` / `Result<PageResult<FleetTeamRespVO>>`
#### 使用场景
管理后台「车辆管理 - 车队管理」列表页加载时调用。供应商列新增展示。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `page` | Query | Integer | 否 | ≥1,默认 1 | 页码 |
| `pageSize` | Query | Integer | 否 | 1..100,默认 10 | 每页大小 |
| `keyword` | Query | String | 否 | ≤64 | 车队名称/负责人姓名模糊搜索 |
| `teamType` | Query | String | 否 | `SELF_OPERATED`/`COOPERATIVE` | 车队类型筛选 |
| `status` | Query | String | 否 | `ACTIVE`/`DISABLED` | 状态筛选 |
| `settleType` | Query | String | 否 | `cash`/`sign`/`company` | 付款方式筛选 |
#### 出参 `Result<PageResult<FleetTeamRespVO>>`
`records[]` 每一项 `FleetTeamRespVO` 字段(与改前相比只多两列,其余字段语义不变):
| 字段 | 类型 | 说明 |
|------|------|------|
| `fleetTeamId` | String | 车队 ID(雪花序列化为字符串,禁止转 Number) |
| `teamCode` | String | 内部稳定编码(`ft_xxx`) |
| `teamName` | String | 车队名称 |
| `teamType` | String | `SELF_OPERATED`/`COOPERATIVE` |
| `leaderName` | String | 负责人姓名 |
| `leaderPhone` | String | 负责人电话(**分页脱敏**:`138****8000`) |
| `settleType` | String | `cash`/`sign`/`company` |
| `status` | String | `ACTIVE`/`DISABLED` |
| `sortOrder` | Integer | 排序值 |
| `remark` | String | 备注 |
| `vehicleCount` | Integer | 名下车辆总数(含停用) |
| `activeVehicleCount` | Integer | 名下在役车辆数 |
| **`supplierId`** | **String/null** | **关联供应商 ID(雪花字符串;未关联为 null)** |
| **`supplierName`** | **String/null** | **关联供应商全称快照(写时同步,未关联为 null)** |
| `createTime` | String | 创建时间 `yyyy-MM-dd HH:mm:ss` |
| `updateTime` | String | 最后更新时间 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
GET /admin/fleet/teams/page?page=1&pageSize=10&status=ACTIVE
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"total": 2,
"page": 1,
"pageSize": 10,
"records": [
{
"fleetTeamId": "2102345678901234567",
"teamCode": "ft_2x9k3m",
"teamName": "合作车队A",
"teamType": "COOPERATIVE",
"leaderName": "测试负责人甲",
"leaderPhone": "138****0001",
"settleType": "cash",
"status": "ACTIVE",
"sortOrder": 10,
"remark": "由 fleet_attribution/历史业务数据迁移",
"vehicleCount": 12,
"activeVehicleCount": 9,
"supplierId": "2091381911266967553",
"supplierName": "内蒙古呼籁旅游服务有限公司",
"createTime": "2026-06-01 10:00:00",
"updateTime": "2026-08-30 15:30:00"
},
{
"fleetTeamId": "2102345678901234568",
"teamCode": "ft_2x9k3n",
"teamName": "自有车队",
"teamType": "SELF_OPERATED",
"leaderName": "自有负责人",
"leaderPhone": "139****0002",
"settleType": "company",
"status": "DISABLED",
"sortOrder": 20,
"remark": "",
"vehicleCount": 0,
"activeVehicleCount": 0,
"supplierId": null,
"supplierName": null,
"createTime": "2026-08-01 09:00:00",
"updateTime": "2026-08-30 15:30:00"
}
]
}
}
```
#### 空数据 / 降级响应
无车队时 `records=[]` `total=0`,`code=200`。供应商字段未关联时为 `null`(不是空串),前端按未关联渲染。
#### 错误响应
分页查询无业务错误分支。网关/框架错误:
```json
{ "code": 401, "message": "未登录", "success": false, "data": null }
```
#### 业务边界
- 只读查询;返回的 `supplierId`/`supplierName` 来自 `fleet_team.supplier_id/supplier_name` 列快照,不做实时跨服务取数。
- 供应商改名后列表展示旧名(快照口径);如需最新名称,让用户重新编辑车队保存触发刷新。
- 车队停用车队(`status=DISABLED`)也可被列表查到(不带状态筛选时默认返回全部状态)。
---
### 2. 查询车队详情 `GET /admin/fleet/teams/{fleetTeamId}`
**VO**: `Result<FleetTeamRespVO>`
#### 使用场景
管理后台编辑车队弹窗初始化时调用,回填 `supplierId`/`supplierName` 到供应商选择器。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `fleetTeamId` | Path | String(Long) | 是 | 正整数 ID 字符串 | 目标车队 ID(雪花,不得转 Number) |
#### 出参 `Result<FleetTeamRespVO>`
字段与 #1 `records[]` 项结构一致(含新增 `supplierId`/`supplierName`):
| 字段 | 类型 | 说明 |
|------|------|------|
| `fleetTeamId` | String | 车队 ID(雪花序列化为字符串,禁止转 Number) |
| `teamCode` | String | 内部稳定编码(`ft_xxx`) |
| `teamName` | String | 车队名称 |
| `teamType` | String | `SELF_OPERATED`/`COOPERATIVE` |
| `leaderName` | String | 负责人姓名 |
| `leaderPhone` | String | 负责人电话(**详情返回原值**,编辑场景回填用) |
| `settleType` | String | `cash`/`sign`/`company` |
| `status` | String | `ACTIVE`/`DISABLED` |
| `sortOrder` | Integer | 排序值 |
| `remark` | String | 备注 |
| `vehicleCount` | Integer | 名下车辆总数(含停用) |
| `activeVehicleCount` | Integer | 名下在役车辆数 |
| `supplierId` | String/null | 关联供应商 ID(雪花字符串;未关联为 null) |
| `supplierName` | String/null | 关联供应商全称快照(写时同步) |
| `createTime` | String | 创建时间 `yyyy-MM-dd HH:mm:ss` |
| `updateTime` | String | 最后更新时间 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
GET /admin/fleet/teams/2102345678901234567
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"fleetTeamId": "2102345678901234567",
"teamCode": "ft_2x9k3m",
"teamName": "合作车队A",
"teamType": "COOPERATIVE",
"leaderName": "测试负责人甲",
"leaderPhone": "13800000001",
"settleType": "cash",
"status": "ACTIVE",
"sortOrder": 10,
"remark": "由 fleet_attribution/历史业务数据迁移",
"vehicleCount": 12,
"activeVehicleCount": 9,
"supplierId": "2091381911266967553",
"supplierName": "内蒙古呼籁旅游服务有限公司",
"createTime": "2026-06-01 10:00:00",
"updateTime": "2026-08-30 15:30:00"
}
}
```
#### 空数据 / 降级响应
车队不存在或已软删除(业务错误,见下方错误响应);无空数据分支。
#### 错误响应
车队不存在或已软删除:
```json
{ "code": 601100, "message": "车队不存在", "success": false, "data": null }
```
未登录(网关拦截):
```json
{ "code": 401, "message": "未登录", "success": false, "data": null }
```
#### 业务边界
- 详情接口返回 `leaderPhone` 为原值(编辑场景需要回填),分页接口脱敏。
- `supplierId` 必须按字符串处理,禁止转 JavaScript Number。
---
### 3. 新增车队 `POST /admin/fleet/teams`
**VO**: `FleetTeamSaveReqVO` / `Result<FleetTeamRespVO>`
#### 使用场景
管理后台「车辆管理 - 车队管理」点击「新增车队」,在表单里可选填供应商。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `teamName` | Body | String | 是 | ≤64,唯一 | 车队名称 |
| `teamType` | Body | String | 是 | `SELF_OPERATED`/`COOPERATIVE` | 车队类型 |
| `leaderName` | Body | String | 是 | ≤64 | 负责人姓名 |
| `leaderPhone` | Body | String | 是 | 电话格式正则 | 负责人电话 |
| `settleType` | Body | String | 是 | `cash`/`sign`/`company` | 付款方式 |
| `sortOrder` | Body | Integer | 是 | ≥0 | 排序 |
| `remark` | Body | String | 否 | ≤256 | 备注 |
| **`supplierId`** | Body | **String(Long)/null** | **否** | **`@Positive`** | **关联供应商 ID(雪花字符串;不填=不选供应商,新建车队落 DISABLED)** |
#### 出参 `Result<FleetTeamRespVO>`
响应结构同 #2;`supplierId`/`supplierName` 按请求回填(选了供应商且校验通过则写入快照,否则为 null)。
| 字段 | 类型 | 说明 |
|------|------|------|
| `fleetTeamId` | String | 新车队 ID(雪花序列化为字符串) |
| `supplierId` | String/null | 按请求回填(选了且校验通过) |
| `supplierName` | String/null | 写时同步的供应商全称快照;未选为 null |
| `status` | String | 选供应商=`ACTIVE`;不选=`DISABLED` |
| `createTime` | String | 创建时间 `yyyy-MM-dd HH:mm:ss` |
| `updateTime` | String | 创建时间(与 createTime 相同) |
| (其余字段) | - | 与详情 #2 同构 |
#### 请求示例
```http
POST /admin/fleet/teams
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"teamName": "新合作车队B",
"teamType": "COOPERATIVE",
"leaderName": "王队长",
"leaderPhone": "13800138000",
"settleType": "sign",
"sortOrder": 20,
"remark": "新签约车队",
"supplierId": "2091381911266967553"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"fleetTeamId": "2103456789012345678",
"teamCode": "ft_3a1b2c",
"teamName": "新合作车队B",
"teamType": "COOPERATIVE",
"leaderName": "王队长",
"leaderPhone": "13800138000",
"settleType": "sign",
"status": "ACTIVE",
"sortOrder": 20,
"remark": "新签约车队",
"vehicleCount": 0,
"activeVehicleCount": 0,
"supplierId": "2091381911266967553",
"supplierName": "内蒙古呼籁旅游服务有限公司",
"createTime": "2026-08-30 16:00:00",
"updateTime": "2026-08-30 16:00:00"
}
}
```
#### 空数据 / 降级响应
未选供应商创建(落 DISABLED,供应商字段为 null):
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"fleetTeamId": "2103456789012345679",
"teamCode": "ft_3a1b2d",
"teamName": "临时车队",
"teamType": "COOPERATIVE",
"leaderName": "临时负责人",
"leaderPhone": "13900139000",
"settleType": "cash",
"status": "DISABLED",
"sortOrder": 30,
"remark": "",
"vehicleCount": 0,
"activeVehicleCount": 0,
"supplierId": null,
"supplierName": null,
"createTime": "2026-08-30 16:00:00",
"updateTime": "2026-08-30 16:00:00"
}
}
```
#### 错误响应
供应商不存在/未生效/不含车队类型(不写库):
```json
{ "code": 601109, "message": "供应商不存在、未生效或不包含车队类型", "success": false, "data": null }
```
供应商校验依赖故障(Feign 不可用,不写库):
```json
{ "code": 601110, "message": "暂时无法校验供应商,请稍后重试", "success": false, "data": null }
```
车队名称重复(`uk_fleet_team_name` 唯一索引兜底):
```json
{ "code": 601101, "message": "车队名称已存在", "success": false, "data": null }
```
Bean Validation 校验失败:
```json
{ "code": 400, "message": "供应商ID必须为正数", "success": false, "data": null }
```
#### 业务边界
- 幂等:同一 teamName + 请求摘要 10 秒窗口内重复提交只生效一次(@Idempotent key=`fleet:team:create:{teamName}`)。
- 分布式锁:同 teamName 创建串行化(@Lock4j)。
- 选供应商时先 Feign 校验(事务外),通过后写库(事务内),Feign 失败/不合格一律不写库(失败关闭)。
- 不写供应商时新建车队 status=DISABLED,需后续编辑绑定供应商后才能启用。
---
### 4. 编辑车队 `PUT /admin/fleet/teams/{fleetTeamId}`
**VO**: `FleetTeamSaveReqVO` / `Result<FleetTeamRespVO>`
#### 使用场景
管理后台编辑车队弹窗提交。可修改基础字段 + 供应商;**不允许修改 status**(启停用走独立接口)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `fleetTeamId` | Path | String(Long) | 是 | 正整数 ID 字符串 | 目标车队 ID(不得转 Number) |
| (其余字段) | Body | String | 同新增 | 同新增 | 全字段提交(SaveReqVO 整体语义) |
| `supplierId` | Body | String(Long)/null | 否 | `@Positive` | 供应商 ID;更换/清除受订单围栏 |
#### 出参 `Result<FleetTeamRespVO>`
同 #2;`supplierId`/`supplierName` 反映最新写入值。
| 字段 | 类型 | 说明 |
|------|------|------|
| `fleetTeamId` | String | 车队 ID(路径回显) |
| `supplierId` | String/null | 最新写入值(更换/清除后刷新) |
| `supplierName` | String/null | 最新写入快照(更换时取新供应商 full_name;清除时为 null) |
| `status` | String | 清除供应商且当前 ACTIVE 时强制落 `DISABLED`;其余场景不变 |
| `updateTime` | String | 本次写入时间(秒级严格递增) |
| (其余字段) | - | 与详情 #2 同构 |
#### 请求示例
```http
PUT /admin/fleet/teams/2102345678901234567
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"teamName": "合作车队A",
"teamType": "COOPERATIVE",
"leaderName": "测试负责人甲",
"leaderPhone": "13800000001",
"settleType": "cash",
"sortOrder": 10,
"remark": "",
"supplierId": "2091381911266967553"
}
```
#### 响应示例
成功(更换供应商且无订单):
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"fleetTeamId": "2102345678901234567",
"teamCode": "ft_2x9k3m",
"teamName": "合作车队A",
"teamType": "COOPERATIVE",
"leaderName": "测试负责人甲",
"leaderPhone": "13800000001",
"settleType": "cash",
"status": "ACTIVE",
"sortOrder": 10,
"remark": "",
"vehicleCount": 12,
"activeVehicleCount": 9,
"supplierId": "2091381911266967553",
"supplierName": "内蒙古呼籁旅游服务有限公司",
"createTime": "2026-06-01 10:00:00",
"updateTime": "2026-08-30 16:30:00"
}
}
```
#### 空数据 / 降级响应
清除供应商(原值 → null)且车队无订单:快照清空,若当前 ACTIVE 强制落 DISABLED(保持不变量:ACTIVE ⇒ 已绑供应商)。
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"fleetTeamId": "2102345678901234567",
"teamCode": "ft_2x9k3m",
"teamName": "合作车队A",
"teamType": "COOPERATIVE",
"status": "DISABLED",
"supplierId": null,
"supplierName": null,
"updateTime": "2026-08-30 16:30:00"
}
}
```
#### 错误响应
车队已有订单时更换/清除供应商(含从原供应商换到新供应商、从原供应商清为空):
```json
{ "code": 601111, "message": "车队已关联订单,不能更换供应商", "success": false, "data": null }
```
供应商校验失败(同新增 #3 的 601109/601110);车队不存在:
```json
{ "code": 601100, "message": "车队不存在", "success": false, "data": null }
```
#### 业务边界
- 幂等:同 fleetTeamId + 请求摘要(含 supplierId 参与摘要)10 秒窗口内重复提交只生效一次(@Idempotent key=`fleet:team:update:{fleetTeamId}:{sha256}`)。
- 分布式锁:同 fleetTeamId 编辑串行化(@Lock4j)。
- 更换供应商校验顺序:订单围栏(601111)→ Feign 资格校验(601109/601110)→ 写库。
- 供应商不变时(含 null→null)不触发订单围栏,也不调 Feign。
- 首次绑定(null → 新值)即使已有订单也允许。
- `status` 不在 SaveReqVO,本接口不修改启停状态;清除供应商且当前 ACTIVE 时强制落 DISABLED 是唯一例外。
---
### 5. 启用车队 `POST /admin/fleet/teams/{fleetTeamId}/enable`
**VO**: `Result<Void>`
#### 使用场景
管理后台对已停用车队点击「启用」。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `fleetTeamId` | Path | String(Long) | 是 | 正整数 ID 字符串 | 目标车队 ID |
#### 出参 `Result<Void>`
成功时 `data=null`:
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | Integer | 恒为 `200` 表示成功 |
| `data` | null | 启用接口无返回体 |
#### 空数据 / 降级响应
启用成功恒返回 `{"code":200,"success":true,"data":null}`;无空数据分支。已是启用态时幂等放行不重复写(返回同样结构)。
#### 请求示例
```http
POST /admin/fleet/teams/2102345678901234567/enable
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "success": true, "data": null }
```
#### 错误响应
未关联供应商(新增语义):
```json
{ "code": 601108, "message": "车队未关联供应商,不能启用", "success": false, "data": null }
```
已是启用态(幂等放行,不重复写):
```json
{ "code": 200, "message": "成功", "success": true, "data": null }
```
车队不存在:`601100`。
#### 业务边界
- 幂等:同 fleetTeamId 重复调用只生效一次(@Idempotent key=`fleet:team:enable:{fleetTeamId}`)。
- 分布式锁:同 fleetTeamId 启停用串行化(@Lock4j key=`fleet:team:status:{fleetTeamId}`)。
- 存量迁移车队(own/coopA/coopB)当前 ACTIVE 且无供应商:enable 若已被置 DISABLED 后会被 601108 拦截,需先编辑绑定供应商再启用。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | 调用 / 结果 |
|---|---|
| ✅ 新增时不选供应商 | `{ "supplierId": null }`(或不传)→ 车队落 DISABLED,待绑定后启用 |
| ✅ 新增时选供应商 | `{ "supplierId": "2091381911266967553" }` → Feign 校验通过后落 ACTIVE |
| ✅ 编辑时供应商不变 | 原 supplierId 原样传回(或省略由后端按原值处理?——必须原样传,SaveReqVO 整份语义) |
| ✅ 首次绑定 | 原 supplierId=null,新 supplierId=有效 FLEET 供应商 → 允许(不受订单围栏) |
| ✅ 供应商改名后刷新快照 | 重新编辑车队并保存(supplierId 原样),快照重新取最新 full_name |
| ❌ 供应商 ID 转 Number | 雪花精度丢失;必须按字符串传输 |
| ❌ 已有订单车队换供应商 | 601111,不写库 |
| ❌ 已有订单车队清供应商 | 601111,不写库 |
| ❌ 清除供应商后期望仍 ACTIVE | 无订单时快照清空+强制 DISABLED;前端收到 200 但 status=DISABLED 属于预期行为 |
### 关键提示(当前 TEST 构建)
- `supplierId`/`supplierName` 在所有响应中均按字符串序列化(`@JsonSerialize(ToStringSerializer)`);**禁止**前端用 `Number()`/`parseInt()`/一元 `+` 转换。
- 供应商全名快照为**写时取数**:`supplier_main.full_name` 改名后列表展示旧值,重新编辑车队保存触发刷新。
- 供应商选择器数据源:可复用 `GET /admin/supplier/items/list?typeCode=FLEET&status=ACTIVE`(mmg 自查前端是否已有该接口封装;无则后端再补)。
---
## 五、数据库行为
- **写操作只影响 `fleet_team` 一行**(新增 insert / 编辑 update / 启停用 update),不跨服务写 `supplier_resource_rel` 或 `supplier_main`。
- 快照列:`supplier_id`(关联 ID)+ `supplier_name`(全称快照)同列写入;清除时同列置 NULL。
- 幂等窗口内重复请求只写一次;锁键串行化同车队写。
- 迁移:`V20260830_001__add_supplier_to_fleet_team.sql` 对 `fleet_team` 加 `supplier_id BIGINT NULL` + `supplier_name VARCHAR(500) NULL` + 索引 `idx_fleet_team_supplier(supplier_id)`;存量车队两列均为 NULL。
- 不动 `supplier_resource_rel`(车队供应商不走资源关系表;关系表仅用于九大资源模块)。
---
## 六、边界行为
- 未登录/登录失效:业务码 `401`(网关拦截)。
- 车队不存在:`601100`。
- 车队名称重复:`601101`(含 DuplicateKeyException 翻译)。
- 车队已停用仍选该车:`601102`(既有口径,本工单不改)。
- 车队下有在役车辆时禁止停用:`601103`(既有口径)。
- 车队已关联车辆时禁止改自有/合作类型:`601104`(既有口径)。
- 车队名下仍有车辆/司机时禁止删除:`601107`(既有口径)。
- 车队未关联供应商禁止启用:`601108`(新增)。
- 供应商不存在/未生效/不含车队类型:`601109`(新增)。
- 供应商校验依赖故障:`601110`(新增,失败关闭不写库)。
- 车队已有订单禁止更换/清除供应商:`601111`(新增)。
- Bean Validation 校验失败:`400`。
- 跨服务 Feign 不可用:写接口一律失败关闭(601110);读接口(列表/详情)读快照列,不受影响。
---
## 六.5、枚举 / 数据字典
### `status`(车队启停状态)
**所属字段**: `FleetTeamRespVO.status` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `ACTIVE` | 启用 | 可被车辆选择;**前提:已关联供应商** |
| `DISABLED` | 停用 | 不可被车辆选择;新建未选供应商时默认落此态 |
### `teamType`(车队类型)
**所属字段**: `FleetTeamSaveReqVO.teamType` / `FleetTeamRespVO.teamType` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `SELF_OPERATED` | 自有 | 自有车队 |
| `COOPERATIVE` | 合作 | 合作车队 |
### `settleType`(付款方式,字典 `resource_settle_type`)
**所属字段**: `FleetTeamSaveReqVO.settleType` / `FleetTeamRespVO.settleType` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `cash` | 现付 | - |
| `sign` | 挂账签单 | - |
| `company` | 公司月结 | - |
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `FleetTeamSaveReqVO.supplierId` | 无 | 新增可选字段,@Positive,参与幂等摘要 |
| `FleetTeamRespVO.supplierId` | 无 | 新增,String/null(ToStringSerializer) |
| `FleetTeamRespVO.supplierName` | 无 | 新增,String/null(快照) |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 新建车队默认状态 | ACTIVE | 选供应商=ACTIVE;不选=DISABLED |
| 启用校验 | 只查当前状态 | 增加 supplierId==null → 601108 |
| 编辑车队供应商 | 无此字段 | 有订单禁换/禁清(601111);清除+ACTIVE→强制 DISABLED |
| 供应商配置入口 | 前端有独立「供应商」按钮(调通用资源关系接口) | 下线;改为车队新增/编辑内嵌选择 |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否(新增可选字段;响应只多两列,前端旧版忽略即兼容)
- **前端是否必须同步上线**: 是(车队列表「供应商」按钮需下线,否则用户仍能从旧入口调通用接口——但通用接口对车队模块本工单起后端侧保留不拦,是前端入口下线)
- **前端 workaround 清理点**: 车队列表的「供应商」操作入口(`views/fleet/teams/index.vue` 中 `supplierRelaShow`/`supplierRelaRow`/`openSupplierRelation` 相关代码)整体删除
---
## 七、不影响范围
- **仅影响**: 管理后台「车辆管理 - 车队管理」列表/新增/编辑/启用 4 个端点 + 详情 1 个端点。
- **零影响**:
- 车辆档案(`fleet_vehicle`)/ 司机档案(`fleet_driver`)/ 派单(`fleet_assignment`)等车队下游域——它们继续经 `FleetTeamService.resolveForVehicle` 解析车队,供应商字段不影响车辆选车队。
- 供应商域九大资源模块(景区/餐厅/备品/组合/游玩项目/酒店/服务/额外成本/服务人员/车辆)的独立供应商关系维护(`/admin/supplier/resource-relations/{module}/{id}/update` 等通用接口保留不动)。
- 订单/对账/看板读路径(只读 fleet_team 既有字段,新增两列不影响)。
- Gateway 路由(`/admin/fleet/**` 通配已覆盖;`/internal/**` 不走网关)。
- Redis/MQ(无新增 key/消息)。
---
## 八、测试环境已验证
真实 TEST 环境实测(2026-08-30,网关 `https://api.test.1814.love:9443`,admin token 走 `/admin/auth/login`):
```
POST /admin/fleet/teams 无供应商新建 → 200, status=DISABLED, supplierId=null, supplierName=null ✓
POST /admin/fleet/teams/{id}/enable 无供应商启用 → 601108「车队未关联供应商,不能启用」 ✓
PUT /admin/fleet/teams/{id} 绑定 DRAFT 供应商 → 601109 ✓
PUT /admin/fleet/teams/{id} 绑定不存在供应商 → 601109 ✓
PUT /admin/fleet/teams/{id} 绑定非 FLEET 类型 ACTIVE 供应商(RESTAURANT) → 601109 ✓
GET /admin/fleet/teams/page → records[] 含 supplierId/supplierName 键(未关联为 null) ✓
```
- 部署:Deploy Panel 任务 `2fe21f84`(hl-fleet-service,2026-08-30 17:20 success)+ `f29a293f`(hl-resource-service,2026-08-30 17:22 success),预期/实际提交均为 `447b92f5143e9ca4381d590d4f868d518cf66e0a`(dev-v3 HEAD)。
- 正向链路(绑定合格 FLEET ACTIVE 供应商 → ACTIVE + 快照写 supplierName):测试服当前无 ACTIVE 状态的 FLEET 类型供应商(供应商审批链要求必备资质,`supplier_type_qualification_rule` 规则表为空,无法造出合格供应商);该路径本地单测已覆盖(`FleetTeamServiceTest#create_withEligibleSupplier_activeAndSnapshot` 等 24 用例全绿),建议 mmg 联调时在真实数据上补验。
- 有订单换供应商(601111)链路:测试服车队均无订单派单可安全构造验证数据,本地单测覆盖(`FleetTeamServiceTest#update_changeSupplierWithOrders_rejected` / `update_clearSupplierWithOrders_rejected`)。
- 本地自动化:fleet 全量 3883 项 0 失败 0 错误(含 FleetRedLineArchTest 13 项门禁);resource 全量 0 失败 0 错误;spotless:check 绿。
- 数据清理:测试车队(352385968453586944)与测试供应商(2093993745543299074)均已删除;未触碰同事真实订单;admin token 已登出。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #6753 | #6717 | 车队关联供应商并展示供应商全名(本次) | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#6717](https://git.1814.love:8443/wx/HL/issues/6717)
- 关联 PR: [wx/HL#6753](https://git.1814.love:8443/wx/HL/pulls/6753)
- 任务设计文档: `docs/tasks/6717-fleet-team-supplier.md`(worktree `D:\work2\HL-v3-0830-fleetsup`)
## 撤回
1. 管理端先恢复车队列表「供应商」操作入口(参照改前版本),保持线上可用。
2. 从最新 `dev-v3` 建独立回退分支,回退 PR #6753 的合并 commit(`72c9018ab` 及其后续如有),验证后经独立 PR 合入。
3. 仅需下线新供应商字段时可先保留两列(快照保留无副作用),只回退 Controller/Service 逻辑与 Feign 校验。
4. 使用 Deploy Panel 滚动部署 `hl-fleet-service` 与 `hl-resource-service`;数据库列保留不删(`supplier_id`/`supplier_name` 允许 NULL,回退后不影响)。
5. 撤回后经 Gateway 验证新增/编辑/启用接口按改前口径通过;车队列表的供应商列展示空白或下线列头。
## 关联 / 联系人
### 链接
- **Issue**: [#6717](https://git.1814.love:8443/wx/HL/issues/6717)
- **PR**: [#6753](https://git.1814.love:8443/wx/HL/pulls/6753)
- **Merge commit**: 待合并后回填
### 联系人
- **后端负责人**: @wx
- **前端联动**: @mmg(下线车队列表「供应商」按钮 + 车队新增/编辑弹窗加供应商选择器)
@@ -0,0 +1,288 @@
---
schema: "hl-changelog/v2"
ticket: "6739"
title: "供应商三级地址保存与回显"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "5f35aafd"
target_release: "v2.1"
verified_at: "2026-08-30"
status_note: "PR #6744 已合并 dev-v3,合并提交 2d24b329 已部署 TEST。前端实证 #6350 拼接单字符串 address 且 002 不回显与新契约相反,属 required。已实现:三级联动与详细地址独立成字段(countyId+address),RegionCascader 新增 getRegionPath 受控回显,编辑表单回填入快照增量比对、详情页 countyId 反查省市区名。前端 commit 5f35aafd 已推 v2.1。当前状态:已消费(frontend_status: verified)。"
updated_at: "2026-08-30"
base: "dev-v3"
---
# 供应商三级地址保存与回显
`countyId` 保存三级联动的最下级区县 ID,`address` 只保存详细地址,例如“某某路 1 号”。前端不要再使用 `addressId`。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 新增供应商草稿 | POST | `/admin/supplier/items/add` | 请求字段 | 可传 `countyId`、`address` |
| 2 | 修改供应商 | PUT | `/admin/supplier/items/{supplierId}/update` | 请求字段 | 可更新 `countyId`、`address` |
| 3 | 提交供应商 | POST | `/admin/supplier/items/{supplierId}/submit` | 请求字段 | 完整表单可传 `countyId`、`address` |
| 4 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应字段 | 顶层返回三级 ID 与 `address` |
## 三、接口详情
### 1. 新增供应商草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO / SupplierWriteRespVO`
#### 使用场景
新增草稿时同时保存区县选择和详细地址。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `countyId` | Body | String | 否 | 正整数 | 三级联动最后一级区县 ID |
| `address` | Body | String | 否 | 最长 500 字符 | 详细地址,不拼接省市区名称 |
| `fullName` / `taxNo` / `mainCooperation` | Body | String | 是 | 沿用既有规则 | 其他新增必填字段 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 新供应商 ID |
| `data.updateTime` | String | 后续修改使用的并发版本 |
#### 请求示例
```json
{"fullName":"示例旅行服务有限公司","taxNo":"91350211M000100Y46","countyId":"376","address":"某某路1号","mainCooperation":"景区合作"}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"supplierId":"2093966831894056961","status":"DRAFT","updateTime":"2026-08-30 15:39:31"}}
```
#### 空数据 / 降级响应
省略 `countyId` 或 `address` 时按 `null` 保存;依赖或业务校验失败时不产生部分写入。
#### 错误响应
```json
{"code":400,"message":"区县ID必须为正数","success":false,"data":null}
```
#### 业务边界
- 有三级联动选择结果时只传最后一级 `countyId`。
- `addressId` 不是本接口字段。
### 2. 修改供应商 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO / SupplierWriteRespVO`
#### 使用场景
编辑页修改区县或详细地址。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 | 供应商 ID |
| `countyId` | Body | String | 否 | 正整数 | 新的最下级区县 ID |
| `address` | Body | String | 否 | 最长 500 字符 | 新的详细地址 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 详情最新并发版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 供应商 ID |
| `data.updateTime` | String | 保存后的新并发版本 |
#### 请求示例
```json
{"countyId":"377","address":"某某路2号","expectedUpdateTime":"2026-08-30 15:39:31"}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"supplierId":"2093966831894056961","status":"DRAFT","updateTime":"2026-08-30 15:39:42"}}
```
#### 空数据 / 降级响应
省略 `countyId` 或 `address` 表示该字段不修改;失败时原值和版本保持不变。
#### 错误响应
```json
{"code":400,"message":"区县ID必须为正数","success":false,"data":null}
```
#### 业务边界
- 修改成功后使用响应中的新 `updateTime` 重新查询详情。
- 非正数 `countyId` 在写入前拒绝。
### 3. 提交供应商 `POST /admin/supplier/items/{supplierId}/submit`
**VO**: `SupplierSubmitReqVO / SupplierApprovalCommandRespVO`
#### 使用场景
提交完整供应商表单进入审批时一并保存区县和详细地址。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 | 草稿供应商 ID |
| `countyId` | Body | String | 否 | 正整数 | 最下级区县 ID |
| `address` | Body | String | 否 | 最长 500 字符 | 详细地址 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 当前并发版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.approvalLogId` | String | 审批记录 ID |
| `data.approvalStatus` | String | 审批状态 |
#### 请求示例
```json
{"fullName":"示例旅行服务有限公司","taxNo":"91350211M000100Y46","countyId":"377","address":"某某路2号","mainCooperation":"景区合作","expectedUpdateTime":"2026-08-30 15:39:42"}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"approvalLogId":"2093967000000000001","approvalStatus":"APPROVED"}}
```
#### 空数据 / 降级响应
`countyId`、`address` 为空时按完整表单既有规则处理;提交失败不推进审批状态。
#### 错误响应
```json
{"code":400,"message":"区县ID必须为正数","success":false,"data":null}
```
#### 业务边界
- 仍须提交完整表单和最新 `expectedUpdateTime`。
- 本次不改变既有审批、幂等和状态门禁。
### 4. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
编辑页读取三级联动默认值和详细地址。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 | 供应商 ID |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.provinceId` | String/null | 省级 ID |
| `data.prefectureId` | String/null | 市级 ID |
| `data.countyId` | String/null | 已保存的最下级区县 ID |
| `data.address` | String/null | 已保存的详细地址原值 |
#### 请求示例
```http
GET /admin/supplier/items/2093966831894056961/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"supplierId":"2093966831894056961","provinceId":"1","prefectureId":"35","countyId":"377","address":"某某路2号","updateTime":"2026-08-30 15:39:42"}}
```
#### 空数据 / 降级响应
历史供应商没有 `countyId` 时三个行政区划 ID 均为 `null`,`address` 仍按历史值返回。
#### 错误响应
```json
{"code":395001,"message":"供应商不存在","success":false,"data":null}
```
#### 业务边界
- 三个行政区划 ID 均按 JSON 字符串返回。
- 行政区划父链不可用或不完整时失败关闭,不伪造 ID。
## 四、契约约束与正确调用方式
1. 用 `/admin/region/children` 逐级选择,将最后一级 ID 作为 `countyId`,门牌内容单独放入 `address`。
2. 编辑页按 `provinceId → prefectureId → countyId` 设置默认选中,并用 `address` 初始化详细地址输入框。
3. 前端字段统一命名为 `countyId`;保存后使用新版本重新查询详情。
## 五、数据库行为
- 写接口成功后,后续详情查询返回已保存的 `countyId` 和 `address`。
- 非正数 `countyId` 校验失败时不修改原值或并发版本。
- 历史供应商不自动补行政区划 ID。
## 六、边界行为
- 未认证返回业务码 `401`;供应商不存在或已删除返回 `395001`。
- 业务失败可能仍使用 HTTP 200,前端须同时判断 `code` 与 `success`。
- `address` 不包含省市区中文文本;省市区展示由三级 ID 对应的选项生成。
## 六.6、修改前后对比
| 项目 | 修改前 | 修改后 |
|---|---|---|
| 写请求 | 仅保存详细地址 | 可同时保存最下级 `countyId` 与详细地址 |
| 详情回显 | 仅有 `address` | 顶层增加三个行政区划 ID,继续返回 `address` |
## 六.7、影响评估
- **是否破坏向后兼容**:否,历史空值继续返回 `null`。
- **前端是否必须同步上线**:是,需要传 `countyId` 并消费三级回显字段。
- **前端 workaround 清理点**:移除 `addressId` 映射和自行解析完整中文地址的逻辑。
## 七、不影响范围
- 不改变供应商权限、状态机、审批、并发和错误码。
- 不改变行政区划联动接口契约,也不影响其他资源模块。
## 八、测试环境已验证
- 新增与修改后可回读对应 `countyId`、`address`,详情三个行政区划 ID 均为字符串。
- 非正数 `countyId` 返回 `400` 且原数据不变;未认证返回 `401`;验收草稿已清理。
## 十、相关文档
- [Issue #6739](https://git.1814.love:8443/wx/HL/issues/6739)
- [PR #6744](https://git.1814.love:8443/wx/HL/pulls/6744)
## 关联 / 联系人
- **合并提交**: [2d24b3290](https://git.1814.love:8443/wx/HL/commit/2d24b32908bffb0e7ac95fd31b33cdfe56ae69f5)
- **后端负责人**: @lc
- **当前状态**: 已消费(`frontend_status: verified`)。RegionCascader 新增受控回显(watch value→getRegionPath 反查父链逐级预载,零 emit,停用节点占位);EditModal 提交体改 countyId+address 独立字段、fillForm 回填+快照增量比对(改动才携带、清空都不携带),删拼接逻辑;DetailModal 地址改 countyId 反查省市区名+address。spec 84 例绿,commit 5f35aafd 已推 v2.1。
@@ -0,0 +1,293 @@
---
schema: "hl-changelog/v2"
ticket: "6811"
title: "车队保存(无供应商)自动落停用;仍有在役车辆时以 601112 拒绝保存"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "6985f0cf"
target_release: "v2.1"
verified_at: "2026-08-31"
status_note: "#6717 只在「本次保存清除供应商」时强制停用,存量无供应商车队原样再保存仍停留启用。本单把守卫改为按保存结果判定,并对齐 disable 的在役车辆不变量新增 601112。TEST 已实测两条分支。前端已消费:submitForm 补 601112 精确提示,保存后以响应/列表刷新状态展示。"
updated_at: "2026-08-31"
base: "dev-v3"
generated: "2026-08-31T10:45:00+08:00"
---
# 车队编辑保存:无供应商时自动落停用,仍有在役车辆则以 601112 拒绝
> **服务**: hl-fleet-service (端口 8087/8187)
> **PR**: #6827
> **Issue**: #6811
> **日期**: 2026-08-31
> **影响范围**: 管理后台车务 → 车队管理 → 编辑车队保存
---
## ⚠️ 关键变化
- 保存车队时若**结果为无供应商**(`supplierId` 传 `null` 或不传),后端不再允许车队停留在「启用」:无在役车辆时自动把状态落为 `DISABLED`,前端保存成功后需按响应/列表刷新后的状态展示,不能沿用提交前的「启用」。
- 上一版(#6717)只在「本次保存把已绑供应商清空」时才强制停用,存量无供应商车队原样再保存不变状态。**该行为已收口**:现在按保存结果判定,`null → null` 同样强制停用。
- 新增错误码 **601112**:无供应商需自动停用但车队仍有在役车辆时,保存整体失败、零写入。
---
## 一、背景
`fleet_team` 的不变量是 **ACTIVE ⇒ 已绑供应商**。此前新建(无供应商落停用)和启用(无供应商 601108 拒绝)都有守卫,唯独编辑保存漏了「结果无供应商」这一路,导致管理后台车队列表出现「供应商列为 —(无供应商)+ 状态启用」的行,且反复保存也不收敛。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 修改车队 | PUT | `/admin/fleet/teams/{fleetTeamId}` | 状态副作用 + 新增错误码 | 保存结果无供应商时自动落停用;仍有在役车辆则 601112 拒绝 |
---
## 三、接口详情
### 1. 修改车队 `PUT /admin/fleet/teams/{fleetTeamId}`
**VO**: `FleetTeamSaveReqVO` → `Result<FleetTeamRespVO>`
#### 使用场景
车务 → 车队管理 → 列表行「编辑」→ 弹窗改车队名称/类型/负责人/电话/付款方式/排序/备注/供应商后点「保存」。本次仅状态副作用与错误码变化,路径、请求字段、字段类型与必填性都不变。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| fleetTeamId | Path | String(雪花 ID) | ✅ | - | 车队 ID,字符串传递,禁止数值化 |
| teamName | Body | String | ✅ | 非空、全局唯一 | 车队名称 |
| teamType | Body | String | ✅ | `SELF_OPERATED` / `COOPERATIVE` | 已关联车辆时不可切换 |
| leaderName | Body | String | ✅ | 非空 | 负责人 |
| leaderPhone | Body | String | ✅ | 手机号 | 负责人电话 |
| settleType | Body | String | ✅ | `cash` / `sign` / `company` | 付款方式(资源付款方式字典) |
| sortOrder | Body | Integer | ✅ | ≥ 0 | 排序 |
| remark | Body | String | ❌ | ≤ 256 | 备注,空串按 null 落库 |
| supplierId | Body | String(雪花 ID) | ❌ | 供应商需生效且含 FLEET 类型 | **不传或传 null = 不选供应商**,触发本次自动停用规则 |
#### 出参 `Result<FleetTeamRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| fleetTeamId | String | 车队 ID |
| teamName | String | 车队名称 |
| teamType | String | `SELF_OPERATED` / `COOPERATIVE` |
| status | String | **`ACTIVE` / `DISABLED`;保存结果无供应商时返回 `DISABLED`** |
| settleType | String | 付款方式 |
| sortOrder | Integer | 排序 |
| supplierId | String / null | 关联供应商 ID,null = 未关联 |
| supplierName | String / null | 供应商全称快照,供应商为 null 时同步清空 |
| vehicleCount | Number | 名下车辆总数 |
| activeVehicleCount | Number | 在役车辆数 |
#### 请求示例
无供应商的车队原样保存(触发自动停用):
```json
{
"teamName": "测试车队P4-1787131721",
"teamType": "COOPERATIVE",
"leaderName": "测试负责人",
"leaderPhone": "13900000001",
"settleType": "cash",
"sortOrder": 99,
"remark": "由 fleet_attribution/历史业务数据迁移,负责人待完善",
"supplierId": null
}
```
#### 响应示例
无供应商 + 无在役车辆 → 200,且 `status` 已变 `DISABLED`:
```json
{
"code": 200,
"message": "成功",
"data": {
"fleetTeamId": "348397854705979392",
"teamName": "测试车队P4-1787131721",
"teamType": "COOPERATIVE",
"status": "DISABLED",
"settleType": "cash",
"sortOrder": 99,
"supplierId": null,
"supplierName": null,
"vehicleCount": 0,
"activeVehicleCount": 0
},
"success": true
}
```
#### 空数据 / 降级响应
本接口为写接口,不返回空集。供应商资格校验依赖(resource 侧)不可用时**失败关闭**,不降级放行:
```json
{
"code": 601110,
"message": "暂时无法校验供应商,请稍后重试",
"success": false,
"data": null
}
```
#### 错误响应
新增(本次)——无供应商需自动停用但仍有在役车辆:
```json
{
"code": 601112,
"message": "车队未关联供应商需自动停用,但仍有在役车辆,请先关联供应商或转移在役车辆",
"success": false,
"data": null
}
```
本接口其他错误码(本次未变,供自包含联调):
| code | message | 触发条件 |
|------|---------|----------|
| 601100 | 车队不存在 | `fleetTeamId` 无对应车队 |
| 601101 | 车队名称已存在 | `teamName` 与其他车队重名 |
| 601104 | 车队已关联车辆,不能修改自有/合作类型 | 名下有车辆时改 `teamType` |
| 601106 | 付款方式不是有效的资源付款方式 | `settleType` 不在 `cash`/`sign`/`company` |
| 601109 | 供应商不存在、未生效或不包含车队类型 | 绑定的 `supplierId` 不合格 |
| 601110 | 暂时无法校验供应商,请稍后重试 | 供应商资格校验依赖不可用 |
| 601111 | 车队已关联订单,不能更换供应商 | 已绑供应商的车队换绑/清除且名下车辆有非取消派单 |
#### 业务边界
- 鉴权:需登录且具备车务车队管理权限;未登录网关返 401。
- **状态不由前端入参驱动**:请求体没有 `status` 字段,启停仍由 `/disable`、`/enable` 切;唯一例外是本次的自动停用副作用。
- 供应商未变时不调供应商资格校验,也不查订单围栏;仅当 `supplierId` 发生变化才校验。
- 已绑供应商的车队换绑或清除时,名下车辆若有非取消派单一律 601111(该守卫先于 601112 判定)。
- 失败零写入:601112、601111、601109、601110 均在写库前抛出,名称、排序、备注等字段都不会部分保存。
- 并发:同一 `fleetTeamId` 的保存互斥(分布式锁),重复提交不会产生中间态。
- 已停用且无供应商的车队保存时保持停用,不报错。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload 与结果 |
|------|---------------|
| ✅ 绑定合法供应商保存 | `{"supplierId": "2094238127517319170", ...}` → 200,`status` 保持原值 |
| ✅ 无供应商 + 无在役车辆 | `{"supplierId": null, ...}` → 200,`status=DISABLED` |
| ✅ 已停用 + 无供应商 | `{"supplierId": null, ...}` → 200,`status` 保持 `DISABLED` |
| ❌ 无供应商 + 有在役车辆 | `{"supplierId": null, ...}` → 601112,零写入 |
| ❌ 靠不传 `supplierId` 保留原供应商 | 不传等同传 `null`,会被当成「清除供应商」 |
### 切换状态时的必要动作
- 编辑回显后提交必须**原样回传详情里的 `supplierId`**;字段缺失就是清除语义,不存在「不传 = 不改」。
- 保存成功后不能沿用提交前的状态展示:用响应体 `data.status` 或重拉列表/详情。
- 收到 601112 时弹后端 message 即可;修复路径是「选一个合法供应商」或「先把在役车辆转走/停用」。
---
## 五、数据库行为
| 前端提交 | 保存后外部可观察结果 |
|----------|--------------------|
| `supplierId` 合法值 | 车队关联该供应商,`supplierName` 刷为当前全称快照,状态不变 |
| `supplierId=null`,无在役车辆 | 供应商关联与全称快照同时置空,状态变 `DISABLED` |
| `supplierId=null`,有在役车辆 | 零写入(名称/排序/备注等也不保存) |
本次无表结构变更、无 Flyway 迁移,存量脏数据不做批量刷状态(下次编辑保存时自然收口)。
---
## 六、边界行为
- 未登录 → 401(网关拦截)。
- 车队不存在 → 601100。
- 供应商资格依赖降级 → 601110 失败关闭,不静默放行。
- 存量无供应商且仍启用的车队:不会被后台任务批量改状态,只在下次编辑保存时收口。
- 已绑供应商的车队保存行为与之前一致,无新增拦截。
- 供应商改名后快照陈旧:仍需重新编辑车队刷新(行为未变)。
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| 请求体字段 | 无变化 | 无变化(路径、字段名、类型、必填性全部不变) |
| 响应 `data.status` | 仅当本次清除已绑供应商时可能返回 `DISABLED` | **只要保存结果无供应商且原为启用,就返回 `DISABLED`** |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 存量无供应商车队(`null → null`)原样保存 | 保持「启用」 | 无在役车辆 → 自动「停用」;有在役车辆 → 601112 |
| 清除已绑供应商(`X → null`) | 启用→停用(不查在役车辆) | 有在役车辆时改为 601112 拒绝,不再产出「停用车队挂在役车辆」脏态 |
| 绑定/换绑合法供应商 | 200,状态不变 | 不变 |
## 六.7、影响评估
- **是否破坏向后兼容**:否(请求/响应结构不变,新增一个错误码与一个状态副作用)。
- **前端是否必须同步上线**:否。旧前端不报错,但若保存后不刷新列表,页面会短暂显示陈旧的「启用」。
- **前端 workaround 清理点**:若前端曾为「无供应商仍显示启用」做过本地推断或提示,可改为直读后端 `status`。
---
## 七、不影响范围
- **仅影响**:管理后台车队编辑保存(`PUT /admin/fleet/teams/{fleetTeamId}`)。
- **零影响**:
- 新建车队 `POST /admin/fleet/teams`(无供应商本来就落停用)
- 启用/停用 `POST /admin/fleet/teams/{id}/enable`、`/disable`
- 车队列表、详情、options 读接口字段
- 车辆、司机、派单、看板、对账等其他车务接口
- 存量数据(不批量刷状态)
---
## 八、测试环境已验证
部署:`dev-v3` 合入 PR #6827(merge `740fdb834`)后部署 hl-fleet-service(8087/8187 滚动,两实例健康)。
```
PUT /admin/fleet/teams/348397854705979392 (无供应商 + 启用 + activeVehicleCount=0)
→ 200 成功,保存后 GET 详情 status=DISABLED, supplierId=null ✓
PUT /admin/fleet/teams/348398422098841600 (无供应商 + 启用 + activeVehicleCount=1)
→ 601112 「车队未关联供应商需自动停用,但仍有在役车辆…」
保存后 GET 详情 status=ACTIVE(零写入) ✓
PUT /admin/fleet/teams/2026072200010000001 (已绑供应商 + 有订单, 清除供应商)
→ 601111 既有订单围栏优先,行为未变 ✓
```
验证用车队:`348397854705979392`(测试车队P4-1787131721)、`348398422098841600`(测试车队P4-1787131856,验证用临时车辆已删除并回到 0 车)。
单测与构建:`mvn -pl hl-fleet-service -am verify` → Tests run 3887 / Failures 0 / Errors 0(Skipped 5 为既有 Release-E 跳过项);`mvn -pl hl-fleet-service spotless:check` → 799 文件 0 违规。
---
## 十、相关文档
- 前置契约:`changelogs-v2/2026-08/30_6717_车队关联供应商并展示供应商全名-修改接口-管理后台.md`
- 本次 PR:[#6827](https://git.1814.love:8443/wx/HL/pulls/6827)
## 关联 / 联系人
- Issue:[#6811](https://git.1814.love:8443/wx/HL/issues/6811)
- 后端联系人:@wx
@@ -0,0 +1,488 @@
---
schema: "hl-changelog/v2"
ticket: "6834"
title: "供应商余额支付类型与草稿编辑项"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "hl-admin"
frontend_ref: "848cca7b"
target_release: "v2.1"
verified_at: "2026-09-01"
status_note: "PR #6860 已合并 dev-v3,合并提交 6ed8e24c0f04051c9c4385c79de67f255abb4952 已部署 TEST。真实 Gateway 已验证支付类型字典、新增必填与失败零写入、余额和支付类型保存回显、草稿省略 changeReason 更新及验收数据清理。前端 hl-admin v2.1 提交 848cca7b 已交付:SupplierEditModal 新增余额 NInputNumber(数值允许负数,提交 number)+支付类型 NSelect(接 supplier_payment_type 字典存 dictValue 显 dictLabel,字典未就绪禁用提交不硬编码),balance 必填用 type:'number' 修 async-validator 误判,编辑按快照增量仅改动携带,DetailModal 回显两字段;spec 12 例绿。当前状态:后端已就绪,前端已交付。"
updated_at: "2026-09-01"
base: "dev-v3"
---
# 供应商余额支付类型与草稿编辑项
供应商新增、提交、编辑和详情增加 `balance`、`paymentType`。新增与提交时两项必填;编辑时可按增量提交,详情用于回显。支付类型从 `supplier_payment_type` 字典读取。
供应商详情 `status=DRAFT` 时,编辑页隐藏“变更原因”;其他状态继续显示并必填。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 新增供应商草稿 | POST | `/admin/supplier/items/add` | 请求字段 | `balance`、`paymentType` 必填 |
| 2 | 提交供应商审批 | POST | `/admin/supplier/items/{supplierId}/submit` | 请求字段 | 完整表单中的两个字段必填 |
| 3 | 编辑供应商 | PUT | `/admin/supplier/items/{supplierId}/update` | 请求字段与状态规则 | 可更新两个字段;草稿可省略 `changeReason` |
| 4 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应字段 | 返回 `balance`、`paymentType`、`status` |
| 5 | 查询支付类型字典类型 | GET | `/admin/dict/type` | 新增字典数据 | 可按 `supplier_payment_type` 查询字典类型 |
| 6 | 查询支付类型字典项 | GET | `/admin/dict/data/supplier_payment_type` | 新增字典数据 | 返回现付、签单、月付 |
## 三、接口详情
### 1. 新增供应商草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO / SupplierWriteRespVO`
#### 使用场景
新增供应商时录入初始余额并选择支付类型。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `balance` | Body | Number | 是 | 最多 16 位整数、2 位小数,可为正数、0 或负数 | 供应商当前余额 |
| `paymentType` | Body | String | 是 | 必须命中当前生效的 `supplier_payment_type` 字典项 | 支付类型值,传 `1`、`2` 或 `3` |
| `fullName` / `taxNo` / `mainCooperation` | Body | String | 是 | 沿用既有规则 | 其他新增必填字段 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 新供应商 ID |
| `data.status` | String | 新增草稿为 `DRAFT` |
| `data.updateTime` | String | 后续编辑使用的并发版本 |
#### 请求示例
```json
{
"fullName": "示例供应商有限公司",
"taxNo": "91350211M000100Y46",
"mainCooperation": "旅游资源合作",
"balance": 1200.50,
"paymentType": "1"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2094278854020399106",
"status": "DRAFT",
"updateTime": "2026-08-31 12:19:23"
}
}
```
#### 空数据 / 降级响应
`balance` 或 `paymentType` 省略、为 `null` 或支付类型为空白时返回业务码 `400`,不创建草稿。
#### 错误响应
```json
{"code":400,"message":"余额不能为空","success":false,"data":null}
```
```json
{"code":400,"message":"供应商支付类型不合法或已停用","success":false,"data":null}
```
#### 业务边界
- 前端必须传数值型 `balance`,不要传格式化金额字符串。
- `paymentType` 传字典 `dictValue`,不要传“现付”等展示文本。
- 校验失败时不产生供应商记录。
### 2. 提交供应商审批 `POST /admin/supplier/items/{supplierId}/submit`
**VO**: `SupplierSubmitReqVO / SupplierApprovalCommandRespVO`
#### 使用场景
把完整供应商表单提交审批时,一并提交余额和支付类型。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 草稿供应商 |
| `balance` | Body | Number | 是 | 最多 16 位整数、2 位小数 | 当前余额 |
| `paymentType` | Body | String | 是 | 当前生效字典值 | 支付类型 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 当前供应商并发版本 |
| 其他完整表单字段 | Body | 对应类型 | 按既有规则 | 沿用现有提交契约 | 不得只提交本次新增字段 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.approvalLogId` | String | 审批记录 ID |
| `data.approvalStatus` | String | 审批状态 |
#### 请求示例
```json
{
"fullName": "示例供应商有限公司",
"taxNo": "91350211M000100Y46",
"mainCooperation": "旅游资源合作",
"balance": 1200.50,
"paymentType": "1",
"expectedUpdateTime": "2026-08-31 12:19:23"
}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"approvalLogId":"2094279000000000001","approvalStatus":"PENDING"}}
```
#### 空数据 / 降级响应
两个新增字段任一为空即拒绝提交,审批状态不推进。
#### 错误响应
```json
{"code":400,"message":"支付类型不能为空","success":false,"data":null}
```
#### 业务边界
- 提交仍是完整表单命令,并继续执行既有状态、并发、审批和幂等门禁。
- 支付类型失效或不存在时失败关闭,不使用本地默认值代替。
### 3. 编辑供应商 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO / SupplierWriteRespVO`
#### 使用场景
编辑页修改余额或支付类型;根据详情的服务端状态决定是否显示变更原因。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `balance` | Body | Number | 否 | 最多 16 位整数、2 位小数 | 省略表示不修改 |
| `paymentType` | Body | String | 否 | 非空时必须命中当前生效字典项 | 省略表示不修改 |
| `changeReason` | Body | String | 条件必填 | 最长 500 | `DRAFT` 可省略;其他状态去空白后必须非空 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 详情最新并发版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 供应商 ID |
| `data.status` | String | 保存后的服务端状态 |
| `data.updateTime` | String | 保存后的新并发版本 |
#### 请求示例
`DRAFT` 草稿完全省略 `changeReason`:
```json
{
"balance": -12.34,
"paymentType": "3",
"expectedUpdateTime": "2026-08-31 12:19:23"
}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"supplierId":"2094278854020399106","status":"DRAFT","updateTime":"2026-08-31 12:19:33"}}
```
#### 空数据 / 降级响应
省略 `balance` 或 `paymentType` 表示该字段不修改;历史供应商尚未补录时,详情可返回 `null`。
#### 错误响应
非草稿缺少变更原因:
```json
{"code":400,"message":"变更原因不能为空","success":false,"data":null}
```
#### 业务边界
- 是否为草稿只取服务端锁定后的当前状态,不接收客户端自报状态。
- 草稿隐藏“变更原因”并省略 `changeReason`;非草稿继续显示且必填。
- 更新成功后使用新 `updateTime` 重新查询详情;并发失败返回既有 `395014`。
### 4. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
新增后或进入编辑页时回显余额、支付类型和当前状态。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.balance` | Number/null | 当前余额;历史未补录可为 `null` |
| `data.paymentType` | String/null | `supplier_payment_type` 字典值;历史未补录可为 `null` |
| `data.status` | String | 用于控制变更原因框 |
| `data.updateTime` | String | 编辑请求的并发版本 |
#### 请求示例
```http
GET /admin/supplier/items/2094278854020399106/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2094278854020399106",
"balance": -12.34,
"paymentType": "3",
"status": "DRAFT",
"updateTime": "2026-08-31 12:19:33"
}
}
```
#### 空数据 / 降级响应
历史供应商没有新字段时返回 `null`;前端显示未补录,不自行写入默认值。
#### 错误响应
```json
{"code":395001,"message":"供应商不存在","success":false,"data":null}
```
#### 业务边界
- `paymentType` 原样返回字典值,中文标签由字典项映射。
- 业务失败可能仍是 HTTP 200,必须同时判断 `code` 和 `success`。
### 5. 查询支付类型字典类型 `GET /admin/dict/type`
**VO**: `PageResult<SysDictTypeRespVO>`
#### 使用场景
字典管理页或联调时确认 `supplier_payment_type` 类型已经存在。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `page` | Query | Number | 否 | 默认 1 | 页码 |
| `pageSize` | Query | Number | 否 | 默认 20 | 每页条数 |
| `keyword` | Query | String | 否 | 传 `supplier_payment_type` | 按编码或名称搜索 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.records[].dictType` | String | `supplier_payment_type` |
| `data.records[].dictName` | String | `供应商支付类型` |
| `data.records[].status` | String | `ACTIVE` |
#### 请求示例
```http
GET /admin/dict/type?page=1&pageSize=20&keyword=supplier_payment_type
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"records":[{"dictType":"supplier_payment_type","dictName":"供应商支付类型","status":"ACTIVE"}],"total":1,"page":1,"pageSize":20}}
```
#### 空数据 / 降级响应
未命中时 `records=[]`;这表示字典未就绪,前端不要回退到其他结算字典。
#### 错误响应
```json
{"code":401,"message":"Token无效或已过期","success":false,"data":null}
```
#### 业务边界
- 该接口只查询字典类型,不直接提供下拉项。
- 下拉选项必须继续调用下一接口。
### 6. 查询支付类型字典项 `GET /admin/dict/data/supplier_payment_type`
**VO**: `List<SysDictDataRespVO>`
#### 使用场景
新增、编辑供应商页面加载支付类型下拉选项。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| 无 | - | - | 否 | 字典编码固定在路径中 | 无请求体、查询参数或动态路径参数 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data[].dictValue` | String | 请求保存的值 |
| `data[].dictLabel` | String | 下拉展示文案 |
| `data[].sortOrder` | Number | 排序号 |
| `data[].status` | String | 当前均为 `ACTIVE` |
#### 请求示例
```http
GET /admin/dict/data/supplier_payment_type
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{"dictValue":"1","dictLabel":"现付","sortOrder":10,"status":"ACTIVE"},
{"dictValue":"2","dictLabel":"签单","sortOrder":20,"status":"ACTIVE"},
{"dictValue":"3","dictLabel":"月付","sortOrder":30,"status":"ACTIVE"}
]
}
```
#### 空数据 / 降级响应
返回空数组或调用失败时禁用提交并提示字典加载失败,不硬编码替代值。
#### 错误响应
```json
{"code":401,"message":"Token无效或已过期","success":false,"data":null}
```
#### 业务边界
- 请求保存 `dictValue`,界面展示 `dictLabel`。
- 后端每次写入都动态校验当前生效项;已停用或未知值返回业务码 `400`。
## 四、契约约束与正确调用方式
1. 页面初始化先查询详情与支付类型字典项,用 `paymentType` 匹配 `dictValue`。
2. 新增和提交时 `balance`、`paymentType` 都必填;编辑时只发送实际修改字段和最新 `expectedUpdateTime`。
3. `status=DRAFT` 隐藏“变更原因”并可省略 `changeReason`;其他状态必须收集非空原因。
4. 金额输入保留最多两位小数,允许负数;不要把千分位格式化文本发给后端。
| 场景 | payload | 结果 |
|---|---|---|
| 新增完整 | `{ "balance": 100.00, "paymentType": "1", ... }` | 成功 |
| 新增缺余额 | `{ "paymentType": "1", ... }` | `400`,余额不能为空 |
| 新增非法支付类型 | `{ "balance": 100.00, "paymentType": "9", ... }` | `400`,支付类型不合法或已停用 |
| 草稿编辑 | `{ "balance": -12.34, "paymentType": "3", "expectedUpdateTime": "..." }` | 成功,可省略 `changeReason` |
| 非草稿编辑缺原因 | `{ "balance": 0, "expectedUpdateTime": "..." }` | `400`,变更原因不能为空 |
## 五、数据库行为
- 新增或更新成功后,详情回读相同的 `balance`、`paymentType`。
- 历史供应商不强制回填,新字段可返回 `null`;创建和提交的新数据仍由接口强制必填。
- 审批与非草稿变更继续保留两个字段的业务快照;失败时不产生部分写入。
## 六、边界行为
- `balance` 最多 16 位整数和 2 位小数,正数、0、负数均有效。
- `paymentType` 必须是当前生效字典值;未知、空白或停用值不保存。
- 未登录返回业务码 `401`;不存在返回 `395001`;并发版本过期返回 `395014`。
- 不新增权限、Gateway 路由、Redis、MQ 或配置行为。
## 六.5、枚举 / 数据字典
### `paymentType`(字典 `supplier_payment_type`)
**所属字段**: `SupplierDraftSaveReqVO.paymentType / SupplierUpdateReqVO.paymentType / SupplierBasicInfoRespVO.paymentType` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `1` | 现付 | 按现付方式处理 |
| `2` | 签单 | 按签单方式处理 |
| `3` | 月付 | 按月付方式处理 |
## 六.6、修改前后对比
| 项目 | 修改前 | 修改后 |
|---|---|---|
| 新增与提交 | 无余额、支付类型字段 | 两字段必填并校验 |
| 编辑与详情 | 无法录入和回显两字段 | 支持增量修改并原值回显 |
| 支付类型选项 | 无专用字典 | 使用 `supplier_payment_type` 三个生效项 |
| 草稿变更原因 | 页面仍可能显示 | `DRAFT` 隐藏;非草稿继续必填 |
## 六.7、影响评估
- **是否破坏向后兼容**:新增、提交请求增加必填字段,前端必须同步;编辑与详情为兼容扩展。
- **前端是否必须同步上线**:是,需要新增两个控件、接入字典并按状态调整变更原因框。
- **前端 workaround 清理点**:删除支付类型硬编码和草稿 `changeReason` 必填/展示逻辑;非草稿校验必须保留。
## 七、不影响范围
- 不修改供应商权限、状态机、审批流程、并发、软删除和既有错误码。
- 不复用 `resource_settle_type`,也不影响供应商账户、合同和资源关系接口。
- 本次仅交付后端接口 Changelog,不修改任何前端源码或资源。
## 八、测试环境已验证
- TEST Gateway 查询到字典类型及精确字典项 `1-现付`、`2-签单`、`3-月付`。
- 缺余额、缺支付类型、非法支付类型均返回 `400`,验证前后列表为空。
- 有效新增返回 `DRAFT`;详情回显 `balance=12.34`、`paymentType=1`。
- 草稿省略 `changeReason` 更新成功;详情回显 `balance=-12.34`、`paymentType=3`。
- 验收草稿已通过删除接口清理,列表为空且详情返回“供应商不存在”。
## 当前状态
- 后端:已部署并已验证。
- 前端:待处理。
## 十、相关文档
- Issue:[#6834](https://git.1814.love:8443/wx/HL/issues/6834)
- 后端 PR:[#6860](https://git.1814.love:8443/wx/HL/pulls/6860)
- 合并提交:[6ed8e24c](https://git.1814.love:8443/wx/HL/commit/6ed8e24c0f04051c9c4385c79de67f255abb4952)
## 关联 / 联系人
- **Issue**: [#6834](https://git.1814.love:8443/wx/HL/issues/6834)
- **PR**: [#6860](https://git.1814.love:8443/wx/HL/pulls/6860)
- **后端负责人**: @lc
- **当前状态**: 后端已就绪,前端待处理。
@@ -0,0 +1,373 @@
---
schema: "hl-changelog/v2"
ticket: "6842"
title: "供应商补充合同字段与附件"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "hl-admin"
frontend_ref: "f5414b12"
target_release: "v2.1"
verified_at: "2026-09-01"
status_note: "PR #6858 已合并 dev-v3,hl-resource-service 已部署提交 5546d7907c6eda9b372b4639094f84e568de6496。TEST Gateway 已验证完整字段与全空业务字段合同的新增、更新、详情回读,以及单个 PDF 上传、预览、下载和清理。前端 hl-admin v2.1 提交 f5414b12 已交付:ContractEditModal 增 businessLine/relatedMainContract/autoRenew(三态,false 不按空过滤)+SIGNED 选项,scanFileUrl 改 FileUpload 文件中心单附件(upload/token→confirm 写 ossUrl),隐藏 contractType/amount/pricingMode/settleCycle/remark 但编辑原样回传防整份替换误清;ManageModal 列重排契约固定集、SIGNED→已签约、有效期直拼上海日历日、附件预览带登录态 preview-by-url/下载直开永久地址;api/file.js 加 getContractPreviewBlobUrl。spec 编辑 16 例+管理 6 例绿。当前状态:后端已就绪,前端已交付。"
updated_at: "2026-09-01"
base: "dev-v3"
---
# 供应商补充合同字段与附件
供应商补充合同新增 `businessLine`、`relatedMainContract`、`autoRenew`,并允许写入 `SIGNED`(已签约)。所有合同业务字段均可为空,只有 `changeReason` 继续必填。
管理端登记页仅展示补合同编号、状态、合同名称、业务线、有效期、签署日期、关联主合同、自动续约和单个 Word/PDF 附件;其他兼容字段隐藏。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---:|---|---|---|---|---|
| 1 | 独立登记供应商合同 | POST | `/admin/supplier/items/{supplierId}/contracts/add` | 扩展请求与响应 | 增加三个字段,状态支持 `SIGNED` |
| 2 | 独立更新供应商合同 | PUT | `/admin/supplier/items/{supplierId}/contracts/{contractId}/update` | 扩展请求与响应 | 新字段支持补录、清空和 `false` |
| 3 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 扩展响应 | `contracts[]` 完整回读补充合同字段 |
## 三、接口详情
### 1. 独立登记供应商合同 `POST /admin/supplier/items/{supplierId}/contracts/add`
**VO**: `SupplierContractCreateReqVO / SupplierContractRespVO`
#### 使用场景
供应商已创建后登记一份补充合同;业务资料未齐时也可只提交审计原因,稍后再补录。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `contractNo` | Body | String/null | 否 | 最长 100 | 补合同编号 |
| `status` | Body | String/null | 否 | `DRAFT` / `ACTIVE` / `SIGNED` / `EXPIRED` | `SIGNED` 显示为“已签约” |
| `contractName` | Body | String/null | 否 | 最长 500 | 合同名称 |
| `businessLine` | Body | String/null | 否 | 最长 100 | 业务线,自由文本 |
| `startDate` | Body | String/null | 否 | `yyyy-MM-dd` | 有效期开始日 |
| `endDate` | Body | String/null | 否 | `yyyy-MM-dd` | 有效期结束日;两端都有值时不得早于开始日 |
| `signDate` | Body | String/null | 否 | `yyyy-MM-dd` | 合同签署日期 |
| `relatedMainContract` | Body | String/null | 否 | 最长 100 | 关联主合同外部引用 |
| `autoRenew` | Body | Boolean/null | 否 | `true` / `false` / `null` | 是 / 否 / 未登记 |
| `scanFileUrl` | Body | String/null | 否 | 最长 1000 | 文件中心确认上传后返回的永久 `ossUrl` |
| `changeReason` | Body | String | 是 | 去空白后非空,最长 500 | 登记原因,写入审计 |
`contractType`、`amount`、`pricingMode`、`settleCycle`、`remark` 仍兼容但本页面隐藏,也均可为空。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.contractId` | String | 合同 ID |
| `data.contractNo` | String/null | 补合同编号 |
| `data.status` | String/null | 包含新增可写值 `SIGNED` |
| `data.contractName` | String/null | 合同名称 |
| `data.businessLine` | String/null | 业务线 |
| `data.startDate` / `data.endDate` | String/null | 有效期日期 |
| `data.signDate` | String/null | 签署日期 |
| `data.relatedMainContract` | String/null | 关联主合同 |
| `data.autoRenew` | Boolean/null | 自动续约三态值 |
| `data.scanFileUrl` | String/null | 合同附件永久地址 |
| `data.updateTime` | String | 当前合同版本,格式 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```json
{
"contractNo": "SHCT2025070102599446",
"status": "SIGNED",
"contractName": "内蒙古牵手草原国际旅行社有限公司额外后返",
"businessLine": null,
"startDate": "2010-01-01",
"endDate": "2099-12-31",
"signDate": "2025-07-01",
"relatedMainContract": "2185933",
"autoRenew": true,
"scanFileUrl": "https://files.example.com/supplier-contract/example.pdf",
"changeReason": "补录已签约合同"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2095000000000000001",
"contractNo": "SHCT2025070102599446",
"status": "SIGNED",
"contractName": "内蒙古牵手草原国际旅行社有限公司额外后返",
"businessLine": null,
"startDate": "2010-01-01",
"endDate": "2099-12-31",
"signDate": "2025-07-01",
"relatedMainContract": "2185933",
"autoRenew": true,
"scanFileUrl": "https://files.example.com/supplier-contract/example.pdf",
"updateTime": "2026-08-31 12:00:00"
}
}
```
#### 空数据 / 降级响应
仅提交 `changeReason` 也可成功;所有合同业务字段返回 `null`,前端不得补默认状态或日期。
#### 错误响应
```json
{
"code": 400,
"message": "变更原因不能为空",
"success": false,
"data": null
}
```
#### 业务边界
- 所有合同业务字段可空;`changeReason` 不属于业务展示字段,继续必填。
- `startDate`、`endDate` 同时有值时才校验日期先后。
- `scanFileUrl` 只保存一个附件永久地址;上传、预览、下载复用文件中心。
### 2. 独立更新供应商合同 `PUT /admin/supplier/items/{supplierId}/contracts/{contractId}/update`
**VO**: `SupplierContractUpdateReqVO / SupplierContractRespVO`
#### 使用场景
补齐、修改或清空已登记合同。该接口为完整替换,省略或传 `null` 会清空对应字段。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
| `contractId` | Path | String | 是 | 正整数 ID 字符串 | 目标合同 |
| 新增接口全部业务字段 | Body | 对应类型/null | 否 | 与新增一致 | 完整替换;`autoRenew=false` 会保留为否 |
| `expectedUpdateTime` | Body | String/null | 否 | `yyyy-MM-dd HH:mm:ss` | 提供时必须匹配当前版本 |
| `changeReason` | Body | String | 是 | 去空白后非空,最长 500 | 更新原因 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.contractId` | String | 原合同 ID |
| `data.businessLine` | String/null | 更新后的业务线,空白规范为 `null` |
| `data.relatedMainContract` | String/null | 更新后的关联主合同 |
| `data.autoRenew` | Boolean/null | 精确保留 `true`、`false` 或 `null` |
| `data.status` | String/null | 可返回 `SIGNED` |
| 其余合同业务字段 | 对应类型/null | 完整替换后的值 |
| `data.updateTime` | String | 更新后的合同版本 |
#### 请求示例
```json
{
"contractNo": "SHCT2025070102599446",
"status": "SIGNED",
"contractName": "内蒙古牵手草原国际旅行社有限公司额外后返",
"businessLine": null,
"startDate": "2010-01-01",
"endDate": "2099-12-31",
"signDate": "2025-07-01",
"relatedMainContract": null,
"autoRenew": false,
"scanFileUrl": "https://files.example.com/supplier-contract/example.pdf",
"changeReason": "调整自动续约信息"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2095000000000000001",
"contractNo": "SHCT2025070102599446",
"status": "SIGNED",
"businessLine": null,
"relatedMainContract": null,
"autoRenew": false,
"updateTime": "2026-08-31 12:00:01"
}
}
```
#### 空数据 / 降级响应
仅提交 `changeReason` 可把全部业务字段清空;与当前内容完全相同时拒绝空更新,不生成新版本。
#### 错误响应
```json
{
"code": 395054,
"message": "合同有效期开始日期不能晚于结束日期",
"success": false,
"data": null
}
```
#### 业务边界
- 页面隐藏兼容字段时,编辑既有合同应从详情原样回传其 `contractType`、`amount`、`pricingMode`、`settleCycle`、`remark`,避免完整替换误清历史值。
- `autoRenew=false` 是有效业务值,不能按空值过滤。
- `expectedUpdateTime` 可省略;提供过期值时返回 `395014`,合同保持不变。
### 3. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO / SupplierContractRespVO`
#### 使用场景
进入供应商编辑或详情页时,从 `data.contracts[]` 回显合同表格和附件操作。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID 字符串 | 目标供应商 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.contracts` | Array | 当前有效合同,按合同 ID 升序 |
| `data.contracts[].businessLine` | String/null | 业务线 |
| `data.contracts[].relatedMainContract` | String/null | 关联主合同 |
| `data.contracts[].autoRenew` | Boolean/null | 自动续约三态值 |
| `data.contracts[].status` | String/null | `SIGNED` 显示为“已签约” |
| `data.contracts[].scanFileUrl` | String/null | 单个合同附件永久地址 |
| 其余合同字段 | 对应类型/null | 与新增、更新响应一致 |
#### 请求示例
```http
GET /admin/supplier/items/2095000000000000000/basic-info/view
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2095000000000000000",
"contracts": [
{
"contractId": "2095000000000000001",
"contractNo": "SHCT2025070102599446",
"status": "SIGNED",
"contractName": "内蒙古牵手草原国际旅行社有限公司额外后返",
"businessLine": null,
"startDate": "2010-01-01",
"endDate": "2099-12-31",
"signDate": "2025-07-01",
"relatedMainContract": "2185933",
"autoRenew": true,
"scanFileUrl": "https://files.example.com/supplier-contract/example.pdf"
}
]
}
}
```
#### 空数据 / 降级响应
没有合同记录时 `contracts=[]`;合同存在但某项未登记时,对应字段为 `null`。
#### 错误响应
```json
{
"code": 395001,
"message": "供应商不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 日期均为上海业务日字符串,前端按 `yyyy-MM-dd` 原样展示,不做 UTC 日期转换。
- 查询只返回未删除合同;附件为空时隐藏预览、下载操作。
- 未登录请求由 Gateway 返回业务码 `401`。
## 四、契约约束与正确调用方式
1. 表格列固定映射为 `contractNo`、`status`、`contractName`、`businessLine`、`startDate/endDate`、`signDate`、`relatedMainContract`、`autoRenew`、`scanFileUrl`;`SIGNED` 展示“已签约”。
2. `startDate/endDate` 直接按上海日历日拼成有效期;`autoRenew=true` 可显示“到期自动续约”,`false` 显示“否”,`null` 显示未登记占位。
3. 附件复用文件中心:申请上传凭证 `POST /admin/file/upload/token`,按返回模式上传后调用 `POST /admin/file/upload/confirm`,把确认响应的 `data.ossUrl` 写入 `scanFileUrl`。组件仅保留一个 `.doc`、`.docx` 或 `.pdf` 文件。
4. 预览使用带登录态的 `GET /admin/file/preview-by-url?url={encodeURIComponent(scanFileUrl)}`;下载直接访问 `scanFileUrl`。
5. 本页面隐藏 `contractType`、`amount`、`pricingMode`、`settleCycle`、`remark`;编辑历史合同时仍从详情原样回传,避免完整替换清空旧值。
## 五、数据库行为
这里只约定外部可观察结果:新增或更新成功后,再次请求基本信息详情可读到相同字段;未填写值返回 `null`,`autoRenew=false` 不会丢失。失败响应不产生部分合同变更。
## 六、边界行为
- 所有合同业务字段可空,只有新增、更新、删除操作的 `changeReason` 必填。
- `SIGNED` 仅是新增可写状态,不改变既有 `DRAFT`、`ACTIVE`、`EXPIRED` 和历史 `TERMINATED` 的兼容读取。
- 日期两端都有值且开始日晚于结束日时返回 `395054`,整次更新零写入。
- 附件字段为单值;前端负责限制为一个 Word/PDF,并在替换附件后提交新的永久地址。
- 写接口沿用既有角色、权限、幂等、锁和审计规则,无新增错误码。
## 六.6、修改前后对比
| 项目 | 修改前 | 修改后 |
|---|---|---|
| 业务线、关联主合同、自动续约 | 无字段 | 新增 `businessLine`、`relatedMainContract`、`autoRenew`,均可空 |
| 合同状态 | 可写 `DRAFT` / `ACTIVE` / `EXPIRED` | 追加可写 `SIGNED`(已签约) |
| 详情回读 | 无新增三字段 | `contracts[]` 完整回读新增三字段 |
| 其他合同业务字段 | 可空 | 继续可空,语义不变 |
## 六.7、影响评估
- **是否破坏向后兼容**:否,均为可选字段和状态枚举扩展。
- **前端是否必须同步上线**:是,需要新增列、附件控件和 `SIGNED` 中文映射,并隐藏非目标字段。
- **前端 workaround 清理点**:不要在本地拼装缺失字段;以详情 `contracts[]` 为回显真相。
## 七、不影响范围
- 不修改供应商主体、联系人、资质、结算账户、审批和归档接口。
- 不把合同并回供应商注册聚合;仍先取得 `supplierId`,再调用独立合同接口。
- 不新增 Gateway 路由、权限点、配置、Redis 或 MQ 契约。
## 八、测试环境已验证
- 已在 TEST Gateway 验证完整字段和仅含 `changeReason` 的合同均可新增并跨请求回读;`SIGNED`、`null`、`false`、日期错误零写入均符合本契约。
- 已验证一个 PDF 经文件中心上传后可预览、下载,验收产生的合同、附件和会话均已清理。
## 当前状态
- 后端:已部署并已验证。
- 前端:待处理。
## 十、相关文档
- Issue:[#6842](https://git.1814.love:8443/wx/HL/issues/6842)
- 后端 PR:[#6858](https://git.1814.love:8443/wx/HL/pulls/6858)
- 合并提交:[5546d790](https://git.1814.love:8443/wx/HL/commit/5546d7907c6eda9b372b4639094f84e568de6496)
## 关联 / 联系人
- **Issue**: [#6842](https://git.1814.love:8443/wx/HL/issues/6842)
- **PR**: [#6858](https://git.1814.love:8443/wx/HL/pulls/6858)
- **后端负责人**: @lc
@@ -0,0 +1,877 @@
---
schema: "hl-changelog/v2"
ticket: "6843"
title: "供应商暂停合作联动停用车队与派单拦截"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "f1b861c6"
target_release: "v2.1"
verified_at: "2026-08-31"
status_note: "PR #6868 已合并 dev-v3(merge commit df78cce5)已部署测试服。前端已消费:useAssignFlow 补 605074 专用分支「车队已停用不可派新单,请先启用车队或换车」;precheck 前端无调用方白名单属 no-op;司机候选静默排除无适配量。"
updated_at: "2026-08-31"
base: "dev-v3"
---
# 车务派单:供应商暂停合作联动停用车队,派单全链路拦截停用车队资源
> **存放目录**:
> - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/2026-08/`
>
> **服务**: hl-fleet-service(端口 8087/8187,双实例滚动)
> **PR**: #6868
> **Issue**: #6843
> **作者**: wx
> **更新时间**: 2026-08-31
> **影响范围**: 管理后台「车务 - 派单」候选查询 / 预校验 / 创建(批量)/ 改派;新增服务间 internal 联动端点(前端不直接调用)
供应商暂停合作(SUSPENDED)/拉黑(BLACKLIST)后,其名下在启车队被批量联动停用;停用车队的车辆与常驻司机不再可派新单。**已派订单不受影响**(存量派单的最终确认、撤销取消不挂新校验)。
---
## ⚠️ 关键变化
- **precheck 新增 warning type `fleet_team_disabled`**:`POST /admin/fleet/assignments/precheck` 的 `warnings[]` 新增该类型(文案「车队已停用不可派新单,提交派单将被拦截,请先启用车队或换车」)。前端若按 type 白名单渲染提示,需把新 type 加入渲染映射,否则该提示会被静默丢弃。
- **新错误码 605074**:「车队已停用不可派新单(请先启用车队或换车)」。派单创建(批量,单派同路径)与改派在写库前硬拦截;前端错误码字典需补 605074 文案映射,建议引导动作 = 启用车队或更换车辆/司机。
- **司机候选静默排除**:`POST /admin/fleet/assignments/candidates` 的司机候选不再返回「常驻车所属车队已停用」的司机(无新增/删除字段,结果集合缩小;车辆侧自 #6717 起已排除)。
- **新增 internal 端点** `POST /internal/fleet-teams/supplier-status-sync`:仅供供应商域(resource 侧,工单 #6844)Feign 调用,**前端不直接调用、网关不路由**;本文按模板写全契约供服务间对齐。
---
## 一、背景
工单 #6843:供应商生命周期进入「停止合作」态(SUSPENDED=暂停合作 / BLACKLIST=拉黑)时,其名下关联车队应联动停用,且停用车队的司机/车辆不得再派新订单。
前置条件已由 #6717 建好:车队主数据持有 `supplierId`/`supplierName` 快照,车队停用会合并车辆可派状态(车辆侧候选过滤与 605037 拦截已存在)。本单补齐两环:
1. **联动入口(fleet 侧接收端)**:新增 internal 端点接收供应商域的状态同步事件,CAS 批量停用该供应商名下 `status=ACTIVE` 的车队。与人工停用的差异:供应商侧停用是**强制联动**,不做「在役车辆」守卫(车队不能再接新单与名下车辆是否仍在役无关)。
2. **派单链路拦截**:候选查询排除(司机侧新增)+ precheck 预警(新增 type)+ 创建/改派锁内硬校验(新错误码 605074,先于车辆/司机自身状态校验 605037/605038 执行)。
供应商侧通知发出端由工单 #6844 实现(本 PR 合并后指派),届时按本文 §三.1 契约 Feign 投递。车队域暂无独立操作日志表,停用结果经响应回执 + 服务端日志留痕。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 供应商状态同步(内部) | POST | `/internal/fleet-teams/supplier-status-sync` | 新增接口 | SUSPENDED/BLACKLIST 批量停用名下在启车队;同 eventId 重投返 duplicate=true 成功回执 |
| 2 | 查询派单候选资源 | POST | `/admin/fleet/assignments/candidates` | 行为变化 | 司机候选排除常驻车所属车队已停用的司机(无字段变化,集合缩小) |
| 3 | 派单预校验冲突 | POST | `/admin/fleet/assignments/precheck` | 响应新增枚举值 | `warnings[]` 新增 type `fleet_team_disabled`(只提示不阻断) |
| 4 | 批量创建派单 | POST | `/admin/fleet/assignments/batch` | 新增错误码 | 车队停用硬校验 605074,先于 605037/605038;单派/批量同路径 |
| 5 | 修改派单(改派) | POST | `/admin/fleet/assignments/{assignmentId}/change` | 新增错误码 | 换车/换司机到停用车队资源被 605074 拦截;纯费用调整司机侧不查 |
---
## 三、接口详情
### 1. 供应商状态同步(内部) `POST /internal/fleet-teams/supplier-status-sync`
**VO**: `SupplierStatusSyncReqVO` / `Result<SupplierStatusSyncRespVO>`
#### 使用场景
供应商域(resource 侧,工单 #6844)在供应商生命周期变更为 SUSPENDED/BLACKLIST 后,经 Feign LB 直连投递本端点;fleet 侧批量停用该供应商名下 `status=ACTIVE` 的车队,让派单链路(候选排除 + 创建/改派硬校验 605074)不再放行其资源。
`/internal` 前缀**不经网关路由**(网关实测返回业务码 404「接口不存在」),`X-Internal-Token` 由 InternalAuthFilter 统一校验。前端不直接调用本端点。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `supplierId` | Body | String(Long) | 是 | `@Positive` | 供应商 ID(雪花,建议按字符串传输,禁止转 JavaScript Number) |
| `targetStatus` | Body | String | 是 | ≤32,大小写不敏感 | 供应商目标状态;仅 `SUSPENDED`/`BLACKLIST` 触发联动停用,其余状态忽略 |
| `eventId` | Body | String | 是 | ≤64 | 事件 ID(幂等键,同一事件重复投递不产生二次变更) |
| `occurredAt` | Body | String/null | 否 | ISO 日期时间 | 事件发生时间(观测/日志用;本端不落供应商状态镜像,不做乱序围栏) |
#### 出参 `Result<SupplierStatusSyncRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `supplierId` | String | 供应商 ID(回显,雪花序列化为字符串) |
| `targetStatus` | String | 供应商目标状态(回显调用方传值) |
| `ignored` | Boolean | 是否忽略(目标状态非 SUSPENDED/BLACKLIST,未做联动) |
| `duplicate` | Boolean | 是否重复投递(true=同 eventId 幂等窗口内已消费,不产生二次变更;重复回执 `disabledCount` 恒 0、清单恒空) |
| `disabledCount` | Integer | 本次实际停用的车队数(CAS 实际影响行数;并发人工停用时可能小于快照大小;重复投递为 0) |
| `disabledTeamIds` | String[] | 本次停用的车队 ID 列表(停用前快照,雪花字符串,供调用方核对) |
#### 请求示例
```json
{
"supplierId": "8996843000000000001",
"targetStatus": "SUSPENDED",
"eventId": "supplier-status-8996843000000000001-SUSPENDED-20260831142000",
"occurredAt": "2026-08-31T14:20:00"
}
```
(传输头:`POST /internal/fleet-teams/supplier-status-sync`,`Content-Type: application/json`,`X-Internal-Token: <服务间令牌>`,Feign LB 直连不经网关。)
#### 响应示例
首次投递(TEST 实测:该供应商名下 1 个 ACTIVE 车队被停用,名下有在役车辆仍强制停用——供应商联动不做在役车辆守卫):
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "8996843000000000001",
"targetStatus": "SUSPENDED",
"ignored": false,
"duplicate": false,
"disabledCount": 1,
"disabledTeamIds": ["352696323818000384"]
}
}
```
#### 空数据 / 降级响应
- 目标状态非停止合作类(如恢复 ACTIVE):不联动、零写入,正常返回 `ignored=true`、`disabledCount=0`、`disabledTeamIds=[]`(恢复合作不自动启用是本单边界,车队需人工启用,安全侧)。
- 供应商名下无 ACTIVE 车队(含窗口外重投、车队已被人工停用):CAS 守卫自然命中 0 行,返回 `ignored=false`、`disabledCount=0`、`disabledTeamIds=[]`,仍属成功回执,调用方不应告警。
- 同 `eventId` 幂等窗口(24h)内重复投递:返回 `duplicate=true` 的成功回执(「已消费」语义),`disabledCount=0`、`disabledTeamIds=[]`(首次投递的实际停用明细不随幂等键留存,重复回执只承诺「已处理」);供应商侧「失败即重试告警」不应被触发。
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "8996843000000000001",
"targetStatus": "SUSPENDED",
"ignored": false,
"duplicate": true,
"disabledCount": 0,
"disabledTeamIds": []
}
}
```
#### 错误响应
缺少必填字段(Bean Validation,TEST 实测缺 `eventId`):
```json
{ "code": 400, "message": "事件ID不能为空", "success": false, "data": null }
```
`X-Internal-Token` 缺失或错误(InternalAuthFilter 拦截,原始响应为过滤器直出、字段名为 `msg`,TEST 实测):
```json
{ "code": 403, "msg": "内部接口禁止外部访问" }
```
经网关误调(`/internal/**` 无网关路由,TEST 实测 HTTP 200 传输层 + 业务码 404):
```json
{ "code": 404, "message": "接口不存在: /internal/fleet-teams/supplier-status-sync", "success": false, "data": null }
```
#### 业务边界
- 幂等双保险:① `@Idempotent` 按 `eventId` 开窗 24h(覆盖 MQ/Feign 延迟重投),窗口内重投由 Controller 转 `duplicate=true` 成功回执;② 窗口外重投由 Mapper CAS 守卫(仅停用 `status=ACTIVE` 行)兜底,自然命中 0 行。
- 状态匹配大小写不敏感(防调用方传小写被静默忽略);回执原样回显调用方传值。
- 与人工停用的差异:供应商联动是强制停用,**不做在役车辆守卫**;存量派单/槽位/占用不在联动范围。
- 本端不落供应商状态镜像、不做乱序围栏——迟到旧事件依赖调用方按事件流顺序投递。
- `supplierId`/`disabledTeamIds[]` 均按字符串序列化(`@JsonSerialize(ToStringSerializer)`),禁止转 JavaScript Number。
---
### 2. 查询派单候选资源 `POST /admin/fleet/assignments/candidates`
**VO**: `AssignmentCandidateReqVO` / `Result<AssignmentCandidateRespVO>`
#### 使用场景
派单 Step2 选车/选司机时查询候选资源。**本单行为变化**:司机候选排除「常驻车所属车队已停用」的司机(TEST 实测:司机斯琴常驻车挂到停用车队后,候选从 1 条变为 0 条;重新启用车队后恢复 1 条)。车辆侧排除自 #6717 起已存在(车队停用合并车辆可派状态),本单未改。
请求/响应**结构零字段变化**——排除是静默的,不返回被排除司机及其原因。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `startDate` | Body | String(日期) | 是 | `yyyy-MM-dd` | 用车开始日期 |
| `endDate` | Body | String(日期) | 是 | `yyyy-MM-dd`,≥startDate | 用车结束日期 |
| `orderId` | Body | String(Long)/null | 否 | - | 当前订单 ID;改派排除自身时必填 |
| `requirementId` | Body | String(Long)/null | 否 | - | 当前用车需求 ID;改派排除自身时必填 |
| `fleetItemIndex` | Body | Integer/null | 否 | ≥0 | 需求车型项序号 |
| `vehicleKeyword` | Body | String/null | 否 | - | 车辆关键词(车牌/车型/常驻司机姓名或完整手机号) |
| `driverKeyword` | Body | String/null | 否 | - | 司机关键词(姓名/完整手机号/常驻车牌) |
| `fleetTeamId` | Body | String(Long)/null | 否 | - | 按车队过滤车辆候选(雪花字符串) |
| `vehicleTypeId` | Body | String(Long)/null | 否 | - | 按车型大类过滤 |
| `driverAvailability` | Body | String | 否 | `AVAILABLE`/`ALL`,默认 `ALL` | 司机可用性筛选 |
| `selectedVehicleId` | Body | String(Long)/null | 否 | - | 已选车辆 ID(先选车后选司机场景) |
| `selectedDriverId` | Body | String(Long)/null | 否 | - | 已选司机 ID(先选司机后选车场景) |
| `excludeAssignmentId` | Body | String(Long)/null | 否 | 须属当前订单与需求 | 改派时排除的当前派单 ID |
#### 出参 `Result<AssignmentCandidateRespVO>`
顶层字段(与改前完全一致,无新增/删除):
| 字段 | 类型 | 说明 |
|------|------|------|
| `vehicles` | Object | 车辆候选分页(`records[]` + `total`);停用车队名下车辆继续被排除(#6717 既有口径) |
| `drivers` | Object | 司机候选分页(`records[]` + `total`);**本单起追加排除常驻车所属车队已停用的司机** |
| `fleetTeamFacets` | Array | 车队聚合筛选项 |
| `vehicleTypeFacets` | Array | 车型大类聚合筛选项 |
| `selectedDriverResidentVehicle` | Object/null | 已选司机的常驻车信息 |
| `selectedDriverResidentVehicles` | Array | 已选司机的全部常驻车 |
| `selectedVehicleResidentDriver` | Object/null | 已选车辆的常驻司机 |
| `selectedRelation` | Object/null | 已选车+司机的常驻关系 |
| `suggestedDriverId` | String(Long)/null | 自动代入司机 ID(雪花字符串) |
| `suggestedDriverReason` | String | 自动代入原因码 |
| `suggestedDriverMessage` | String | 自动代入原因展示文案 |
| `canonicalSnapshot` | Object | Step2 canonical 快照(代际/版本/槽位集合) |
`drivers.records[]` 项关键字段(结构未变,列与联调相关者):
| 字段 | 类型 | 说明 |
|------|------|------|
| `driverId` | String | 司机 ID(雪花字符串,禁止转 Number) |
| `name` | String | 司机姓名 |
| `maskedPhone` | String | 司机手机(脱敏,如 `135****5001`) |
| `driverStatus` | String | 占用态(`idle`/`busy`/`rest`/`pending`) |
| `season` | String | 赛季态(候选恒 `active`,其余赛季已过滤) |
| `residentVehicleId` | String/null | 常驻车辆 ID(常驻车所属车队停用时,该司机整体不出现在候选) |
| `residentVehiclePlate` | String/null | 常驻车辆车牌 |
| `available` | Boolean | 是否覆盖整个请求日期范围可用 |
| `selectable` | Boolean | 本次选车场景下是否可被选中 |
#### 请求示例
```http
POST /admin/fleet/assignments/candidates
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"startDate": "2026-09-10",
"endDate": "2026-09-12",
"driverKeyword": "斯琴"
}
```
#### 响应示例
司机常驻车所属车队**启用**时(TEST 实测基线,斯琴在候选内):
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"drivers": {
"total": 1,
"records": [
{
"driverId": "2065272145289658370",
"name": "斯琴",
"maskedPhone": "135****5001",
"years": 17,
"driverStatus": "idle",
"season": "active",
"licenseType": "A3",
"licenseExpired": false,
"available": true,
"selectable": true
}
]
}
}
}
```
#### 空数据 / 降级响应
车队停用后同一查询(TEST 实测:`records=[]`、`total=0`、`code=200`)——排除是静默的,响应结构不变、不附带被排除原因;前端按「无匹配司机」渲染即可,无需特殊处理。车辆候选同理(车队停用后名下车辆不再返回)。
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"drivers": { "total": 0, "records": [] }
}
}
```
#### 错误响应
缺少必填日期(Bean Validation):
```json
{ "code": 400, "message": "用车开始日期不能为空", "success": false, "data": null }
```
未登录(网关拦截):
```json
{ "code": 401, "message": "未登录", "success": false, "data": null }
```
#### 业务边界
- 司机侧排除口径 = 常驻关系(`fleet_vehicle.primary_driver_id`)反查:司机任一常驻车所属车队为 DISABLED 即被排除,与司机列表「所属车队」筛选同源(同一反查端口,防口径漂移)。
- **无常驻车的司机不受车队联动约束**(司机档案无车队列,本期不加列)。
- 排除只读过滤,不写任何表;被排除司机在司机档案/列表页不受影响,仅派单候选场景不可见。
- 车队长停用后重新启用,候选即时恢复(无缓存延迟,TEST 实测启用后同查询恢复 1 条)。
---
### 3. 派单预校验冲突 `POST /admin/fleet/assignments/precheck`
**VO**: `PrecheckReqVO` / `Result<PrecheckRespVO>`
#### 使用场景
Step2 选定车辆 + 司机后、保存前预探冲突。**本单变化**:`warnings[]` 新增 type `fleet_team_disabled`——车辆所属车队停用、或司机常驻车所属车队停用时给出预警(对齐创建/改派锁内 605074 硬校验,precheck 只提示不抛)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `vehicleId` | Body | String(Long) | 是 | 雪花字符串 | 车辆 ID |
| `driverId` | Body | String(Long) | 是 | 雪花字符串 | 司机 ID |
| `startDate` | Body | String(日期) | 是 | `yyyy-MM-dd` | 用车开始日期(闭区间起点) |
| `endDate` | Body | String(日期) | 是 | `yyyy-MM-dd` | 用车结束日期(闭区间终点) |
| `orderId` | Body | String(Long)/null | 否 | - | 订单 ID(仅日志/上下文) |
| `pickupAt` | Body | String/null | 否 | - | 接客地(城市衔接判定用) |
| `dropoffAt` | Body | String/null | 否 | - | 送客地(城市衔接判定用) |
| `headcount` | Body | Integer/null | 否 | ≥0 | 人数(座位不足 warning 判定用) |
| `excludeAssignmentId` | Body | String(Long)/null | 否 | - | 改派预校验排除自身 |
#### 出参 `Result<PrecheckRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `conflict` | Boolean | 是否存在阻断性冲突;资源不可用(含车队停用)时为 true,此时阻断原因在 `warnings[]` 体现、`conflicts[]` 可为空 |
| `conflicts` | Array | 冲突明细(`type`/`conflictAssignmentId`/`conflictOrderNo`/`conflictDateRange`/`cityJunctionShareCandidate`/`msg`),无冲突为空数组 |
| `warnings` | Array | 非阻断提示(`type` + `msg`);**本单新增 type 值 `fleet_team_disabled`** |
`warnings[]` 项字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `type` | String | 提示类型;新增 `fleet_team_disabled`=车队已停用(全集见 §六.5) |
| `msg` | String | 提示文案,新 type 恒为「车队已停用不可派新单,提交派单将被拦截,请先启用车队或换车」 |
#### 请求示例
```http
POST /admin/fleet/assignments/precheck
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"vehicleId": "2094307514693648385",
"driverId": "2065272145289658370",
"startDate": "2026-09-10",
"endDate": "2026-09-12"
}
```
#### 响应示例
车队停用后(TEST 实测:车辆侧 `vehicle_unavailable` 与新增 `fleet_team_disabled` 同时出现,资源不可用使 `conflict=true` 但 `conflicts=[]`):
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"conflict": true,
"conflicts": [],
"warnings": [
{ "type": "vehicle_unavailable", "msg": "车辆处于维保或停用状态" },
{ "type": "fleet_team_disabled", "msg": "车队已停用不可派新单,提交派单将被拦截,请先启用车队或换车" }
]
}
}
```
#### 空数据 / 降级响应
无冲突无提示(车队启用基线,TEST 实测):`conflict=false`、`conflicts=[]`、`warnings=[]`、`code=200`。precheck 为只读咨询,恒 code=200 不抛业务异常;车辆/司机不存在时也不抛错,而是落 `vehicle_unavailable`/`driver_unavailable` warning + `vehicle_not_found`/`driver_not_found` conflict 明细(既有 #5629 口径)。
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": { "conflict": false, "conflicts": [], "warnings": [] }
}
```
#### 错误响应
缺少必填车辆 ID(Bean Validation):
```json
{ "code": 400, "message": "车辆 ID 不能为空", "success": false, "data": null }
```
未登录(网关拦截):
```json
{ "code": 401, "message": "未登录", "success": false, "data": null }
```
#### 业务边界
- precheck 只提示不阻断提交动作本身;最终一致以 create/change 锁内重校验(605074)为准——车务忽略预警强行提交会被 605074 拦截。
- 车辆所属车队行缺失(历史数据无车队)时 `fleet_team_disabled` 不触发,仍走 `vehicle_unavailable` 既有口径。
- 司机侧判定 = 常驻车所属车队停用;无常驻车司机不产生本 warning。
- 前端若按 type 白名单渲染 warning,需把 `fleet_team_disabled` 加入映射(建议样式与 `vehicle_unavailable` 同级:醒目提示 + 引导换车/启用车队)。Swagger 注解中的 type 枚举列举未同步新值,以本文 §六.5 全集为准。
---
### 4. 批量创建派单 `POST /admin/fleet/assignments/batch`
**VO**: `BatchCreateAssignmentReqVO` / `Result<BatchAssignmentWriteRespVO>`
#### 使用场景
派单 Step2 提交(单派/批量同路径,经同一锁内创建逻辑)。**本单变化**:新增车队状态硬校验——所选车辆所属车队、或所选司机常驻车所属车队非 ACTIVE 时,写库前抛 **605074**,先于车辆/司机自身状态校验(605037/605038)执行,避免车队停用被误报成「车辆维保/停用」。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `orderId` | Body | String(Long) | 是 | 雪花字符串 | 订单 ID |
| `requirementId` | Body | String(Long) | 是 | 雪花字符串 | 用车需求 ID |
| `startDate` | Body | String(日期) | 是 | `yyyy-MM-dd` | 用车开始日期 |
| `endDate` | Body | String(日期) | 是 | `yyyy-MM-dd` | 用车结束日期 |
| `requestId` | Body | String | 是 | 非空,幂等键 | 批次幂等请求标识(重复提交同 requestId + 同载荷幂等放行) |
| `items` | Body | Array | 是 | 每项含 `fleetItemIndex`/`vehicleId`/`driverId`(均必填) | 派车明细;`vehicleId`/`driverId` 为雪花字符串 |
| `pickupAt` | Body | String/null | 否 | - | 接客地 |
| `dropoffAt` | Body | String/null | 否 | - | 送客地 |
| `headcount` | Body | Integer/null | 否 | ≥0 | 乘客人数(不含司机) |
| `chargeableServiceDates` | Body | Array/null | 否 | - | 收取车费的服务日期 |
| `confirmCrossResident` | Body | Boolean/null | 否 | - | 跨常驻车派单显式确认 |
#### 出参 `Result<BatchAssignmentWriteRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `assignments` | Array | 创建结果列表(`fleetItemIndex` + `assignment` 写入结果,含 `assignmentId` 雪花字符串) |
| `failedFleetItemIndex` | Integer/null | 失败的需求项序号(整批事务回滚,任一失败全部不生效) |
| `dailyDifferences` | Array/null | 逐日差异提示 |
#### 请求示例
```http
POST /admin/fleet/assignments/batch
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"orderId": "2087157006417657857",
"requirementId": "2087157006845476865",
"startDate": "2026-09-10",
"endDate": "2026-09-12",
"headcount": 4,
"requestId": "web-step2-<uuid>",
"items": [
{ "fleetItemIndex": 0, "vehicleId": "2094307514693648385", "driverId": "2065272145289658370" }
]
}
```
#### 响应示例
成功(结构示例;本次 TEST 未跑通正向创建,原因见 §八):
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"assignments": [
{
"fleetItemIndex": 0,
"assignment": { "assignmentId": "2094310000000000001", "assignmentStatus": "assigned" }
}
],
"failedFleetItemIndex": null,
"dailyDifferences": null
}
}
```
#### 空数据 / 降级响应
本接口为写接口,无空数据分支;整批任一 item 失败即同事务回滚,不产生半批状态。幂等重试(同 `requestId` + 同载荷)返回首个成功结果,不重复建单。
#### 错误响应
车队已停用(新增;车辆所属车队或司机常驻车所属车队非 ACTIVE,写库前拦截、零写入):
```json
{ "code": 605074, "message": "车队已停用不可派新单(请先启用车队或换车)", "success": false, "data": null }
```
行程已结束的需求不可再派(既有口径,TEST 实测优先级高于 605074——过期需求先撞本码):
```json
{ "code": 605047, "message": "行程已结束,派车信息只读,不能修改或改派", "success": false, "data": null }
```
车辆自身维保/停用(既有;车队校验通过后才轮到本码):
```json
{ "code": 605037, "message": "车辆处于维保或停用状态,不能派车", "success": false, "data": null }
```
#### 业务边界
- 校验顺序(锁内):需求项占用守卫 → **车队状态(605074)** → 车辆状态(605037)→ 司机状态(605038)→ 司机赛季(605006/605013)→ 跨常驻确认 → 档期重叠。
- 只拦「产生新占用」的创建入口;存量派单的最终确认/撤销取消不挂本校验——供应商事后停用不卡死在途单。
- 车队行缺失(历史车辆无车队)不归 605074,仍由合并后车辆状态走 605037 老口径。
- 幂等:同 `requestId` 重放返回首个结果;分布式锁串行化同需求写入。
- 行程已结束(605047)、需求版本过期(605905)等前置守卫先于车队校验命中。
---
### 5. 修改派单(改派) `POST /admin/fleet/assignments/{assignmentId}/change`
**VO**: `ChangeAssignmentReqVO` / `Result<ChangeAssignmentRespVO>`
#### 使用场景
车务对在途派单换车/换司机/调整费用。**本单变化**:改派目标为停用车队资源时写库前抛 **605074**(TEST 实测:把在途派单改到停用车队的车辆+司机,返回 605074 且原派单零副作用)。纯费用调整(车人未换)不查司机常驻车队——在途单不因司机另一辆常驻车挂的车队被供应商事后停用而卡死费用修订。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `assignmentId` | Path | String(Long) | 是 | 雪花字符串 | 目标派单 ID |
| `effectiveDate` | Body | String(日期) | 是 | 须在派单服务日期范围内 | 改派生效日期 |
| `newVehicleId` | Body | String(Long)/null | 否 | 与 `newDriverId` 至少其一 | 新车辆 ID(雪花字符串) |
| `newDriverId` | Body | String(Long)/null | 否 | 与 `newVehicleId` 至少其一 | 新司机 ID(雪花字符串) |
| `reason` | Body | String | 是 | 非空 | 修改原因 |
| `requestId` | Body | String | 是 | 非空,幂等键 | 请求幂等 ID |
| `serviceDates` | Body | Array/null | 否 | - | 指定改派的服务日期子集 |
| `vehicleFeeTotal` | Body | Number/null | 否 | ≥0 | 手工总车费(需搭配调整原因) |
| `confirmCrossResident` | Body | Boolean/null | 否 | - | 跨常驻车派单显式确认 |
#### 出参 `Result<ChangeAssignmentRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `assignmentId` | String | 新派单 ID(雪花字符串) |
| `assignmentSlotId` | String | 槽位 ID(雪花字符串) |
| `previousAssignmentGroupId` | String/null | 原派车组 ID |
| `newAssignmentGroupId` | String/null | 新派车组 ID |
| `assignmentStatus` | String | 新派单状态 |
| `effectiveDate` | String(日期) | 改派生效日期 |
| `affectedDays` | Integer | 影响天数 |
| `vehicleFeeTotal` | String(Number)/null | 最终总车费 |
| `dailyVehicleFees` | Array/null | 逐日车费 |
| `warningCode` | String/null | 预警码 |
| `warningMessage` | String/null | 预警文案 |
#### 请求示例
```http
POST /admin/fleet/assignments/2091068072742756354/change
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"effectiveDate": "2026-09-02",
"newVehicleId": "2094307514693648385",
"newDriverId": "2065272145289658370",
"reason": "车队停用前换车",
"requestId": "web-change-<uuid>"
}
```
#### 响应示例
成功(结构示例;本次 TEST 的改派请求按预期被 605074 拦截,未产生成功样本):
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"assignmentId": "2094310000000000002",
"assignmentSlotId": "2094310000000000003",
"assignmentStatus": "assigned",
"effectiveDate": "2026-09-02",
"affectedDays": 1
}
}
```
#### 空数据 / 降级响应
本接口为写接口,无空数据分支;幂等重放(同 `requestId` + 同载荷)返回首个成功回执,不重复改派。改派目标派单自身已取消/已完成时不归本单变化范围,走既有 605066「派单已取消,无法修改,请重新派车」。
#### 错误响应
改派目标资源所属车队已停用(新增,TEST 实测原文;写库前拦截、原派单零副作用):
```json
{ "code": 605074, "message": "车队已停用不可派新单(请先启用车队或换车)", "success": false, "data": null }
```
生效日期不在派单服务日期范围内(既有):
```json
{ "code": 605028, "message": "生效日期不在派单服务日期范围内", "success": false, "data": null }
```
派单已取消(既有):
```json
{ "code": 605066, "message": "派单已取消,无法修改,请重新派车", "success": false, "data": null }
```
#### 业务边界
- 车队校验先于 605037/605038 执行(与创建同口径);车辆侧车队停用必拦(含纯费用调整场景——该场景 #6717 起本就被合并状态 605037 拦截,此处只是把报错换成更准的 605074)。
- 纯费用调整(`identityUnchanged`,车人未换)时**司机侧不查常驻车队**:在途单司机的另一辆常驻车被联动停用,不影响本单费用修订(「已派订单不受影响」边界)。
- 换车/换司机(非纯费用调整)时司机常驻车队停用 → 605074。
- 幂等:同 `requestId` 重放不重复改派;车锁/司机锁串行化。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | 调用 / 结果 |
|---|---|
| ✅ 供应商域投递停止合作事件 | `{"supplierId":"...","targetStatus":"SUSPENDED","eventId":"<全局唯一>","occurredAt":"..."}` + `X-Internal-Token` 头,Feign LB 直连 |
| ✅ `targetStatus` 传小写 `suspended` | 大小写不敏感,正常触发联动停用 |
| ✅ 同 `eventId` 网络重试重投 | 返 `duplicate=true` 成功回执,按已消费处理,**不要告警重试** |
| ✅ 非停止合作态(如 ACTIVE 恢复) | 返 `ignored=true` 成功,不联动(恢复合作需人工启用车队) |
| ✅ 前端收到 605074 | 按文案引导:启用车队(车队管理页)或更换车辆/司机后重试 |
| ✅ precheck 渲染 `fleet_team_disabled` | 与 `vehicle_unavailable` 同级醒目提示;加入 type 白名单映射 |
| ❌ 前端直接调 `/internal/fleet-teams/supplier-status-sync` | 网关不路由(业务码 404);该端点仅供服务间 Feign 调用 |
| ❌ 调用方对 `duplicate=true` 回执告警重试 | 重复投递是「已消费」而非失败,告警重试会造成误报风暴 |
| ❌ 复用 `eventId` 投递不同事件 | 幂等键冲突会让新事件被当成已消费吞掉;`eventId` 必须全局唯一(建议含 supplierId+状态+时间戳) |
| ❌ 雪花 ID 转 JavaScript Number | `supplierId`/`disabledTeamIds[]`/`driverId`/`vehicleId`/`assignmentId` 均为雪花字符串,`Number()`/`parseInt()`/一元 `+` 会精度丢失 |
| ❌ 车务忽略 precheck 预警强行提交 | 创建/改派锁内 605074 硬拦截,零写入 |
### 关键提示(当前 TEST 构建)
- 605074 与 605037 的分工:605074 专指「车队级」停用(多因供应商 SUSPENDED/BLACKLIST 联动);605037 指车辆自身维保/停用。前端错误码字典两个都要保留。
- 候选排除是静默的:前端不需要也不可能在候选接口里展示「被排除的司机」;如业务需要解释「为什么某司机不可派」,引导至司机档案查看常驻车所属车队状态。
- 车队重新启用后(车队管理 → 启用,前提是已绑供应商),候选/预校验/创建/改派即时恢复,无缓存延迟。
---
## 五、数据库行为
- **internal 联动端点只写 `fleet_team` 行**:CAS 批量 update(`status` ACTIVE→DISABLED,`where` 带 `status=ACTIVE` 守卫),重复/并发自然命中 0 行;不跨服务写 `supplier_main`/`supplier_resource_rel`。
- **幂等键在 Redis**:`fleet:team:supplier-status-sync:{eventId}`,TTL 24h(@Idempotent 开窗),窗口内重投不再触库。
- **派单侧零表结构变更**:候选排除 = 查询过滤(只读);创建/改派 605074 = 锁内读校验(只读),不写任何额外表。
- **本工单无 Flyway 迁移**:不加表、不加列(车队-供应商关联列由 #6717 迁移建好)。
- 联动停用不影响存量 `fleet_assignment` 行(本单边界:不动在途派单/槽位/占用)。
---
## 六、边界行为
- 未登录/登录失效:业务码 `401`(网关拦截)。
- internal 端点 `X-Internal-Token` 缺失/错误:`403`(过滤器直出,字段名 `msg`)。
- 经网关误调 `/internal/**`:业务码 `404`「接口不存在」(无路由,符合预期)。
- Bean Validation 失败:`400`(如缺 `eventId`/`vehicleId`/`startDate`)。
- 车队已停用不可派新单:`605074`(新增;创建/改派写库前硬拦截)。
- 车辆维保/停用:`605037`(既有;车队校验通过后才会命中)。
- 司机休假/待激活:`605038`(既有;同上)。
- 行程已结束只读:`605047`(既有;过期需求/派单先于 605074 命中,TEST 实测)。
- 派单已取消不可改派:`605066`(既有)。
- 生效日期越出派单服务日期:`605028`(既有)。
- 车队已停用仍选该车(车队管理写口径):`601102`(既有,本单不改)。
- 跨服务 Feign 不可用:本单 fleet 侧链路不新增 Feign 调用(联动方向是 resource → fleet);候选/预校验/创建/改派均为库内读写,不受 resource 侧可用性影响。
---
## 六.5、枚举 / 数据字典
### `targetStatus`(供应商目标状态,internal 端点入参)
**所属字段**: `SupplierStatusSyncReqVO.targetStatus` | **类型**: `String`(大小写不敏感)
| 值 | 中文 | 说明 |
|----|------|------|
| `SUSPENDED` | 暂停合作 | **触发**联动停用名下在启车队 |
| `BLACKLIST` | 拉黑 | **触发**联动停用名下在启车队 |
| `DRAFT`/`VETTING`/`ACTIVE`/`FROZEN`/`ARCHIVED` | 草稿/审核中/合作中/冻结/归档 | 忽略(`ignored=true`),不联动 |
### `warnings[].type`(precheck 非阻断提示类型)
**所属字段**: `PrecheckRespVO.warnings[].type` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `seats_short` | 座位不足 | 既有 |
| `vehicle_unavailable` | 车不可用 | 既有(车辆不存在/维保/停用;车队停用合并车辆状态后同现) |
| `driver_unavailable` | 司机不可用 | 既有 |
| `driver_blacklisted` | 司机已黑名单 | 既有(提交将被 605006 拦截) |
| `driver_season_not_registered` | 司机非在册赛季 | 既有(提交将被 605013 拦截) |
| **`fleet_team_disabled`** | **车队已停用** | **本单新增**(提交将被 605074 拦截,引导启用车队或换车) |
| `cross_resident` | 跨常驻 | 既有 |
| `license_expired` | 驾照过期 | 既有 |
| `veh_inspect_expired` | 车辆年检过期 | 既有 |
| `veh_insure_expired` | 车辆保险过期 | 既有 |
### 车队状态(`fleet_team.status`)
**所属字段**: 车队管理相关响应 `status` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `ACTIVE` | 启用 | 名下资源可派;前提:已关联供应商(#6717/#6811 不变量) |
| `DISABLED` | 停用 | 名下车辆/常驻司机不可派新单(本单贯通到派单全链路) |
---
## 六.6、修改前后对比
### 字段/枚举级对比
| 项 | 改前 | 改后 |
|------|------|------|
| `POST /internal/fleet-teams/supplier-status-sync` | 不存在 | 新增 internal 端点(ReqVO 4 字段 / RespVO 6 字段) |
| `PrecheckRespVO.warnings[].type` 值域 | 9 种既有 type | 新增 `fleet_team_disabled` |
| 派单错误码 | 无车队级码(车队停用被合并状态误报 605037) | 新增 605074「车队已停用不可派新单(请先启用车队或换车)」 |
| `candidates` 请求/响应字段 | 现有结构 | 零字段变化(行为变化:司机候选集合缩小) |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 供应商暂停/拉黑 | 车队无感知,名下资源照常可派 | 联动停用名下在启车队(强制,不做在役车辆守卫) |
| 司机候选(常驻车挂停用车队) | 照常返回 | 静默排除 |
| precheck 车队停用提示 | 仅 `vehicle_unavailable`(车辆侧) | 追加 `fleet_team_disabled`(车+司机两侧) |
| 创建/改派选到停用车队资源 | 报 605037「车辆维保/停用」(语义不准);司机侧可漏过 | 统一报 605074(先于 605037/605038) |
| 在途单纯费用调整(司机另一常驻车车队被停用) | 司机侧无校验 | 仍不校验(刻意豁免,不卡死在途单) |
| 供应商恢复合作 | - | 不自动启用车队(人工启用,安全侧) |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否。响应结构无字段删改;候选集合缩小属业务收口;新增 warning type 与错误码对旧前端为未知值——旧前端按通用兜底渲染即可,不崩。
- **前端是否必须同步上线**: 建议同批但不强阻断。两项适配:① precheck warning type 白名单加 `fleet_team_disabled`(不加则该预警被静默丢弃,车务要到提交时才知道被拦);② 错误码字典加 605074 文案与引导动作(不加则按通用错误提示展示)。
- **前端 workaround 清理点**: 无(此前无对应前端绕行逻辑)。
- **服务间依赖**: resource 侧 #6844 未上线前,internal 端点无生产流量,派单拦截仅对人工停用/无供应商自动停用的车队生效——同样符合预期。
---
## 七、不影响范围
- **仅影响**: 派单候选查询 / 派单预校验 / 派单创建(批量,单派同路径)/ 派单改派 4 个管理后台端点 + 1 个 internal 端点(fleet 侧)。
- **零影响**:
- 团批组派(GroupDispatch 独立体系,不写 `fleet_assignment`,PR 明示不在拦截范围)。
- 存量派单的最终确认 / 撤销取消 / 行程短信 / 软清等推进类写口(不挂车队状态新校验,供应商事后停用不卡死在途单)。
- 车队管理 CRUD 端点(`/admin/fleet/teams/**` 本单未改;启停用/供应商守卫口径仍属 #6717/#6811)。
- 司机档案 / 车辆档案 / 司机列表「所属车队」筛选(排除逻辑复用同一反查端口,档案域零改动感知)。
- 供应商域九大资源模块与供应商管理端点(联动发出端 #6844 另行落地)。
- 小程序端(派单链路为管理后台车务功能)。
- Gateway 路由(`/admin/fleet/assignments/**` 通配已覆盖;`/internal/**` 本就不经网关,无需新增配置)。
- Redis/MQ(仅新增幂等键 `fleet:team:supplier-status-sync:{eventId}`,无新消息)。
---
## 八、测试环境已验证
真实 TEST 环境实测(2026-08-31,网关 `https://api.test.1814.love:9443`,admin token 走 `/admin/auth/login`;internal 端点经 SSH 在测试服本机 curl `127.0.0.1:8087` 直连,模拟 Feign 调用方):
```
[internal 链路]
POST /internal/fleet-teams/supplier-status-sync SUSPENDED 首投
→ 200, ignored=false, duplicate=false, disabledCount=1, disabledTeamIds=["352696323818000384"] ✓
POST /internal/fleet-teams/supplier-status-sync 同 eventId 重投(幂等)
→ 200, duplicate=true, disabledCount=0, disabledTeamIds=[] ✓
POST /internal/fleet-teams/supplier-status-sync targetStatus=ACTIVE(非停合作态)
→ 200, ignored=true, disabledCount=0 ✓
POST /internal/fleet-teams/supplier-status-sync 缺 eventId
→ 400「事件ID不能为空」 ✓
POST /internal/fleet-teams/supplier-status-sync 错误 X-Internal-Token
→ 403「内部接口禁止外部访问」 ✓
POST 网关 /internal/fleet-teams/supplier-status-sync
→ 业务码 404「接口不存在」(/internal 不经网关路由,符合预期) ✓
[管理后台链路(admin token)]
GET /admin/fleet/teams/352696323818000384
→ status=DISABLED(联动生效,全栈贯通) ✓
POST /admin/fleet/assignments/candidates driverKeyword=斯琴
→ 停用前 records=1 条 → 联动停用后 records=[] total=0(新增排除生效) ✓
POST /admin/fleet/assignments/candidates vehicleKeyword=T6843
→ records=[](车辆侧 #6717 既有排除仍生效) ✓
POST /admin/fleet/assignments/precheck 停用车队车辆+常驻司机
→ warnings 含 {"type":"fleet_team_disabled","msg":"车队已停用不可派新单,提交派单将被拦截,请先启用车队或换车"} ✓
POST /admin/fleet/assignments/2091068072742756354/change 改派到停用车队车+司机
→ 605074「车队已停用不可派新单(请先启用车队或换车)」,原派单零副作用(事后核 update_time 未变) ✓
POST /admin/fleet/assignments/batch 停用车队车+司机(过期行程占位)
→ 605047「行程已结束」优先命中(前置守卫先于 605074,符合校验顺序) ✓
POST /admin/fleet/teams/352696323818000384/enable 重新启用
→ 200;同条件 candidates 斯琴恢复 1 条(恢复路径即时生效) ✓
```
- 部署:Deploy Panel 任务 `a7300401`(hl-fleet-service,2026-08-31 14:02 success,8087/8187 双实例滚动均 UP),部署 dev-v3 HEAD `2543febee`(含本 PR merge commit `df78cce550b4f4ea6a9f40352c1dd176b8d8fba6`,2026-08-31 13:50 合入)。
- **batch 创建 605074 正向链未能在 TEST 实跑**:测试服全部待派(unassigned)占位的行程均已结束,创建请求先撞 605047;构造可派需求需完整订单+需求展开链,超出本次验证范围。该路径由单测覆盖(`AssignmentServiceTest#create_fleetTeamDisabledVehicle_throws605074` / `create_driverOnDisabledTeam_throws605074`,与已实测的改派路径 `change_fleetTeamDisabledVehicle_throws605074` 共用同一锁内校验方法),建议 mmg 联调时在真实新订单上补验一次。
- 前置态构造说明:「停用车队 + 在役车辆/常驻司机」状态被人工停用守卫(601103)与车队选择守卫(601102)封锁,只有供应商联动能合法产生——实测经 internal 端点真实产生(正是本端点设计语义);测试车队的「ACTIVE + 已绑供应商」前置态由 DB 直改构造(测试服 fixture)。
- 本地自动化(PR 自报):目标单测 730 个全绿(AssignmentServiceTest 522 / FleetTeamServiceTest 36 / DriverServiceTest 157 / 新增 Mapper+Controller 测试);全量 `mvn -pl hl-fleet-service test` 3913 tests 0 失败(含 IT);`FleetRedLineArchTest` 13/13 绿;spotless:check 通过。
- 数据清理:测试车队(352696323818000384)、测试车辆(2094307514693648385,蒙A-T6843)均已删除(DB 复核无残留);测试用模拟供应商 ID(8996843000000000001)未在 `supplier_main` 落库,随车队删除清除;司机斯琴档案未改动(常驻绑定随车辆删除解除);被改派实测的真实派单(2091068072742756354)经核零副作用;admin token 已登出。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #6868 | #6843 | 供应商暂停合作联动停用车队 + 派单拦截停用车队司机车辆(本次) | ✅ 最新 |
| #6753 | #6717 | 车队关联供应商并展示供应商全名(车队-供应商关联与车辆侧排除的前置) | ✅ 已被本单扩展 |
| #6827 | #6811 | 车队保存无供应商自动停用 + 601112 在役车辆守卫(车队停用来源之二) | ✅ 并存 |
| - | #6844 | 供应商侧通知发出端(resource → fleet 联动调用方),待指派实现 | ⏳ 未开始 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#6843](https://git.1814.love:8443/wx/HL/issues/6843)
- 关联 PR: [wx/HL#6868](https://git.1814.love:8443/wx/HL/pulls/6868)
- 前置契约: `changelogs-v2/2026-08/30_6717_车队关联供应商并展示供应商全名-修改接口-管理后台.md`
- 前置契约: `changelogs-v2/2026-08/31_6811_车队保存无供应商时自动停用与在役车辆守卫-修改接口-管理后台.md`
## 撤回
1. 前端先摘除对 605074 与 `fleet_team_disabled` 的特化渲染(按通用兜底展示),保持线上可用。
2. 从最新 `dev-v3` 建独立回退分支,回退 PR #6868 的合并 commit(`df78cce5`),验证后经独立 PR 合入。
3. ⚠️ 已被联动停用的车队**不会**因回退自动恢复:回退代码只撤联动与拦截逻辑,已落 DISABLED 的车队需在车队管理人工启用(启用前置 = 已绑供应商)。
4. 仅需止血时也可不回退整单:internal 端点无生产流量前(#6844 未上线)本单对线上唯一可感知影响是人工停用/无供应商车队在派单侧的拦截收口,属预期行为。
5. 使用 Deploy Panel 滚动部署 `hl-fleet-service`;本单无数据库迁移,无需回滚脚本。
6. 撤回后经 Gateway 验证:候选恢复返回停用车队常驻司机、precheck 不再出现 `fleet_team_disabled`、改派停用车队资源回报 605037 老口径。
## 关联 / 联系人
### 链接
- **Issue**: [#6843](https://git.1814.love:8443/wx/HL/issues/6843)
- **PR**: [#6868](https://git.1814.love:8443/wx/HL/pulls/6868)
- **Merge commit**: `df78cce550b4f4ea6a9f40352c1dd176b8d8fba6`
### 联系人
- **后端负责人**: @wx
- **前端联动**: @mmg(① precheck `warnings[]` type 白名单加 `fleet_team_disabled`;② 错误码字典加 605074「车队已停用不可派新单(请先启用车队或换车)」+ 引导动作;③ 司机候选静默排除无前端适配量)
- **服务间联动**: resource 侧 #6844 实现方按本文 §三.1 契约投递(supplierId/targetStatus/eventId/occurredAt + `X-Internal-Token`,重复投递认 `duplicate=true` 成功回执)
@@ -0,0 +1,74 @@
---
schema: "hl-changelog/v2"
ticket: "frontend-fleet-team-sort-validation"
title: "车队编辑弹窗「排序」已填值仍提示「请输入排序」,无法保存"
consumer: "admin"
author: "wx(GIT)"
change_type: "前端缺陷"
backend_status: "not_required"
gateway_status: "not_required"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "e2b2482e"
target_release: "v2.1"
verified_at: "2026-08-31"
status_note: "纯前端校验缺陷,前端已修复。rules.sortOrder 的 required 规则补 type:'number',number 值(含 0)不再误判为空,空值仍正确提示必填。"
updated_at: "2026-08-31"
base: "dev-v3"
generated: "2026-08-31T01:43:00+08:00"
---
# 车队编辑弹窗「排序」已填值仍提示「请输入排序」,无法保存
> 前端缺陷,待前端修复。后端契约无变化。
## 现象(TEST 实测)
车务 → 车队管理 → 编辑车队(如「合作车队A」):
- 「排序」输入框中已显示值 `1`;
- 点击「保存」时表单项仍报红色校验错误「请输入排序」,保存被拦截,无法提交。
## 只读前端定位(hl-ui 未修改)
`src/views/fleet/teams/index.vue`:
- 表单绑定:`NInputNumber v-model:value="formData.sortOrder"`,编辑时 `fillForm` 正确回填 `row?.sortOrder ?? null`,输入框显示的 `1` 说明 model 已有值;
- 校验规则(约 391 行):
```js
sortOrder: [{ required: true, message: '请输入排序', trigger: ['blur', 'change'] }],
```
规则缺少 `type: 'number'`。Naive UI 底层 async-validator 的必填规则默认按 string 类型校验,对 number 类型的值会误判为空,导致「值已填但 required 校验不通过」。同页面其他字段均为字符串/枚举值所以未暴露此问题。
## 修复建议
规则补充 `type: 'number'`,例如:
```js
sortOrder: [{ required: true, type: 'number', message: '请输入排序', trigger: ['blur', 'change'] }],
```
同时建议回归以下场景:
1. 编辑已有车队(详情回填 sortOrder)直接保存,不再误报;
2. 新建车队填写排序后保存正常;
3. 排序为空(清空输入框)时仍正确提示「请输入排序」;
4. 排序为 `0` 时可通过校验(`min: 0`,0 是合法值,不得被误判为空)。
## 后端契约(无变化,供联调核对)
- 详情:`GET /fleet/teams/{fleetTeamId}`,响应含 `sortOrder`(整数);
- 更新:`PUT /fleet/teams/{fleetTeamId}`,请求体 `sortOrder` 为整数;
- 新增:`POST /fleet/teams`,请求体 `sortOrder` 为整数。
本条目不引入后端代码、接口、数据库、配置、Redis 或 MQ 变更。
## 复现路径
车务(fleet 管理后台)→ 车队管理 → 任一有排序值的车队 → 编辑 → 直接点「保存」。
## 撤回
如需撤回本联调通知,只需回退本 Changelog 文件;后端契约和运行数据不受影响。
@@ -0,0 +1,748 @@
---
schema: "hl-changelog/v2"
ticket: "6903"
title: "团期看板六芯片逐户明细 GB-ADM-090~095"
consumer: "admin"
author: "wx(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-01"
status_note: "2026-09-01 部署测试服 dev-v3@1159c8949,6 端点网关实测 200 全通过,团期不存在 589500 / 参数错误 400 已核验。前端评估(2026-09-01):团期看板 src/views/order-v2/batch 当前为纯本地 mock 阶段(period 用 mock id,全仓无真实 group-batch 调用),chips 端点需真实雪花 groupBatchId,下钻暂无真实落脚页。决策:先接真团期看板(需 GB-ADM-001 看板列表契约+真实 groupBatchId,不在本单)再接六芯片下钻,下钻形态已定=弹层明细。待看板列表契约后接入,跟踪见 hl-admin 任务 #104。"
updated_at: "2026-09-01"
base: "dev-v3"
---
# 团期看板:六芯片逐户明细(GB-ADM-090~095,房/车/导/摄/约/保)
> **存放目录**: 二期(order-v3)→ `changelogs-v2/2026-09/`
>
> **服务**: hl-order-service-v3
> **Issue**: [#6903](https://git.1814.love:8443/wx/HL/issues/6903)
> **日期**: 2026-09-01
> **影响范围**: 管理后台团期看板行右侧六芯片(配房/配车/配导游/配摄影/合同/保险)点击后的逐户下钻
---
## ⚠️ 关键变化
**新增能力,无破坏性变化**:先前看板芯片只有整团聚合色块(GB-ADM-001 `chips.X`)、没有逐户下钻;本次把「点芯片看每户到哪一步」补成可调用接口。数据(order_main 六态列)早已落库并被房务/车队/地接等 Service 消费,本组接口只读透出,不改变任何业务状态。
---
## 一、背景(选填)
团期看板行右侧有「房/车/导/摄/约/保」六个芯片,点击后按需展开该项的**逐户明细**。逐户口径与整团聚合、看板芯片(GB-ADM-001 `chips.X`)同源同算法(共享地基 `GroupBatchChipResolver`,#6902/#6916)。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | GB-ADM-090 配房逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/hotel` | 新增接口 | 房芯片逐户下钻 |
| 2 | GB-ADM-091 配车逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/vehicle` | 新增接口 | 车芯片逐户下钻 |
| 3 | GB-ADM-092 配导游逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/guide` | 新增接口 | 导芯片逐户下钻 |
| 4 | GB-ADM-093 配摄影逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/photo` | 新增接口 | 摄芯片逐户下钻 |
| 5 | GB-ADM-094 合同逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/contract` | 新增接口 | 约芯片逐户下钻 |
| 6 | GB-ADM-095 保险逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/insurance` | 新增接口 | 保芯片逐户下钻 |
---
## 三、接口详情
六个接口共用 `GroupBatchChipDetailVO` / `GroupBatchChipItemRespVO`,正文各自自包含。
### 1. GB-ADM-090 配房逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/hotel`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「房」芯片点击展开:返回该团期每户的配房进度(哪一户到哪一步),前端展开列表展示。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值一律忽略(不校验客户端值) |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「配房」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常),与 GB-ADM-001 `chips.hotel` 同源同算法 |
| totalCount | Integer | 计入统计的子订单数(免闸户不计入分母) |
| doneCount | Integer | 已完成户数(`status==DONE`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户配房状态,`RequirementStatus` 6 值,见「六.5」;无需/未开始为 null |
| items[].statusText | String | 状态中文名(服务端给出,见「六.5」) |
| items[].needsIt | Boolean | 本户是否需要配房(`order_main.needs_hotel`);false=免闸户,status=null、置灰、不计入计数 |
| items[].updateTime | String | 配房最后变更时间;六状态列无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/hotel
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "配房",
"aggregateStatus": "DOING",
"totalCount": 7,
"doneCount": 5,
"items": [
{ "orderId": "770152", "orderNo": "GT-26-0096", "contactName": "陈昊",
"peopleCount": 2, "status": "DONE", "statusText": "配房完成",
"needsIt": true, "updateTime": null },
{ "orderId": "770153", "orderNo": "GT-26-0097", "contactName": "林婉清",
"peopleCount": 3, "status": null, "statusText": "无需",
"needsIt": false, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "配房",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- 免闸户(`needsIt=false`):status=null、statusText=「无需」、前端置灰、不计入 totalCount/doneCount 分子分母。
- 已取消子订单不计入(活跃口径与共享地基一致)。
- 团期已流团(CANCELLED)→ aggregateStatus 恒整团待办(wire 值同看板 chips.X),但逐户明细仍如实返回(不造假计数)。
---
### 2. GB-ADM-091 配车逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/vehicle`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「车」芯片点击展开:返回每户配车进度。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「配车」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常),与 GB-ADM-001 `chips.vehicle` 同源 |
| totalCount | Integer | 计入统计的子订单数(免闸户不计入) |
| doneCount | Integer | 已完成户数(`status==DONE`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户配车状态,`RequirementStatus` 6 值,见「六.5」;无需/未开始为 null |
| items[].statusText | String | 状态中文名(服务端给出,车务文案:待车务配/配车中/配车完成/待审核/驳回给定制师/驳回给管理员,见「六.5」) |
| items[].needsIt | Boolean | 本户是否需要配车(`order_main.needs_vehicle`);false=免闸户置灰不计入 |
| items[].updateTime | String | 配车最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/vehicle
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "配车",
"aggregateStatus": "DONE",
"totalCount": 3,
"doneCount": 3,
"items": [
{ "orderId": "770160", "orderNo": "GT-26-0101", "contactName": "王强",
"peopleCount": 2, "status": "DONE", "statusText": "配车完成",
"needsIt": true, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "配车",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- 免闸户(`needsIt=false`):status=null、statusText=「无需」、前端置灰、不计入计数。
- 已取消子订单不计入;流团团期聚合恒 整团待办、明细如实返回。
---
### 3. GB-ADM-092 配导游逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/guide`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「导」芯片点击展开:返回每户配导游进度。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「配导游」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常) |
| totalCount | Integer | 计入统计的子订单数(免闸户不计入) |
| doneCount | Integer | 已完成户数(`status==DONE`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户配导游状态,3 值:`NONE` 无需/未开始 / `PENDING` 待指派 / `DONE` 已指派,见「六.5」 |
| items[].statusText | String | 状态中文名(服务端给出,见「六.5」) |
| items[].needsIt | Boolean | 本户是否需要配导游(`order_main.needs_guide`);false=免闸户 status 恒 `NONE`、不计入计数 |
| items[].updateTime | String | 配导游最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/guide
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "配导游",
"aggregateStatus": "DOING",
"totalCount": 4,
"doneCount": 2,
"items": [
{ "orderId": "770170", "orderNo": "GT-26-0102", "contactName": "周磊",
"peopleCount": 2, "status": "DONE", "statusText": "已指派", "needsIt": true, "updateTime": null },
{ "orderId": "770171", "orderNo": "GT-26-0103", "contactName": "吴芳",
"peopleCount": 1, "status": "NONE", "statusText": "无需", "needsIt": false, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "配导游",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- **免闸户(needsIt=false)status 为 `NONE`(非 null)**,与房/车(null)区分;前端按 NONE 置灰。
- 读侧只认 `DONE`,其余非免闸状态一律 `PENDING`;已取消子订单不计入;流团团期聚合恒 整团待办。
---
### 4. GB-ADM-093 配摄影逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/photo`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「摄」芯片点击展开:返回每户配摄影进度。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「配摄影」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常) |
| totalCount | Integer | 计入统计的子订单数(免闸户不计入) |
| doneCount | Integer | 已完成户数(`status==DONE`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户配摄影状态,3 值:`NONE` 无需/未开始 / `PENDING` 待指派 / `DONE` 已指派,见「六.5」 |
| items[].statusText | String | 状态中文名(服务端给出,见「六.5」) |
| items[].needsIt | Boolean | 本户是否需要配摄影(`order_main.needs_photographer`);false=免闸户 status 恒 `NONE`、不计入计数 |
| items[].updateTime | String | 配摄影最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/photo
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "配摄影",
"aggregateStatus": "整团待办",
"totalCount": 1,
"doneCount": 0,
"items": [
{ "orderId": "770180", "orderNo": "GT-26-0104", "contactName": "郑浩",
"peopleCount": 2, "status": "PENDING", "statusText": "待指派", "needsIt": true, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "配摄影",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- 免闸户(needsIt=false)status 为 `NONE`(非 null),与导芯片一致。
- 读侧只认 `DONE`,其余非免闸状态一律 `PENDING`;已取消子订单不计入;流团团期聚合恒 整团待办。
---
### 5. GB-ADM-094 合同逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/contract`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「约」芯片点击展开:返回每户合同(签约)进度。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「合同」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常);房车导摄四项未全 DONE 前恒整团待办(硬规则;wire 值同看板 chips.X) |
| totalCount | Integer | 计全部活跃子订单(约/保无免闸户) |
| doneCount | Integer | 已完成户数(`status==SIGNED`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户合同状态,`ContractStatus` 8 值,见「六.5」;无合同为 null |
| items[].statusText | String | 状态中文名(服务端给出,见「六.5」) |
| items[].needsIt | Boolean | 恒 true(约/保无免闸) |
| items[].updateTime | String | 合同最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/contract
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "合同",
"aggregateStatus": "DOING",
"totalCount": 2,
"doneCount": 1,
"items": [
{ "orderId": "770190", "orderNo": "GT-26-0105", "contactName": "钱进",
"peopleCount": 2, "status": "SIGNED", "statusText": "已签署", "needsIt": true, "updateTime": null },
{ "orderId": "770191", "orderNo": "GT-26-0106", "contactName": "孙丽",
"peopleCount": 2, "status": "PENDING", "statusText": "待出具", "needsIt": true, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "合同",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- needsIt 恒 true(约/保无免闸户);doneCount 只按 `SIGNED`;作废中/已作废计入整团 ERROR。
- 约/保整团聚合在房车导摄四芯片未全部 DONE 前恒 整团待办(硬规则);已取消子订单不计入;流团团期聚合恒 整团待办。
---
### 6. GB-ADM-095 保险逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/insurance`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「保」芯片点击展开:返回每户保险进度。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「保险」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常);房车导摄未全 DONE 前恒整团待办(硬规则;wire 值同看板 chips.X) |
| totalCount | Integer | 计全部活跃子订单(约/保无免闸户) |
| doneCount | Integer | 已完成户数(`status==INSURED`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户保险状态,4 值:`INSURING` 投保中 / `INSURED` 已出单 / `CANCELLED` 已取消 / `FAILED` 出单失败;无保险为 null |
| items[].statusText | String | 状态中文名(服务端给出,见「六.5」) |
| items[].needsIt | Boolean | 恒 true(约/保无免闸) |
| items[].updateTime | String | 保险最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/insurance
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "保险",
"aggregateStatus": "DOING",
"totalCount": 2,
"doneCount": 1,
"items": [
{ "orderId": "770200", "orderNo": "GT-26-0107", "contactName": "李娜",
"peopleCount": 2, "status": "INSURED", "statusText": "已出单", "needsIt": true, "updateTime": null },
{ "orderId": "770201", "orderNo": "GT-26-0108", "contactName": "赵敏",
"peopleCount": 3, "status": "INSURING", "statusText": "出单中", "needsIt": true, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "保险",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- needsIt 恒 true;doneCount 只按 `INSURED`;已取消/出单失败计入整团 ERROR。
- 约/保整团聚合在房车导摄未全 DONE 前恒 整团待办(硬规则);已取消子订单不计入;流团团期聚合恒 整团待办。
---
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写后端接受/拒绝请求的规则,不写 UI 渲染建议。
### ✅ 正确 / ❌ 错误调用对照
| 场景 | 说明 |
|------|------|
| ✅ 网关已注入 X-Admin-Id,调任一芯片端点 | 返回 `Result<GroupBatchChipDetailVO>`,code=200 |
| ✅ 免闸户(needsIt=false) | 房/车 status=null;导/摄 status=NONE;不计入 totalCount/doneCount |
| ❌ 未配置 `group-batch:view` 权限 | 589507 无操作权限 |
| ❌ groupBatchId 不存在/非团期/已软删 | 589500 团期不存在 |
| ❌ 客户端自行传 X-Admin-Id | 以网关注入值为准,客户端值一律忽略(不校验客户端值) |
### 切换状态时的必要动作
无状态切换——六个端点均为只读 GET、无请求体;前端点击芯片即查询,请求方不需要携带任何业务状态字段,也不修改任何状态。
---
## 五、数据库行为(涉及写操作时必写)
本组接口**无写操作**(READ_ONLY 事务),无表变更、无字段变更、无数据迁移。数据来源即为现有 order_main 六态列(needs_hotel/needs_vehicle/needs_guide/needs_photographer/合同状态/保险状态),由共享地基读取投影。
---
## 六、边界行为
- 未登录/无有效 token → 401(网关拦截,本组接口不允许匿名访问)。
- 未配置 `group-batch:view` 权限 → 589507 无操作权限。
- 团期不存在/非团期/已软删 → 589500 团期不存在。
- 任意芯片端点输入非法(groupBatchId 非数字)→ 400 参数错误(框架级)。
- 空团期/无活跃子订单 → data 正常返回:totalCount=0、doneCount=0、items=[],不 500 不降级。
- 下游数据缺失(老数据无对应需求/合同/保险记录)→ 对应 status 为 null(房/车/约/保)或 NONE(导/摄),不异常。
- 团期流团(CANCELLED)→ aggregateStatus 恒整团待办(wire 值同看板 chips.X),明细仍如实返回。
- 已返团(审核/结算)→ 房车导摄恒 DONE 聚合。
- 列表查询与整团伙计数一次性内存聚合(防 N+1),单次请求最多一次 `listByProductBatchId` 查询。
---
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
### items[].status(配房/配车,`com.hulalv.order.core.enums.RequirementStatus`)
**所属字段**: `GroupBatchChipItemRespVO.status` | **类型**: `String`
| 值 | 中文(statusText) | 说明 |
|----|------|------|
| `PENDING` | 待房务配(房)/ 待车务配(车) | 待房务/车队配 |
| `PROCESSING` | 配房中(房)/ 配车中(车) | 配置进行中 |
| `DONE` | 配房完成(房)/ 配车完成(车) | 已完成(房/车分文案,#6921) |
| `PENDING_REVIEW` | 待审核 | 待复核 |
| `REJECTED_TO_CONSULTANT` | 驳回给定制师 | 失败态(计入整团 ERROR) |
| `REJECTED_TO_ADMIN` | 驳回给管理员 | 失败态(计入整团 ERROR) |
### items[].status(配导游/配摄影)
**所属字段**: `GroupBatchChipItemRespVO.status` | **类型**: `String`
| 值 | 中文(statusText) | 说明 |
|----|------|------|
| `NONE` | 无需 | 免闸户/未开始(免闸户恒 NONE) |
| `PENDING` | 待指派 | 读侧非免闸非 DONE 一律 PENDING |
| `DONE` | 已指派 | 已完成 |
### items[].status(合同,`com.hulalv.order.*.ContractStatus`)
**所属字段**: `GroupBatchChipItemRespVO.status` | **类型**: `String`
| 值 | 中文(statusText) | 说明 |
|----|------|------|
| `PENDING` | 待出具 | 待生成 |
| `GENERATED` | 已生成 | - |
| `REPORTED` | 已报备 | - |
| `UPLOADED` | 已上传 | - |
| `SIGNING` | 签署中 | - |
| `SIGNED` | 已签署 | doneCount 判定值 |
| `VOIDING` | 作废中 | 失败态(计入整团 ERROR) |
| `VOIDED` | 已作废 | 失败态(计入整团 ERROR) |
### items[].status(保险)
**所属字段**: `GroupBatchChipItemRespVO.status` | **类型**: `String`
| 值 | 中文(statusText) | 说明 |
|----|------|------|
| `INSURING` | 出单中 | 投保中 |
| `INSURED` | 已出单 | doneCount 判定值 |
| `CANCELLED` | 已取消 | 失败态(计入整团 ERROR) |
| `FAILED` | 出单失败 | 失败态(计入整团 ERROR) |
---
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- **仅影响**: 管理后台团期看板六芯片的逐户明细展示层接口(GB-ADM-090~095)。
- **零影响**:
- 看板整团聚合接口(GB-ADM-001 `chips.X` 结构不变)
- 房务/车队/地接/合同/保险的任何写接口与业务流程(本组只读)
- 一期(v2)所有接口
- 数据库结构、网关路由、权限点(复用既有 `group-batch:view`)
---
## 八、测试环境已验证
**JUnit 定向测试**: `GroupBatchChipServiceTest`(12 用例)+ `GroupBatchChipControllerTest`(7 用例)+ 共享地基 `GroupBatchChipResolverTest`(18 用例)全绿,覆盖六端点、聚合态、免闸户、错误码 589500/589507、权限校验。
真实接口网关验证(2026-09-01 部署 dev-v3 @1159c8949 后实测):
测试团期: `groupBatchId=2089713777065832450`(batch Q202610312089667212070612994,RESOURCE_PREPARING,含 1 活跃子订单)
> 聚合态 `aggregateStatus` wire 英文四态值与看板 GB-ADM-001 `chips.X` 完全一致(读侧同源同算法);下表按中文态名展示实测结果。
```
GET /v3/admin/order/group-batch/2089713777065832450/chips/hotel → 200 code=200 aggregateStatus=整团待办 total=1 done=0 items=1 ✓
GET /v3/admin/order/group-batch/2089713777065832450/chips/vehicle → 200 code=200 aggregateStatus=整团待办 total=1 done=0 items=1 ✓
GET /v3/admin/order/group-batch/2089713777065832450/chips/guide → 200 code=200 aggregateStatus=整团待办 total=0 items=1(needsIt=false 免闸不计 total,"无需")✓
GET /v3/admin/order/group-batch/2089713777065832450/chips/photo → 200 code=200 aggregateStatus=整团待办 total=0 items=1(免闸,"无需")✓
GET /v3/admin/order/group-batch/2089713777065832450/chips/contract → 200 code=200 aggregateStatus=整团待办 total=1 items=1(无合同 → statusText="无合同")✓
GET /v3/admin/order/group-batch/2089713777065832450/chips/insurance→ 200 code=200 aggregateStatus=整团待办 total=1 items=1(无保险 → statusText="无保险")✓
GET /v3/admin/order/group-batch/999999999999999999/chips/hotel → 200 code=589500 message="团期不存在" ✓
GET /v3/admin/order/group-batch/abc/chips/hotel → 200 code=400 message="参数 groupBatchId 格式错误,请检查后重试" ✓
```
验证通道: 统一网关 `https://api.test.1814.love:9443`,管理员登录 token + 网关注入 `X-Admin-Id`。
---
## 九、相关历史 PR(纠错 / 功能演进时必写)
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #6916 | #6915 | 共享地基 statusText 契约文案修正 + 测试补齐(本单契约基础) | ✅ 有效 |
| 本 PR(#6903 分支合并) | #6903 | 六芯片逐户明细接口交付 | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#6903](https://git.1814.love:8443/wx/HL/issues/6903)
- 关联 PR: [wx/HL#6918](https://git.1814.love:8443/wx/HL/pulls/6918)(squash 合并至 dev-v3 @1159c8949)
- 共享地基: #6902(Resolver/看板聚合)、#6916(statusText 契约修正)
## 关联 / 联系人
### 链接
- **Issue**: [#6903](https://git.1814.love:8443/wx/HL/issues/6903)
- **PR**: 合并后回填
- **Merge commit**: 合并后回填
### 联系人
- **后端负责人**: @wx(GIT)
@@ -0,0 +1,338 @@
---
schema: "hl-changelog/v2"
ticket: "6904"
title: "团期看板统计条 GB-ADM-009 + 团期导出 GB-ADM-008"
consumer: "admin"
author: "wx(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-01"
status_note: "2026-09-01 部署测试服 dev-v3@9f689354e,/summary 与 /export 经测试网关实测 200 全通过;CSV BOM/CRLF/10 列、7 桶口径、589517 上限均核验。前端评估(2026-09-01):团期看板 src/views/order-v2/batch 当前为纯本地 mock 阶段(无真实 group-batch 调用),统计条/导出依赖真实看板页;且看板列表契约 #6905/GB-ADM-000~003 未推送进 changelog 仓(仅被本单与 #6903 引用,无契约本体)。决策:等后端推送看板列表契约后,统一接真看板再一并接统计条+导出+六芯片下钻(#6903)。跟踪见 hl-admin 任务 #104。"
updated_at: "2026-09-01"
base: "dev-v3"
---
# 团期看板:统计条(GB-ADM-009)+ 团期导出(GB-ADM-008)
> **存放目录**: 二期(order-v3)→ `changelogs-v2/2026-09/`
>
> **服务**: hl-order-service-v3
> **PR**: #6917
> **Issue**: [#6904](https://git.1814.love:8443/wx/HL/issues/6904)
> **日期**: 2026-09-01
> **影响范围**: 管理后台团期看板顶部统计条(7 桶计数 + 活跃子订单数)与整表 CSV 导出
---
## ⚠️ 关键变化
**新增能力,无破坏性变化**:团期看板新增两个只读端点——统计条(`/summary`)返回七大状态桶与跨团期活跃子订单数;导出(`/export`)按当前筛选整表导出 CSV(UTF-8 BOM、CRLF、固定 10 列)。与 #6905 交付的列表/详情(GB-ADM-000~003)同地基(#6902),本单不触碰 #6905 任何接口与文件。
---
## 一、背景(选填)
管理后台团期看板页顶部需要一条统计条(七桶计数 + 活跃子订单数),并把当前筛选下的团期整表导出为 CSV。口径与看板列表(GB-ADM-000/001/002/003)完全一致:同筛选(productId 精确、month 落出团月、keyword 模糊 OR)、同状态源(`order_group_batch.batch_status` 八态)、同聚合(跨命中团期统计活跃子订单)。FORMED 为 RESOURCE_PREPARING+MATERIAL_PREPARING 复合桶;AUDITING/CHECKED 在接口层保持独立桶,前端展示可折叠合并。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | GB-ADM-009 团期看板统计条 | GET | `/v3/admin/order/group-batch/summary` | 新增接口 | 七桶计数 + total + subOrderCount |
| 2 | GB-ADM-008 团期导出 | GET | `/v3/admin/order/group-batch/export` | 新增接口 | 看板整表 CSV 导出(10 列) |
---
## 三、接口详情
两个接口共用筛选口径与状态映射(#6902 `GroupBatchStageBuckets` 七桶 / `GroupBatchStatus` 八态),正文各自自包含。
### 1. GB-ADM-009 团期看板统计条 `GET /v3/admin/order/group-batch/summary`
**VO**: `GroupBatchSummaryVO`
#### 使用场景
团期看板页顶部统计条:返回当前筛选口径下七大状态桶计数、桶合计与跨团期活跃子订单数(任一桶非零即有数据)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值一律忽略(不校验客户端值) |
| productId | Query | Long | ❌ | - | 精确匹配团期所属产品 |
| month | Query | String | ❌ | `yyyy-MM` | 出团月筛选:`depart_date >= 月初 && < 次月`;空/缺省 = 全部 |
| keyword | Query | String | ❌ | - | 团期号/团期名模糊 OR(trim 后;`%` `_` `\` 转义,与 GB-ADM-001 同义) |
无请求体。
#### 出参 `Result<GroupBatchSummaryVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| total | Integer | 七桶计数之和(接口自校验桶和,恒等于各桶值加总) |
| buckets | Map<String,Integer> | 七键恒全:RECRUIT/FORMED/PENDING_TRIP/TRAVELLING/AUDITING/CHECKED/DISBANDED;FORMED=RESOURCE_PREPARING+MATERIAL_PREPARING 复合 |
| subOrderCount | Integer | 跨全部命中团期的活跃子订单数(order_status != CANCELLED 且未软删;1 团期 1 房) |
#### 请求示例
```http
GET /v3/admin/order/group-batch/summary?month=2026-06&keyword=%E6%B5%8B%E8%AF%95
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"total": 14,
"buckets": {
"RECRUIT": 0, "FORMED": 13, "PENDING_TRIP": 0, "TRAVELLING": 0,
"AUDITING": 0, "CHECKED": 0, "DISBANDED": 1
},
"subOrderCount": 1
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "total": 0,
"buckets": { "RECRUIT": 0, "FORMED": 0, "PENDING_TRIP": 0, "TRAVELLING": 0,
"AUDITING": 0, "CHECKED": 0, "DISBANDED": 0 },
"subOrderCount": 0 }, "success": true }
```
#### 错误响应
```json
{ "code": 589507, "message": "无操作权限", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:list`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- 非法 month(非 `yyyy-MM`)→ 400 参数错误(框架级)。
- 空数据时七键仍全返回(各桶 0),total=0、subOrderCount=0,不 500 不降级。
---
### 2. GB-ADM-008 团期导出 `GET /v3/admin/order/group-batch/export`
**VO**: `text/csv`(流式附件,非 JSON 信封)
#### 使用场景
团期看板页「导出」按钮:按当前筛选口径整表导出 CSV 文件(浏览器附件下载)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| productId | Query | Long | ❌ | - | 同 summary |
| month | Query | String | ❌ | `yyyy-MM` | 同 summary;文件名 `group-batch-{month}.csv` |
| keyword | Query | String | ❌ | - | 同 summary |
无请求体。
#### 出参 `text/csv`(附件流)
| 字段 | 类型 | 说明 |
|------|------|------|
| Content-Type | String | `text/csv; charset=utf-8` |
| Content-Disposition | String | `attachment; filename=group-batch-{month|all}.csv` |
| body | String | UTF-8 BOM 开头的 CSV 文本:CRLF 换行、固定 10 列(表头见下) |
**表头 10 列**: 团期号、期号、日期、出团日、满团名额、已售、剩余、状态、子订单数、整团应收。
#### 请求示例
```http
GET /v3/admin/order/group-batch/export?month=2026-06
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
(响应体为原生 CSV 文本,非 JSON 信封;下表以 JSON 字符串形式呈现字节内容示例)
```json
"团期号,期号,日期,出团日,满团名额,已售,剩余,状态,子订单数,整团应收\r\nQ2026...,测试期,2026-06-10,2026-06-10,10,7,3,资源准备,1,15900.00\r\n"
```
#### 空数据 / 降级响应
0 命中仅输出表头 10 列(仍带 UTF-8 BOM 与 CRLF),HTTP 200。
#### 错误响应
```json
{ "code": 589517, "message": "导出数据超限", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:export`;未配置权限 → 589507。
- 命中行数 > 2000 → 589517(上限 2000 行),前端提示收窄筛选。
- 每次成功导出写入 `group_batch_status_log` 导出留痕(BATCH_EXPORT,data 类变更,记录操作管理员与导出档头内容)。
- 只读(READ_ONLY 事务);0 命中仅表头(BOM/CRLF 保持)。
- 非法 month → 400 参数错误;未登录 → 401(网关拦截)。
- 期号含逗号/引号/换行 → CSV 引号转义(内部引号翻倍);金额两位小数不加千分位;剩余=max_rooms-已售且下限 0。
---
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写后端接受/拒绝请求的规则,不写 UI 渲染建议。
### ✅ 正确 / ❌ 错误调用对照
| 场景 | 说明 |
|------|------|
| ✅ 仅带 Authorization 调 summary/export | 全量口径:7 桶恒全、export 全表(受 2000 行上限约束) |
| ✅ productId+month+keyword 组合筛选 | 与 GB-ADM-001 同义:productId 精确 / month 落出团月 / keyword trim 后模糊 OR(`%` `_` `\` 转义) |
| ❌ 未配置 `group-batch:list` / `group-batch:export` | 589507 无操作权限(两个授权点独立) |
| ❌ 命中 > 2000 行导出 | 589517 导出数据超限 |
| ❌ 客户端自行传 X-Admin-Id | 以网关注入值为准,客户端值一律忽略 |
### 切换状态时的必要动作
无状态切换——两端点均只读 GET、无请求体;导出需前端先具备 `group-batch:export` 授权点(看板查看仅需 `group-batch:list`)。
---
## 五、数据库行为(涉及写操作时必写)
两端点主体为只读(READ_ONLY 事务):聚合 `countActiveByProductBatchIds`(活跃子订单)、`sumBatchAmountsByProductBatchIds`(整团应收,共享 #6902 地基),无表变更、无字段变更。唯一写操作:成功导出后向 `group_batch_status_log` 追加一条 BATCH_EXPORT 留痕记录(`REQUIRES_NEW` 独立事务,导出失败不写留痕)。
---
## 六、边界行为
- 未登录/无有效 token → 401(网关拦截,两端点不允许匿名访问)。
- 未配置对应授权点 → 589507。(`/summary` 用 `group-batch:list`,`/export` 用 `group-batch:export`)
- 导出命中 > 2000 行 → 589517;前端应引导收窄 month/productId/keyword。
- 非法 month 格式 → 400 参数错误(框架级)。
- 空数据:summary 七键全 0;export 仅表头 10 列(BOM/CRLF 保持),不 500 不降级。
- 状态未知/历史脏数据:`safeStatus` 原样透出(导出的状态列不报错)。
- 剩余列恒 `max_rooms - 已售` 且下限 0;金额列两位小数、不加千分位。
- 期号含 `,` `"` `\r` `\n` → CSV 引号转义(内部引号翻倍)。
---
## 六.5、枚举 / 数据字典(接口类必写)
### buckets 键(7 桶)
**所属字段**: `GroupBatchSummaryVO.buckets` | **类型**: `Map<String,Integer>`
| 桶键 | 来源 batch_status | 说明 |
|------|------------------|------|
| RECRUIT | RECRUITING | 招募中 |
| FORMED | RESOURCE_PREPARING + MATERIAL_PREPARING | 复合桶(成团准备) |
| PENDING_TRIP | PENDING_DEPARTURE | 待出发 |
| TRAVELLING | TRAVELLING | 出游中 |
| AUDITING | REVIEWING | 审核 |
| CHECKED | SETTLED | 结算 |
| DISBANDED | CANCELLED | 流团/取消 |
> 说明:FORMED 复合桶、AUDITING/CHECKED 独立桶为接口层口径;前端展示可将 AUDITING+CHECKED 折叠为「返团核账」类目(display 层合并,接口不并)。
### 导出「状态」列中文名
**所属字段**: CSV 第 8 列 | **类型**: `String`
| batch_status | 中文 |
|--------------|------|
| RECRUITING | 招募中 |
| RESOURCE_PREPARING | 资源准备 |
| MATERIAL_PREPARING | 物料准备 |
| PENDING_DEPARTURE | 待出发 |
| TRAVELLING | 出游中 |
| REVIEWING | 审核中 |
| SETTLED | 已结算 |
| CANCELLED | 已流团 |
---
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- **仅影响**: 团期看板统计条(GB-ADM-009)与导出(GB-ADM-008)两个新增端点。
- **零影响**:
- #6905 交付的看板列表/详情/条件接口(GB-ADM-000~003)与文件(本单未改动)
- 共享地基 #6902 的 `OrderService`/`GroupBatchStageBuckets`(本单仅新增导出留痕方法与本枚举项,未改既有方法与接口)
- 一期(v2)所有接口;数据库结构、网关路由、权限点(复用既有 `group-batch:list`/`group-batch:export`)
---
## 八、测试环境已验证
**JUnit 定向测试**: `GroupBatchBoardStatsServiceTest`(11 用例)+ `GroupBatchBoardStatsControllerTest`(6 用例)+ `GroupBatchMapperEscapeLikeTest`(3 用例)全绿;覆盖七桶折叠、total 自校验、subOrderCount、筛选转义、CSV 格式、589507/589517、留痕写入。
**全模块** `mvn -pl hl-order-service-v3 -am test`(2026-09-01,分支合并至 dev-v3 后):Tests run 7872 / Failures 3 / Errors 0 / Skipped 7——3 个失败均为 refund 模块**既有**切片用例(旧契约期望 200+code401 信封,当前拦截器直返 401),已在独立基线 worktree(dev-v3 不含本次改动)复现,与本次 9 个文件变更无关。
真实接口网关验证(2026-09-01 部署 dev-v3@9f689354e 后实测):
```
GET /v3/admin/order/group-batch/summary → 200 code=200 buckets={RECRUIT:0,FORMED:13,PENDING_TRIP:0,TRAVELLING:0,AUDITING:0,CHECKED:0,DISBANDED:1} total=14 subOrderCount=1 ✓
GET /v3/admin/order/group-batch/summary?month=2026-06&keyword=测试 → 200 code=200 ✓
GET /v3/admin/order/group-batch/export → 200 size=1739 BOM ✓ CRLF ✓ 10 列 ✓
GET /v3/admin/order/group-batch/export?month=2026-06 → 200 size=98(0 命中仅表头) ✓
```
验证通道: 统一网关 `https://api.test.1814.love:9443`,管理员登录 token + 网关注入 `X-Admin-Id`。
---
## 九、相关历史 PR(纠错 / 功能演进时必写)
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #6917 | #6904 | 团期看板统计条 + 导出交付(本单) | ✅ 最新 |
| #6914 | #6902 | 共享地基(七桶枚举/聚合/权限/错误码) | ✅ 有效 |
| #6916 | #6915 | 共享地基 statusText 契约修正 | ✅ 有效 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#6904](https://git.1814.love:8443/wx/HL/issues/6904)
- 关联 PR: [wx/HL#6917](https://git.1814.love:8443/wx/HL/pulls/6917)(合并至 dev-v3 @9f689354e)
- 共享地基: #6902(七桶/聚合/权限)、#6915/#6916(契约修正)
## 十一、复审补充知会(2026-09-01)
> 复审轮对现行行为的口径确认,无新接口、无字段结构变更。
1. **排序口径不一致(暂行,待拍板)**:001 看板列表按 `create_time` **倒序**(既有行为未变),009 统计条 / 008 导出按 `depart_date` **升序**——「导出件顺序 ≠ 列表页顺序」当前属预期;001 是否改 `depart_date ASC` 待 wx 确认(决策点③),确认后另行通知对齐。
2. **008 当前忽略 `opsStage`**:本期 008 只收 productId/month/keyword,契约卡的 opsStage 筛选由返工 #6926 补上。**#6926 上线后:桶筛选态点「导出表格」必须带上当前 `opsStage`**,否则导出为全量(当前行为:传了也被静默忽略)。
3. 同随 #6926 上线:009 自校验日志修正(内部可观测性,前端无感)、month 非法值容错跳过(不再 500)。
## 关联 / 联系人
### 链接
- **Issue**: [#6904](https://git.1814.love:8443/wx/HL/issues/6904)
- **PR**: #6917
- **Merge commit**: 9f689354e
### 联系人
- **后端负责人**: @wx(GIT)
@@ -0,0 +1,467 @@
---
schema: "hl-changelog/v2"
ticket: "6905"
title: "团期看板 4 接口对齐补全 GB-ADM-000/001/002/003"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: "regionText 待上游 product-v2 补齐后由前端组另行验收(本单不验收前端展示)"
target_release: ""
verified_at: "2026-09-01"
status_note: "regionText 由上游 product-v2 提供后透传,当前测试环境返回 null 属预期;001 排序保持 create_time DESC 未变(depart_date ASC 变更待 wx 确认);#6929 已实现:003 totalPrice 应收字段 + birthdayInTrip 跨年修正(详见 §十一)"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 团期看板 4 接口对齐补全 GB-ADM-000/001/002/003
> **服务**: hl-order-service-v3
> **PR**: #6923(网关拦截 PUT 无法 API 合并,采用等价 squash 推送落地 dev-v3,PR 已 closed 留痕)
> **Issue**: #6905
> **日期**: 2026-09-01
> **影响范围**: 管理后台团期看板 4 个查询接口(产品列表/团期分页/团期详情/团期订单列表)
---
## ⚠️ 关键变化
- 本单**只做查询接口的字段/筛选/性能对齐**,未新建任何表/列,未新增写操作。
- 运营阶段映射为团期八态(opsStage 桶+八态并集),本单**不新增 ops_stage 字段/表**。
- **证件号与游客手机号永不返回**(编译期字段裁剪 + 运行期实测零返回),前端不得依赖。
- contactPhone 一期**全量掩码**(保前 3 后 4,如 138****0001)。
- 001 排序**保持 create_time DESC**(将 depart_date 升序的变更留待 wx 确认,PR 与进度中已注明)。
- regionText 由上游 product-v2 提供(order 侧仅透传),当前上游未提供时返回 null,**产品域交接后由前端组验收展示**。
---
## 一、背景
团期看板已实现 4 个查询接口(GB-ADM-000/001/002/003,见 #6902 共享件),本单按验收清单补齐全量字段、真实来源取值、批量聚合与筛选参数,消除 N+1。依赖 #6902(已合并 dev-v3)。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 产品团期看板(000) | GET | `/v3/admin/order/group-batch/products` | 新增响应字段+入参 | batchCount/regionText + productType 筛选 |
| 2 | 团期分页列表(001) | GET | `/v3/admin/order/group-batch` | 新增筛选+响应字段 | opsStage/month/keyword + chips/金额/orderCount |
| 3 | 团期详情(002) | GET | `/v3/admin/order/group-batch/<groupBatchId>` | 金额实时聚合+reporter | totalReceivable/totalReceived 实时、primaryReporter 批量 |
| 4 | 团期订单列表(003) | GET | `/v3/admin/order/group-batch/<groupBatchId>/orders` | 新增批量派生字段 | 成本/tier/人数/房车需求/掩码手机/游客(证件零返回) |
## 三、接口详情
### 1. 产品团期看板 `GET /v3/admin/order/group-batch/products`
**VO**: `GroupBatchProductItemRespVO`
#### 使用场景
管理后台看板"产品列表"页:按产品展示其团期批量数。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productType | Query | String | 否 | - | 产品类型筛选;不传=只看团期产品(上游恒定 GROUP 范围,详见「十一、复审补充知会」第 2 条勘误) |
| keyword | Query | String | 否 | - | 产品名关键词(原有行为不变) |
#### 出参 `Result<GroupBatchProductItemRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchCount | Integer | 该产品下活跃团期数(一次性 in 投影 product_id 后内存分组计数,无逐产品 count) |
| regionText | String | 产品地域文本(上游 product-v2 提供后透传;当前上游未提供时 null) |
#### 请求示例
```http
GET /v3/admin/order/group-batch/products?productType=&keyword=
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": []
}
```
#### 空数据 / 降级响应
```json
{
"code": 200,
"message": "成功",
"data": []
}
```
- 无数据 → 空数组,不报错。
- 未登录 → 401(网关拦截)。
#### 错误响应
```json
{
"code": 401,
"message": "未登录或登录已过期",
"data": null
}
```
#### 业务边界
- 未传筛选 = 不过滤,等价旧行为;regionText 为 null 时不阻断列表。
### 2. 团期分页列表 `GET /v3/admin/order/group-batch`
**VO**: `GroupBatchPageItemRespVO`
#### 使用场景
管理后台看板"团期列表"页:分页 + 筛选(阶段桶/月份/关键词)。
#### 入参(新增,均可选)
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| opsStage | Query | String | 否 | RECRUIT/FORMED/PENDING_TRIP/TRAVELLING/AUDITING/CHECKED/DISBANDED | 七桶折叠;FORMED=RRESOURCE_PREPARING+MATERIAL_PREPARING 复合桶;未知/空=不过滤 |
| month | Query | String | 否 | yyyy-MM | 按出发日期所在自然月区间过滤 |
| keyword | Query | String | 否 | - | 匹配团期编号/名称,服务端 trim+转义 % _ \\(防通配符扩匹配) |
#### 出参 `Result<PageResult<GroupBatchPageItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| receivableAmount | BigDecimal | 应收(批量 sum 实时聚合,非快照) |
| receivedAmount | BigDecimal | 实收(批量 sum 实时聚合,非快照) |
| chips | Object | 六芯片键:hotel/vehicle/guide/photo/contract/insurance(#6902 GroupBatchChipResolver 聚合) |
| orderCount | Integer | 该团期子订单数(批量 in 投影计数,无 N+1) |
#### 请求示例
```http
GET /v3/admin/order/group-batch?pageNo=1&pageSize=5&opsStage=FORMED&month=2026-09&keyword=x
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"records": [],
"total": 0
}
}
```
#### 空数据 / 降级响应
```json
{
"code": 200,
"message": "成功",
"data": {
"records": [],
"total": 0
}
}
```
- 无匹配 → records 空数组 total=0。
- 排序保持 create_time DESC(向后兼容,未做排序变更)。
#### 错误响应
```json
{
"code": 401,
"message": "未登录或登录已过期",
"data": null
}
```
#### 业务边界
- opsStage 非法/未知 = 不过滤(保守向后兼容);month 格式非法 → 业务 400。
### 3. 团期详情 `GET /v3/admin/order/group-batch/<groupBatchId>`
**VO**: `GroupBatchDetailRespVO`
#### 使用场景
看板点击团期看详情:实时金额 + 主报道人。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 团期 ID(路径参数) |
#### 出参 `Result<GroupBatchDetailRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| totalReceivable | BigDecimal | 应收实时聚合(sumBatchAmountsByProductBatchIds),非恒 0 |
| totalReceived | BigDecimal | 实收实时聚合,非恒 0 |
| primaryReporterId/primaryReporterName | Long/String | order_batch_staff 中 reporter_rank=PRIMARY 的首个;无 PRIMARY 配置时 null |
| secondaryReporterId/Name | Long/String | 副报道人(可 null) |
| advanceAmount | BigDecimal | 预付款(原字段,语义不变) |
#### 请求示例
```http
GET /v3/admin/order/group-batch/<groupBatchId>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": null
}
```
#### 空数据 / 降级响应
```json
{
"code": 200,
"message": "成功",
"data": null
}
```
- 团期不存在 → 业务 404。
#### 错误响应
```json
{
"code": 404,
"message": "团期不存在或已被删除",
"data": null
}
```
#### 业务边界
- PRIMARY 报道人未配置时两 reporter 字段为 null,不降级不报错。
### 4. 团期订单列表 `GET /v3/admin/order/group-batch/<groupBatchId>/orders`
**VO**: `GroupBatchOrderItemRespVO`
#### 使用场景
看板展开订单列表:成本/tier/人数/房车需求/游客(含旅行内生日、无证件号)。
#### 入参(新增,均可选)
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| includeTravelers | Query | Boolean | 否 | - | 是否加载游客数组(不传=不加载,零开销) |
| includeNeeds | Query | Boolean | 否 | - | 是否加载房车需求做派生聚合 |
| includeCancelled | Query | Boolean | 否 | - | 是否包含已取消子订单 |
#### 出参 `Result<List<GroupBatchOrderItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| estimatedCost | BigDecimal | 估算成本(无成本数据可 null) |
| totalPrice | BigDecimal(字符串) | 本户应收 = orderAmount + surchargeAmount − discountAmount(下限 0,`OrderAmountUtil.payableForDisplay` 口径;**取消单返 "0"**);预计毛利 = totalPrice − estimatedCost(#6929) |
| tierCode/tierName | String | tier 组合(成人A/儿童C/幼童Y/婴儿B,如 2A1C→"2成人1儿童";全零→null,映射表待 wx 确认) |
| participantCount | Integer | 人数聚合(adult+child+youngChild+baby) |
| youngChildCount/babyCount | Integer | 幼童/婴儿数 |
| roomCount | Integer | 房数=逐晚需求 segment 房间数最大值;无需求行缺省 ceil(人数/2) |
| roomType | String | 最大房数晚的 roomCategory "、" 连接;无 → null |
| specialNeeds | String | 需求 special_tags ";" 连接 → 需求行 remark → 缺需求行 customer_remark |
| contactPhone | String | **全量掩码**(保前 3 后 4) |
| travelerInfoComplete | Boolean | 游客信息完整(校验服务批量聚合) |
| travelers | Array | name/type/age/birthdayInTrip;**不含 idNo/phone(编译期字段裁剪 + 运行期零返回)** |
#### 请求示例
```http
GET /v3/admin/order/group-batch/<groupBatchId>/orders?includeTravelers=true&includeNeeds=true&includeCancelled=true
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": []
}
```
#### 空数据 / 降级响应
```json
{
"code": 200,
"message": "成功",
"data": []
}
```
- 无子订单 → 空数组。
- 证件号/游客手机号永不返回(数据安全边界)。
#### 错误响应
```json
{
"code": 401,
"message": "未登录或登录已过期",
"data": null
}
```
#### 业务边界
- include* 参数不传 = 不加载对应数据(零开销,向后兼容)。
---
## 四、契约约束与正确调用方式
- 全部新增参数可选;不传 = 旧行为(向后兼容)。
- `opsStage=FORMED` 代表"资源筹备中+物料筹备中"两态并集(复合桶),不是单一状态值。
- 金额一律 BigDecimal 字符串(ToStringSerializer 序列化),前端按字符串处理,避免精度丢失。
- 001 keyword 的 `%_` 会被转义为字面匹配;需要模糊查询请用 `%` 以外的普通字符。
- 003 的证件号/手机号**不存在于任何响应**,前端不得引用(编译期保证)。
## 五、数据库行为
- 本单**零写操作、零表/列变更、零迁移**;全部为查询层字段/聚合/筛选对齐。
## 六、边界行为
- 未登录 → 401(网关拦截)
- 资源不存在 → 业务 404
- 下游(order-stats/requirement)异常降级 → 金额/需求字段 null,不 500 不阻断页面
- 老数据无新列 → 新增字段 null,不异常
- 身份证号/手机号 → 永不返回(安全边界)
## 六.5、枚举 / 数据字典
### opsStage(GroupBatchStageBuckets 七桶)
| 值 | 中文 | 折叠状态 |
|----|------|----------|
| RECRUIT | 招募中 | (created) |
| FORMED | 已成团(复合桶) | RESOURCE_PREPARING + MATERIAL_PREPARING |
| PENDING_TRIP | 待出行 | - |
| TRAVELLING | 出行中 | - |
| AUDITING | 待审核 | - |
| CHECKED | 已审核 | - |
| DISBANDED | 已解散 | - |
### tierCode 组合(成人A/儿童C/幼童Y/婴儿B)
| 值 | 说明 |
|----|------|
| 2A1C | 2 成人 1 儿童(tierName="2成人1儿童") |
| 全零 | null(不输出) |
## 六.6、修改前后对比
### 字段级对比
| 接口 | 字段 | 改前 | 改后 |
|------|------|------|------|
| 000 | batchCount | 无 | 批量 in 投影计数 |
| 001 | receivableAmount/receivedAmount | 无(或空) | 实时聚合(2943.00/3270.00 测试实证) |
| 001 | chips/orderCount | 无 | 六芯片聚合/批量计数 |
| 002 | totalReceivable/totalReceived | 实体快照 | sumBatchAmounts 实时聚合(非恒 0) |
| 002 | primaryReporter | 无 | reporter_rank=PRIMARY 批量取值 |
| 003 | estimatedCost/tier/人数 | 无 | 批量派生 |
| 003 | contactPhone | 原文 | 全量掩码 |
| 003 | totalPrice | 无 | 本户应收(payableForDisplay 口径,取消单返 "0",#6929) |
| 003 | travelers | 无 | name/type/age/birthdayInTrip(无证件号,跨年修正 #6929) |
## 六.7、影响评估
- **是否破坏向后兼容**: 否(全部新增可选字段/参数)
- **前端是否必须同步上线**: 否
- **前端 workaround 清理点**: 无
## 七、不影响范围
- **仅影响**: 管理后台团期看板 4 个查询接口(响应新增字段 + 筛选参数)
- **零影响**:
- C 端 / MP 端接口
- 下单/支付/退款链路
- 数据库表结构(零迁移)
- #6902/#6903/#6904 已合并接口(本单未改其文件,仅顺带合并 dev-v3 时解决 GroupBatchMapper 尾部冲突取并集)
## 八、测试环境已验证
网关: `https://api.test.1814.love:9443`(`/v3/admin/**` → hl-order-service-v3,dev-v3 @ 47aaff0be,双实例 8086/8186 UP,BUILD SUCCESS 24.6s)
- `GET /v3/admin/order/group-batch?pageNo=1&pageSize=5` → 200 records=5 total=14,receivableAmount=2943.00 receivedAmount=3270.00,chips=hotel/vehicle/guide/photo/contract/insurance,orderCount=1 ✓
- 筛选实证: base_total=14 → keyword=0 / opsStage(FORMED)=13 / month(2026-09)=12 ✓
- `GET /v3/admin/order/group-batch/products` → 200 list=6,batchCount>0 产品 4/6(3/3/3/5),regionText=null(上游未提供,预期)✓
- `GET /v3/admin/order/group-batch/<groupBatchId>` → 200 keys(34),totalReceivable=2943.00 totalReceived=3270.00(非恒 0)✓,primaryReporterId/Name 字段在位(无 PRIMARY 配置时 null)✓
- `GET /v3/admin/order/group-batch/<groupBatchId>/orders?includeTravelers=true&includeNeeds=true&includeCancelled=true` → 200 orders=1,tierCode=2A tierName=2成人,roomCount=1,specialNeeds=脚本造单·…,contactPhone=138****0001(掩码)✓,travelerInfoComplete=true ✓,travelers len=2 keys=age,birthdayInTrip,name,type(无 idNo/phone)✓,样本: name=张伟 type=ADULT age=41 birthdayInTrip=false ✓
本地: 定向单测 19+15+27+8+3 全绿;模块全量 7832 用例仅 3 个 refund 401 用例失败且为 dev-v3 基线固有(git stash 对照同结果),与本单无关。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #6902 | #6902 | 共享件(chip/stats/buckets) | ✅ 依赖 |
| #6917 | #6904 | 看板统计条+导出(顺带合并冲突并集) | ✅ 有效 |
| **#6923** | **#6905** | 本单 4 接口对齐(squash 等价落地 47aaff0be,PR 因网关禁 PUT 已 closed 留痕) | ✅ 最新 |
## 十、相关文档
- 关联 Issue: [wx/HL#6905](https://git.1814.love:8443/wx/HL/issues/6905)
- 关联 PR: [wx/HL#6923](https://git.1814.love:8443/wx/HL/pulls/6923)
- 合并落点: [wx/HL commit 47aaff0be](https://git.1814.love:8443/wx/HL/commit/47aaff0be)(dev-v3)
- 后续计划: regionText 上游 product-v2 补齐 + 前端展示验收(另行交接)
## 十一、复审补充知会(2026-09-01)
> 复审轮对 #6903/#6904/#6905 已上线行为的口径确认与勘误,无新接口、无字段结构变更;其中 2 项已开返工(见文末表)。
### 联调口径(现行行为,按此开发)
1. **【001】无活跃子订单的团期 `chips` 整体为 `null`**(不是六键全「待办」)。有单团期六键全在(聚合态四值:待办/处理中/已完成/异常;个别键可为 null)。渲染芯片前判 `chips != null`,null 时按「未开始」占位。
2. **【000】`productType` 实际只能看团期产品**:上游产品域只返回 GROUP 产品,传非 GROUP 值得空列表;「不限类型查普通产品」是面向隐式团的规划能力,当前不可达。§三.1 入参表原「不传=不限」描述有误,已就地勘误,以本条为准。
3. **【003】`demandStatus`(本户需求态 SUBMITTED/CONFIRMED/REJECTED)不下发**:契约卡 GB-ADM-003 有该字段但本期未实现,响应中不存在;原型名单表「打回 / 已重提」列暂无数据源,请先隐藏或恒占位,勿依赖。
4. **【003】`totalPrice`(本户应收)已实现(#6929)**:「预计毛利 = totalPrice − estimatedCost」现可直接算(`estimatedCost` 已可用)。`totalPrice` 为 JSON 字符串(ToStringSerializer),活跃单 = orderAmount + surchargeAmount − discountAmount(下限 0),**取消单返 `"0"`**。**请勿用 `paidAmount + balanceAmount` 自算应收**(含退款场景口径不对)。
5. **【003】`include*=false` 时扩展字段「键在、值为 null」**:`travelers / roomCount / roomType / specialNeeds` 键仍存在、值为 `null`,判 `null` 即可,勿用 `key in obj` 判断。
6. **【003】`birthdayInTrip` 跨年已修复(#6929)**:行程跨年(12 月~1 月)时,出团年与返团年分别年化比较,任一落在行程闭区间即 `true`(如 12-28~01-03 行程内 01-02 生日 → true);2-29 生日在非闰年落 2-28 不抛异常。
### 在途返工(上线后另行同步)
| 工单 | 内容 | 前端影响 |
|------|------|----------|
| #6926 | 008 导出补 `opsStage`;009 自校验修正;month 非法值容错 | 见 `01_6904_*` 第十一节 |
| ~~#6929~~ | ~~003 补 `totalPrice`;`birthdayInTrip` 跨年修正;内部双包装收敛~~ | ✅ 已实现(PR #7057,部署验收后生效):`totalPrice − estimatedCost` 可直接算预计毛利;跨年生日不再漏报;其余无感 |
## 关联 / 联系人
### 链接
- **Issue**: [#6905](https://git.1814.love:8443/wx/HL/issues/6905)
- **PR**: [#6923](https://git.1814.love:8443/wx/HL/pulls/6923)(closed;网关拦截 PUT,采用等价 squash 推送落地)
- **Merge commit**: [47aaff0be](https://git.1814.love:8443/wx/HL/commit/47aaff0be)(dev-v3 落点)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,662 @@
---
schema: "hl-changelog/v2"
ticket: "6921"
title: "六芯片读端接入专用投影 + 车芯片车务文案修正"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "not_required"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "复审返工:#6903 六芯片读端由 order_main 整行实体查询改为 18 列专用投影(不再触发 customerPhone/emergencyContactPhone 解密),车芯片 statusText 由房务文案改为车务文案;2026-09-01 部署测试服 dev-v3,6 端点网关实测 200 全通过、团期不存在 589500,文案由单测锁定"
updated_at: "2026-09-01"
base: "dev-v3"
---
# 六芯片读端接入专用投影 + 车芯片车务文案修正
> **服务**: hl-order-service-v3
> **PR**: #6924(squash 合并落地 dev-v3 `35b7f2ed`)
> **Issue**: #6921(#6903 复审返工)
> **日期**: 2026-09-01
> **影响范围**: 管理后台团期看板行右侧六芯片(配房/配车/配导游/配摄影/合同/保险)逐户下钻接口(GB-ADM-090~095)
---
## ⚠️ 关键变化
- **读端投影化(实现层,对外契约不变)**:六芯片逐户明细改为从 `order_main` 的 **18 列专用投影**读取,不再整行加载实体,因此**不再触发 `customerPhone` / `emergencyContactPhone` 解密逻辑**;返回体、字段名、枚举、免闸计数口径全部不变。
- **车芯片文案修正(对外可观察)**:`items[].statusText` 车务文案为 `待车务配 / 配车中 / 配车完成`(房务文案 `待房务配 / 配房中 / 配房完成` 不变)。前版 #6903 文档误写"与房同文案",#6921 按车务实际口径修正。
- 对外响应结构、错误码、聚合态、免闸口径与 #6903 完全一致,**无任何字段新增/删除/改名**,前端无需改动。
---
## 一、背景
#6903 交付的六芯片接口在代码走查中发现读端直接加载 `OrderInfo` 实体并触发手机号解密,存在无关数据暴露与无效解密开销;同时车芯片文案错误沿用房务文案。本单为复审返工:#6901/#6903 接口契约不变,仅修正读端实现与车芯片文案。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | GB-ADM-090 配房逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/hotel` | 修改接口 | 读端改专用投影,对外不变 |
| 2 | GB-ADM-091 配车逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/vehicle` | 修改接口 | 读端改专用投影 + statusText 车务文案 |
| 3 | GB-ADM-092 配导游逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/guide` | 修改接口 | 读端改专用投影,对外不变 |
| 4 | GB-ADM-093 配摄影逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/photo` | 修改接口 | 读端改专用投影,对外不变 |
| 5 | GB-ADM-094 合同逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/contract` | 修改接口 | 读端改专用投影,对外不变 |
| 6 | GB-ADM-095 保险逐户明细 | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/insurance` | 修改接口 | 读端改专用投影,对外不变 |
---
## 三、接口详情
六个接口共用 `GroupBatchChipDetailVO` / `GroupBatchChipItemRespVO`,本单为复审返工:仅读端实现与车芯片文案变化,契约字段与 #6903 完全一致,正文各自自包含。
### 1. GB-ADM-090 配房逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/hotel`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「房」芯片点击展开:返回该团期每户的配房进度(哪一户到哪一步),前端展开列表展示。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「配房」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常) |
| totalCount | Integer | 计入统计的子订单数(免闸户不计入) |
| doneCount | Integer | 已完成户数(`status==DONE`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户配房状态,`RequirementStatus` 6 值,见「六.5」;无需/未开始为 null |
| items[].statusText | String | 状态中文名(服务端给出,房务文案,见「六.5」) |
| items[].needsIt | Boolean | 本户是否需要配房(`order_main.needs_hotel`);false=免闸户,status=null、置灰、不计入计数 |
| items[].updateTime | String | 配房最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/hotel
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "配房",
"aggregateStatus": "DONE",
"totalCount": 3,
"doneCount": 3,
"items": [
{ "orderId": "770153", "orderNo": "GT-26-0097", "contactName": "林婉清",
"peopleCount": 3, "status": "DONE", "statusText": "配房完成",
"needsIt": true, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "配房",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- 本单读端改为专用投影,返回体与 #6903 完全一致(契约不变)。
### 2. GB-ADM-091 配车逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/vehicle`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「车」芯片点击展开:返回该团期每户的配车进度,前端展开列表展示。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「配车」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常),与 GB-ADM-001 `chips.vehicle` 同源 |
| totalCount | Integer | 计入统计的子订单数(免闸户不计入) |
| doneCount | Integer | 已完成户数(`status==DONE`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户配车状态,`RequirementStatus` 6 值,见「六.5」;无需/未开始为 null |
| items[].statusText | String | 状态中文名(服务端给出,**车务文案**,见「六.5」) |
| items[].needsIt | Boolean | 本户是否需要配车(`order_main.needs_vehicle`);false=免闸户置灰不计入 |
| items[].updateTime | String | 配车最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/vehicle
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "配车",
"aggregateStatus": "DONE",
"totalCount": 3,
"doneCount": 3,
"items": [
{ "orderId": "770160", "orderNo": "GT-26-0101", "contactName": "王强",
"peopleCount": 2, "status": "DONE", "statusText": "配车完成",
"needsIt": true, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "配车",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- **本单唯一文案变化**:statusText 使用车务文案(待车务配/配车中/配车完成),不再与房同文案。
- 免闸户(`needsIt=false`):status=null、statusText=「无需」、置灰、不计入计数。
### 3. GB-ADM-092 配导游逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/guide`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「导」芯片点击展开:返回每户配导游进度。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「配导游」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常) |
| totalCount | Integer | 计入统计的子订单数(免闸户不计入) |
| doneCount | Integer | 已完成户数(`status==DONE`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户配导游状态,3 值:`NONE` 无需/未开始 / `PENDING` 待指派 / `DONE` 已指派,见「六.5」 |
| items[].statusText | String | 状态中文名(服务端给出,见「六.5」) |
| items[].needsIt | Boolean | 本户是否需要配导游(`order_main.needs_guide`);false=免闸户 status 恒 `NONE`、不计入计数 |
| items[].updateTime | String | 配导游最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/guide
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "配导游",
"aggregateStatus": "DOING",
"totalCount": 4,
"doneCount": 2,
"items": [
{ "orderId": "770170", "orderNo": "GT-26-0102", "contactName": "周磊",
"peopleCount": 2, "status": "DONE", "statusText": "已指派", "needsIt": true, "updateTime": null },
{ "orderId": "770171", "orderNo": "GT-26-0103", "contactName": "吴芳",
"peopleCount": 1, "status": "NONE", "statusText": "无需", "needsIt": false, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "配导游",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- 免闸户(needsIt=false)status 为 `NONE`(非 null),与房/车(null)区分;前端按 NONE 置灰。
### 4. GB-ADM-093 配摄影逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/photo`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「摄」芯片点击展开:返回每户配摄影进度。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「配摄影」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常) |
| totalCount | Integer | 计入统计的子订单数(免闸户不计入) |
| doneCount | Integer | 已完成户数(`status==DONE`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户配摄影状态,3 值:`NONE` 无需/未开始 / `PENDING` 待指派 / `DONE` 已指派,见「六.5」 |
| items[].statusText | String | 状态中文名(服务端给出,见「六.5」) |
| items[].needsIt | Boolean | 本户是否需要配摄影(`order_main.needs_photographer`);false=免闸户 status 恒 `NONE`、不计入计数 |
| items[].updateTime | String | 配摄影最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/photo
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "配摄影",
"aggregateStatus": "DOING",
"totalCount": 3,
"doneCount": 1,
"items": [
{ "orderId": "770180", "orderNo": "GT-26-0104", "contactName": "郑涛",
"peopleCount": 2, "status": "DONE", "statusText": "已指派", "needsIt": true, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "配摄影",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- 免闸户(needsIt=false)status 为 `NONE`(非 null),前端按 NONE 置灰。
### 5. GB-ADM-094 合同逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/contract`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「约」芯片点击展开:返回每户合同(团约)进度。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「合同」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常) |
| totalCount | Integer | 计入统计的子订单数(免闸户不计入) |
| doneCount | Integer | 已完成户数(`status==SIGNED`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户合同状态(`ContractStatus`),见「六.5」;无合同记录为 null |
| items[].statusText | String | 状态中文名(服务端给出,见「六.5」);无合同为「无合同」 |
| items[].needsIt | Boolean | 本户是否需要合同(`order_main.needs_contract`);false=免闸户不计入 |
| items[].updateTime | String | 合同最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/contract
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "合同",
"aggregateStatus": "整团待办",
"totalCount": 1,
"doneCount": 0,
"items": [
{ "orderId": "770190", "orderNo": "GT-26-0105", "contactName": "钱进",
"peopleCount": 2, "status": "SIGNED", "statusText": "已签署", "needsIt": true, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "合同",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- 老数据无合同记录:status=null、statusText=「无合同」,不异常不计入。
### 6. GB-ADM-095 保险逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/insurance`
**VO**: `GroupBatchChipDetailVO`
#### 使用场景
团期看板行右侧「保」芯片点击展开:返回每户保险进度。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| X-Admin-Id | Header | Long | ✅ | 网关注入 | 受信管理员 ID;客户端传值忽略 |
| groupBatchId | Path | String | ✅ | 团期 ID(Snowflake) | 不存在/非团期/已软删 → 589500 |
无查询参数、无请求体。
#### 出参 `Result<GroupBatchChipDetailVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| batchId | String | 团期 ID |
| chipLabel | String | 固定「保险」 |
| aggregateStatus | String | 整团聚合态 四态取值与看板 GB-ADM-001 `chips.X` 的 `aggregateStatus` 完全一致(整团待办/进行中/已完成/异常) |
| totalCount | Integer | 计入统计的子订单数(免闸户不计入) |
| doneCount | Integer | 已完成户数(`status==INSURED`) |
| items[].orderId | String | 子订单 ID(JSON String 化) |
| items[].orderNo | String | 子订单编号 |
| items[].contactName | String | 联系人/客户姓名 |
| items[].peopleCount | Integer | 本户人数 = adult+child+youngChild+baby |
| items[].status | String | 本户保险状态,见「六.5」;无保险记录为 null |
| items[].statusText | String | 状态中文名(服务端给出,见「六.5」);无保险为「无保险」 |
| items[].needsIt | Boolean | 本户是否需要保险(`order_main.needs_insurance`);false=免闸户不计入 |
| items[].updateTime | String | 保险最后变更时间;无独立时间戳时为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/chips/insurance
Authorization: Bearer ****
X-Admin-Id: 3301
```
#### 响应示例
```json
{
"code": 200,
"data": {
"batchId": "90211",
"chipLabel": "保险",
"aggregateStatus": "整团待办",
"totalCount": 1,
"doneCount": 0,
"items": [
{ "orderId": "770200", "orderNo": "GT-26-0106", "contactName": "孙丽",
"peopleCount": 2, "status": "INSURED", "statusText": "已出单", "needsIt": true, "updateTime": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
```json
{ "code": 200, "data": { "batchId": "90211", "chipLabel": "保险",
"aggregateStatus": "整团待办", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true }
```
#### 错误响应
```json
{ "code": 589500, "message": "团期不存在", "success": false, "data": null }
```
#### 业务边界
- 授权码 `group-batch:view`;未配置权限 → 589507。
- 只读(READ_ONLY 事务),无锁、无幂等、不改变任何状态。
- 老数据无保险记录:status=null、statusText=「无保险」,不异常不计入。
---
## 四、契约约束与正确调用方式
- **只读**:六端点全部为 GET 查询,READ_ONLY 事务,不产生任何写操作;重复调用结果一致(无副作用)。
- **授权**:必须携带管理端登录令牌,网关校验 `group-batch:view` 权限;未配置权限 → 589507。
- **入参**:仅 Path 参数 `groupBatchId`(Snowflake 团期 ID),无查询参数、无请求体。
- **安全**:读端 18 列专用投影不含任何手机号/证件列,**不会返回也不解密 `customerPhone` / `emergencyContactPhone`**;前端不得依赖此类字段。
- **免闸口径**:`needsIt=false` 户不计入 totalCount/doneCount;房/车/约/保 status=null+「无需」/「无合同」/「无保险」,导/摄 status=`NONE`+「无需」。
- **聚合态**:`aggregateStatus` 与看板 GB-ADM-001 `chips.X` 同源同算法(共享 `GroupBatchChipResolver`)。
---
## 六、边界行为
- 未登录/无有效 token → 401(网关拦截,本组接口不允许匿名访问)。
- 未配置 `group-batch:view` 权限 → 589507 无操作权限。
- 团期不存在/非团期/已软删 → 589500 团期不存在。
- 任意芯片端点输入非法(groupBatchId 非数字)→ 400 参数错误(框架级)。
- 空团期/无活跃子订单 → data 正常返回:totalCount=0、doneCount=0、items=[],不 500 不降级。
- 下游数据缺失(老数据无对应需求/合同/保险记录)→ 对应 status 为 null(房/车/约/保)或 NONE(导/摄),不异常。
- 团期流团(CANCELLED)→ aggregateStatus 恒整团待办(wire 值同看板 chips.X),明细仍如实返回。
- 已返团(审核/结算)→ 房车导摄恒 DONE 聚合。
- 列表查询与整团伙计数一次性内存聚合(防 N+1),单次请求最多一次投影查询。
---
## 六.6、修改前后对比
| 维度 | 修改前(#6903) | 修改后(#6921) |
|------|------|------|
| 读端数据源 | 加载 `OrderInfo` 整行实体再取字段 | `order_main` **18 列专用投影**(`listChipProjectionByProductBatchIds`),不再整行加载 |
| 手机号解密 | 读端会触发 `customerPhone` / `emergencyContactPhone` 解密(本单实测从未返回,但存在触发路径) | 投影列不含手机号列,**编译期零手机列、运行时零解密** |
| 车芯片 statusText | 与房同文案(待房务配/配房中/配房完成) | **车务文案**:待车务配/配车中/配车完成(驳回文案与房一致) |
| 对外响应结构 | `GroupBatchChipDetailVO` | 完全一致(无字段新增/删除/改名) |
| 错误码/聚合/免闸口径 | 同 #6903 | 完全一致 |
## 六.7、影响评估
- **调用方影响**:无。响应结构、字段名、枚举、错误码、聚合态、免闸计数口径全部不变,管理后台前端无需改动。
- **性能**:读端从整行实体 + 可能触发解密,改为 18 列投影 + 内存聚合,减少列宽与解密开销(本单不涉及额外查询次数)。
- **安全**:消除读端手机号解密触发路径,减少无关敏感字段暴露面。
- **回归范围**:六芯片端点 + 共享 `GroupBatchChipResolver` 文案;已用模块全量 7874 用例回归,仅 3 个 dev-v3 既有 refund 401 基线用例失败(与本单无关)。
- **其他服务**:不涉及 Feign/域事件/表结构变更,未触碰 #6905 GroupBatchQueryService 与 OrderInfoMapper 契约。
---
## 七、不影响范围
- 写操作(配房/配车/配导/配摄/合同/保险状态流转)
- 下单/支付/退款链路
- 数据库表结构(零迁移)
- #6905 团期看板 4 接口(GB-ADM-000/001/002/003,本单未改)
- OrderInfoMapper 既有查询契约(仅新增只读投影方法,未改任何既有方法)
- 前端展示逻辑(本次为纯后端返工,前端无需改动)
## 八、测试环境已验证
网关: `https://api.test.1814.love:9443`(`/v3/admin/**` → hl-order-service-v3,dev-v3 @ `0d5f8c568`,双实例 8086/8186 UP)
- 6 端点对真实团期 `2089713777065832450` 网关实测全通过:hotel/vehicle/guide/photo/contract/insurance → 200 code=200,chipLabel 正确,items 在位 ✓
- 团期不存在 `999999999999999999` → 589500 团期不存在 ✓
- 测试数据无 PROCESSING/DONE 态订单(status 全空或无需/无合同/无保险),车务文案(待车务配/配车中/配车完成)由 `GroupBatchChipResolverTest` 单测锁定 ✓
- 本地:chip 定向 229 用例全绿;模块全量 7874 用例仅 3 个 dev-v3 refund 401 基线失败(与本单无关)✓
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #6902 | #6902 | 共享件(chip/stats/buckets) | ✅ 依赖 |
| #6903 | #6903 | 六芯片逐户明细新增接口(本单返工对象) | ✅ 有效 |
| #6916 | #6915 | 共享地基 statusText 契约文案修正 + 测试补齐 | ✅ 依赖 |
| #6924 | #6921 | 本单复审返工(读端投影 + 车务文案)squash 合并 `35b7f2ed` | ✅ 最新 |
## 十、相关文档
- 关联 Issue: [wx/HL#6921](https://git.1814.love:8443/wx/HL/issues/6921)
- 关联 PR: [wx/HL#6924](https://git.1814.love:8443/wx/HL/pulls/6924)
- 合并落点: [wx/HL commit 35b7f2ed](https://git.1814.love:8443/wx/HL/commit/35b7f2ed)(dev-v3)
- 原接口文档: #6903 `01_6903_团期看板六芯片逐户明细-GB-ADM-090~095-新增接口-管理后台.md`(本单已同步其车务文案描述)
## 关联 / 联系人
### 链接
- **Issue**: [#6921](https://git.1814.love:8443/wx/HL/issues/6921)
- **PR**: [#6924](https://git.1814.love:8443/wx/HL/pulls/6924)(squash 合并)
- **Merge commit**: [35b7f2ed](https://git.1814.love:8443/wx/HL/commit/35b7f2ed)(dev-v3 落点)
### 联系人
- 后端: wx(GIT)
- 前端: 待定(本次无前端改动)
@@ -0,0 +1,259 @@
---
schema: "hl-changelog/v2"
ticket: "6938"
title: "供应商注册提交可选主表字段缺席保留"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-02"
status_note: "PR #6941(含补充 #6945/#6946)已合并 dev-v3,合并链终态提交 697ae6f57 已滚动部署 TEST 双实例。注册提交接口可选主表字段语义由缺席清空改为缺席保留;TEST 真实网关验收 32 项断言通过。"
updated_at: "2026-09-02"
base: "dev-v3"
---
# 供应商模块: 注册提交可选主表字段缺席保留
> **存放目录**: 二期(v3) → `changelogs-v2/2026-09/`
>
> **服务**: hl-resource-service (端口 8082)
> **PR**: #6941
> **Issue**: #6938
> **日期**: 2026-09-02
> **影响范围**: 管理端供应商注册提交表单
---
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
- 本次变化:注册提交保存完整表单时,**缺席或空白的可选主表字段保留草稿现值,不再清空**。
- 前端以前以为的:提交表单未回显的字段(如公司联系电话)提交后仍在。
- 实际旧行为:提交瞬间被静默置空(本单修复前的缺陷);**新行为**:缺席=保留,与增量更新接口语义对齐。
---
## 一、背景(选填)
工单 #6938:供应商提交注册时全量表单静默清空未回传字段(公司联系电话丢失)。根因是提交链路按"完整表单权威覆盖"语义把缺席/空白字段写成 NULL 并留下清空审计。修复后提交与增量更新共用"空白=保留"语义。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 提交供应商注册审批 | POST | `/admin/supplier/items/{supplierId}/submit` | 字段缺席语义变更 | 可选主表字段缺席/空白由"清空"改为"保留现值" |
---
## 三、接口详情
### 1. 提交供应商注册审批 `POST /admin/supplier/items/{supplierId}/submit`
**VO**: `SupplierSubmitReqVO`
#### 使用场景
管理端供应商编辑页点击"提交注册"时调用;提交前应用 `GET /admin/supplier/items/{supplierId}/basic-info/view` 回显完整表单。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| fullName | Body | String | ✅ | - | 全称,请求权威提供 |
| taxNo | Body | String | ✅ | GB32100 校验位 | 税号,请求权威提供 |
| mainCooperation | Body | String | ✅ | - | 主要合作内容 |
| balance | Body | BigDecimal | ✅ | ≥0 | 余额 |
| paymentType | Body | String | ✅ | - | 支付类型 |
| licenseImageUrl | Body | String | ✅(提交时) | - | 执照影像,维持请求侧必填门禁,缺席拒绝 |
| expectedUpdateTime | Body | LocalDateTime | ✅ | `yyyy-MM-dd HH:mm:ss` | 乐观锁,取视图 updateTime |
| shortName / contactPhone / address / countyId / establishDate / registeredCapital / businessScope / staffScale / remark | Body | - | ❌ | - | **缺席或空白 = 保留草稿现值(本次变更)** |
| legalRepresentative / legalRepresentativeIdNo / legalRepresentativeIdCardFrontUrl / legalRepresentativeIdCardBackUrl | Body | - | ❌ | 格式校验 | 法人四件套,同上缺席保留 |
| types / contacts / qualifications / initialAccounts | Body | List | ❌ | - | 子项快照;`contacts`/`initialAccounts` 缺席 = 保留历史 |
#### 出参 `Result<SupplierApprovalCommandRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| approvalStatus | String | 受理后 `PENDING` |
| spNo | String | 审批单号(TEST 现为真实企微单号) |
#### 请求示例
```json
{
"fullName": "HL6938-TEST-20260902",
"taxNo": "91150100HL6938T01W",
"mainCooperation": "车队合作",
"balance": "0.00",
"paymentType": "1",
"licenseImageUrl": "https://example.com/hl6938-license.png",
"expectedUpdateTime": "2026-09-02 11:05:52",
"types": [{"typeCode": "FLEET", "isPrimary": 1}],
"qualifications": [{"qualType": "GZZRX_LICENSE", "certNo": "GZZRX2026HL6938A", "permanentValid": true}]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": { "approvalStatus": "PENDING", "spNo": "202609020001" },
"success": true
}
```
#### 空数据 / 降级响应
写接口无空数据场景;提交受理即返回审批单号,无降级分支。
```json
{ "code": 200, "data": { "approvalStatus": "PENDING" }, "success": true }
```
#### 错误响应
```json
{
"code": 400,
"message": "expectedUpdateTime不能为空",
"success": false,
"data": null
}
```
#### 业务边界
- 鉴权:写权限 `FINANCE`/`SUPER_ADMIN`;未登录 → 业务码 `401`。
- 乐观锁:`expectedUpdateTime` 与现值不符 → 业务码 `395014`,零写入。
- 非草稿状态重复提交 → 状态机拒绝,零写入。
- 表单与草稿一致时不产生"提交注册前保存完整表单"变更记录。
---
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload |
|------|---------|
| ✅ 回显全量已填字段提交 | 视图字段全部带回,行为与旧版一致 |
| ✅ 可选字段缺席 | `contactPhone` 等缺省 → 保留草稿现值,**不再清空** |
| ✅ 显式修改可选字段 | `contactPhone: "04712227654"` → 覆盖并双向审计留痕 |
| ❌ 缺席 `licenseImageUrl` | 提交必填门禁拒绝(业务码 3950xx 资质必填) |
| ❌ 缺席 `expectedUpdateTime` | 400,`expectedUpdateTime不能为空` |
- 缺席保留意味着可选字段**不再支持以空白清空**;确需清空请联系后端评估独立入口。
- `expectedUpdateTime` 必须取最近一次 `basic-info/view` 返回的 `updateTime`。
---
## 五、数据库行为(涉及写操作时必写)
| 前端提交 | `contact_phone` 等可选列 |
|----------|--------------------------|
| 字段缺席或空白 | **保留数据库现值(不再 SET NULL)** |
| 字段显式提供新值 | 覆盖为新值,`supplier_change_log` 记录旧→新 |
- 无 migration、DDL 或 DML;历史已被清空的旧数据不回写。
---
## 六、边界行为
- 未登录 → 业务码 `401`(网关包装 HTTP 200)。
- 乐观锁冲突 → 业务码 `395014`,不阻断页面,重新取视图重试即可。
- 旧客户端继续全量回显提交 → 行为不变。
- 历史已清空数据 → 保持 NULL,不异常、不回写。
---
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| 12 个可选文本字段(`contactPhone` 等)缺席/空白 | 清空为 NULL 并记清空审计 | 保留草稿现值,无审计 |
| `countyId`/`establishDate` 缺席 | 清空为 NULL | 保留草稿现值 |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 提交表单未回显可选字段 | 字段被静默清空 | 字段保留 |
| 提交审计 delta | 出现非用户主动的清空留痕 | 只记录真实变化;一致时不落审计行 |
## 六.7、影响评估(修改/删除类必写)
- **是否破坏向后兼容**: 否。请求/响应结构不变;仅缺席字段行为由清空变保留,无合理调用方依赖清空语义。
- **前端是否必须同步上线**: 否。建议跟进修复提交表单回显(工单验收②的前端分支),后端缺席保留已兜底。
- **前端 workaround 清理点**: 无。
---
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- **仅影响**: 管理后台供应商注册提交接口的可选主表字段缺席行为。
- **零影响**:
- 供应商新增 `/add`、增量更新 `/update`(其 `licenseImageUrl` 既有空白清空分支保持原样,不在本单)
- 必填字段(全称/税号/合作内容/余额/支付类型/执照影像)的请求权威语义
- 供应商状态机、审批流、企微 Provider 交互、审批候选快照结构
- C 端接口与历史存量数据
---
## 八、测试环境已验证
真实 Gateway(`api.test.1814.love`)+ TEST 数据库实证,带 ✓ 标记:
```
POST /admin/supplier/items/add → 200 草稿含全量可选字段 ✓
POST /admin/supplier/items/{id}/submit(缺席可选字段) → 200 PENDING + 真实单号 ✓
GET /admin/supplier/items/{id}/basic-info/view → 九个可选字段全部保留 ✓
(contactPhone=04711234567 座机形态保留,验收①②)
DB supplier_change_log 提交审计 changedFields → 恰为 [contacts,qualifications],
九字段均不在列,无 "contactPhone":null 清空形态 ✓(验收③)
POST submit(显式改 contactPhone) → 新值生效,审计 before/after
双向留痕 13847110001→04712227654 ✓(兼容性)
```
验证夹具: `HL6938-TEST-20260902`(supplierId=2094985119537217538) / `HL6938B-TEST-20260902`(supplierId=2094987591035101186);验收产生的真实企微在途单已发起撤销。
自动化: `SupplierAggregateWriterTest` 定向零失败;hl-resource-service 全量 2293 项零失败。部署: TEST 任务 `dcdfb70a`,部署提交 `697ae6f57` 与预期一致,双实例滚动健康。
---
## 九、相关历史 PR(纠错 / 功能演进时必写)
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #6930 | #6927 | 注册提交接企微四级串行审批 | ✅ 有效 |
| **本 PR #6941** | **#6938** | 可选主表字段缺席保留(补充 #6945/#6946) | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#6938](https://git.1814.love:8443/wx/HL/issues/6938)
- 关联 PR: [wx/HL#6941](https://git.1814.love:8443/wx/HL/pulls/6941)
## 关联 / 联系人
### 链接
- **Issue**: [#6938](https://git.1814.love:8443/wx/HL/issues/6938)
- **PR**: [#6941](https://git.1814.love:8443/wx/HL/pulls/6941)(补充 [#6945](https://git.1814.love:8443/wx/HL/pulls/6945)、[#6946](https://git.1814.love:8443/wx/HL/pulls/6946))
- **Merge commit**: [697ae6f57](https://git.1814.love:8443/wx/HL/commit/697ae6f57aeea96b0567cfc8766e6ff1a091c1da)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,441 @@
---
schema: "hl-changelog/v2"
ticket: "6950"
title: "团期人员配置候选列表(导游位并收 GUIDE/LEADER)"
consumer: "admin"
author: "jw(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-03"
status_note: "PR #6962 已合入 dev-v3;2026-09-03 经测试环境网关实测,6 条正负向用例全部通过。前端尚未接入"
updated_at: "2026-09-03"
base: "dev-v3"
---
# 团期人员配置: 新增人员配置候选列表接口,并修正 staffRole 取值校验
> **存放目录**: 二期(order-v3)→ `changelogs-v2/2026-09/`
>
> **服务**: hl-order-service-v3
> **PR**: #6962
> **Issue**: #6950
> **日期**: 2026-09-02
> **影响范围**: 管理后台「团期详情 → 配置导游 / 配置摄影」弹窗的人员资源库列表
---
## ⚠️ 关键变化
本次除新增候选列表接口外,**另有一处行为变更**,调用方必须知道:
**保存团期人员配置 `PUT /v3/admin/group-batch/:groupBatchId/staff` 的 `staffList[].staffRole` 加了枚举白名单校验。**
- 前端以前以为:`staffRole` 只做非空校验,传什么都能存进去。
- 实际现在是:白名单外的取值 **返回 400**,不再原样入库。
- 白名单:`LEADER` / `GUIDE` / `DRIVER` / `PHOTOGRAPHER` / `OTHER`。
另外该字段的 Swagger 描述此前 **漏了 `GUIDE`**,本次补全。若前端此前按旧描述认为只有 4 个取值,现在是 5 个。
---
## 一、背景
「配置导游」「配置摄影」弹窗需要一份可选人员列表,此前 order-v3 侧没有这个接口,前端无从取候选池。
**资源域零改动**:`GET /internal/staff/list-available` 本就存在(其 Swagger 注释写明「供管理后台团批分配人员的下拉选择框使用」),order-v3 侧只补了 Feign 方法与降级处理。
**导游位为什么并收两类**(2026-09-02 jw 裁决):资源域字典里 `GUIDE` 是导游、`LEADER` 是领队,两者在团期现场都可能承担带团职责,由配置人按实际情况挑,**服务端不替业务做取舍**。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 查询团期人员配置候选列表 | GET | `/v3/admin/group-batch/:groupBatchId/staff/candidates` | **新增接口** | 导游位并收 GUIDE/LEADER,摄影位只收 PHOTOGRAPHER |
| 2 | 保存团期人员配置 | PUT | `/v3/admin/group-batch/:groupBatchId/staff` | **请求体新增校验** | `staffList[].staffRole` 加枚举白名单,越界返 400 |
---
## 三、接口详情
### 1. 查询团期人员配置候选列表 `GET /v3/admin/group-batch/:groupBatchId/staff/candidates`
**VO**: `StaffCandidateRespVO`
#### 使用场景
管理后台「团期详情 → 配置导游 / 配置摄影」弹窗打开时调用,用于渲染可选人员资源库列表。
弹窗按配置位分别调用:导游弹窗传 `role=GUIDE`,摄影弹窗传 `role=PHOTOGRAPHER`。
列表中 `assigned=true` 的项应预置为已勾选状态,供配置人在原有选择基础上增删。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 团期批次 ID。**这里是产品侧 `group_tour_batch.batch_id`**,不是订单侧运营团期主键,与 `order_batch_staff.group_batch_id` 同源 |
| `role` | Query | String | ✅ | 只接受 `GUIDE` / `PHOTOGRAPHER` | 配置位。`GUIDE`=导游位(并收 GUIDE/LEADER,可选多人);`PHOTOGRAPHER`=摄影位。**其余取值(含 `DRIVER`)返回 582113** |
> `DRIVER` 被显式拒绝:司机由车务派车产生,不在团期人员配置里手工指定。
#### 出参 `Result<List<StaffCandidateRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `staffId` | Long | 人员 ID |
| `staffName` | String | 姓名 |
| `staffPhone` | String | 手机号,**前 3 后 4 脱敏**(与 `getConfig` 口径一致) |
| `staffType` | String | 资源域人员类型:`GUIDE` / `LEADER` / `PHOTOGRAPHER` |
| `avatarUrl` | String | 头像 URL;资源域无头像或取头像失败时为 `null` |
| `assigned` | Boolean | 是否已被本团期选中。**`true` 时前端应显示为已勾选** |
| `assignedRole` | String | 已选中时对应的 `order_batch_staff.staff_role`;未选中为 `null` |
> `assigned` 与 `assignedRole` 要分开判:存在「已选但角色为空」的历史数据,此时 `assigned=true` 而 `assignedRole=null`,前端**不能**用 `assignedRole != null` 判断勾选态。
#### 请求示例
```http
GET /v3/admin/group-batch/1823456789012345678/staff/candidates?role=GUIDE
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{
"staffId": 1823456789012345678,
"staffName": "张三",
"staffPhone": "138****8000",
"staffType": "GUIDE",
"avatarUrl": "https://oss.example.com/avatar/1.jpg",
"assigned": true,
"assignedRole": "LEADER"
},
{
"staffId": 1823456789012345679,
"staffName": "李四",
"staffPhone": "139****1234",
"staffType": "LEADER",
"avatarUrl": null,
"assigned": false,
"assignedRole": null
}
]
}
```
#### 空数据 / 降级响应
资源域无可用人员时返回空数组,不报错:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": []
}
```
头像服务取不到时**不阻断列表**,`avatarUrl` 降级为 `null`,其余字段照常返回。
#### 错误响应
`role` 传了 `GUIDE` / `PHOTOGRAPHER` 之外的值(如 `DRIVER`):
```json
{
"code": 582113,
"message": "人员配置位不合法,只支持导游位与摄影位",
"success": false,
"data": null
}
```
资源域人员查询失败:
```json
{
"code": 582103,
"message": "员工信息查询失败,请稍后重试",
"success": false,
"data": null
}
```
#### 业务边界
- 只读接口,**不产生任何写入**,可安全重复调用
- 同一人同时属于 `GUIDE` 与 `LEADER` 时按 `staffId` 去重,只返回一条
- `assigned` 反映的是本团期当前配置状态,与 `role` 入参无关:传 `role=GUIDE` 时,
已被配成摄影位的人不会出现在结果里,但导游位候选中若有人已被选中则 `assigned=true`
- 资源域仅返回启用状态人员,停用人员不在候选池
- 头像属展示增强,取不到时降级为 `null`,**不影响可选性**
---
### 2. 保存团期人员配置 `PUT /v3/admin/group-batch/:groupBatchId/staff`
**VO**: `BatchStaffConfigReqVO`
**本次只改请求体校验,路径、出参均不变。**
#### 使用场景
「配置导游 / 配置摄影」弹窗点击保存时调用,整批覆盖该团期的人员配置。
本次变更后,前端必须保证 `staffRole` 取值落在白名单内,否则整个请求被拒、无一条生效。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 团期批次 ID,同接口 1 |
| `staffList` | Body | Array | ✅ | — | 整批覆盖语义:传入列表即最终配置,未包含的人员被移除 |
| `staffList[].staffId` | Body | Long | ✅ | `@NotNull` | 人员 ID |
| `staffList[].staffRole` | Body | String | ✅ | **本次新增** `@Pattern`:`LEADER`\|`GUIDE`\|`DRIVER`\|`PHOTOGRAPHER`\|`OTHER` | **变更前**仅 `@NotBlank`,任意非空值原样入库;**变更后**越界返 400 |
| `staffList[].sortOrder` | Body | Integer | ❌ | — | 展示排序 |
| `staffList[].remark` | Body | String | ❌ | `@Size(max=500)` | 备注 |
#### 出参 `Result<BatchStaffConfigRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `staffList` | Array | 保存后的人员配置列表,字段同 `getConfig` |
| `affectedOrderCount` | Integer | 本次保存扇出影响的子订单数 |
#### 请求示例
```json
{
"staffList": [
{
"staffId": 1002,
"staffRole": "GUIDE",
"sortOrder": 0
}
]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"staffList": [
{
"id": "2095403473712418817",
"staffId": 1002,
"staffRole": "GUIDE",
"staffName": "李雪梅",
"staffPhone": "138****1002",
"avatarUrl": null,
"sortOrder": 0,
"remark": null
}
],
"affectedOrderCount": 0
}
}
```
#### 空数据 / 降级响应
传入空 `staffList` 数组表示清空该团期的人员配置,返回 200 且 `staffList` 为空数组,
不报错。这也是回退误配置的正规手段。
#### 错误响应
`staffRole` 越界(**本次新增行为**):
```json
{
"code": 400,
"message": "员工角色只能是 LEADER / GUIDE / DRIVER / PHOTOGRAPHER / OTHER",
"success": false,
"data": null
}
```
#### 业务边界
- **整批覆盖**语义,不是增量追加;未包含在 `staffList` 里的既有人员会被移除
- 校验在 `@Valid` 阶段完成,**任一条目越界则整个请求被拒,不会部分写入**
- `@Pattern` **区分大小写**,`guide` 不等于 `GUIDE`
- 空串会同时触发 `@Pattern` 与 `@NotBlank`,消息合并返回
- 存量 `order_batch_staff` 数据不迁移,新校验只在下次保存时触发
---
## 四、契约约束与正确调用方式
- **`role` 必传且只接受两个值**:`GUIDE`、`PHOTOGRAPHER`。不要传 `DRIVER`——司机由车务派车投影产生,
不在团期人员配置里手工指定,传了会返回 582113。
- **勾选态判断用 `assigned`,不要用 `assignedRole != null`**。存在「已选但角色为空」的历史数据,
用后者会漏掉这批人。
- **`groupBatchId` 是产品侧 `group_tour_batch.batch_id`**,不是订单侧运营团期主键。传错会返回空列表而非报错。
- **保存前先过白名单**:`staffRole` 只能是 `LEADER` / `GUIDE` / `DRIVER` / `PHOTOGRAPHER` / `OTHER`,
且区分大小写。整批中任一条越界会导致整个请求 400、无一条生效。
- **保存是整批覆盖**:每次提交需带上该团期的完整人员列表,只传增量会导致其余人员被清除。
- 错误码 `582113` 是业务码,HTTP 状态仍为 200,判断成败要读响应体的 `code` 字段。
---
## 五、数据库行为
仅描述外部可观察行为:
- 接口 1(GET candidates)**只读,不产生任何写入**。
- 接口 2(PUT staff)按整批覆盖语义重写该团期的人员配置:提交列表中的人员被保留或新增,
未包含的既有人员被移除;响应的 `affectedOrderCount` 表示随之扇出更新的子订单数量。
- 越界校验发生在写入之前,**校验失败时数据库无任何变更**。
- 本次变更**不涉及表结构调整**,也不对存量数据做迁移或回填。
---
## 六、边界行为
- `role` 非法 → **582113**,不是 400(业务码,HTTP 仍 200)
- 资源域无可用人员 → 返回 `[]`,不报错、不阻断弹窗
- 头像服务异常 → `avatarUrl` 为 `null`,列表照常返回(头像属展示增强)
- 资源域只返回 `status=1` 的人员,按 `sortOrder`、`staffId` 排序
- 同一人同时命中 `GUIDE` 与 `LEADER` 两类 → **按 `staffId` 去重,只出现一次**
- 已选人员 `staffRole` 为 `null`(历史脏数据)→ `assigned=true`、`assignedRole=null`,**接口不 500**
- 未登录 → 401(网关拦截)
---
## 六.5、枚举 / 数据字典
### `role`(Query 参数,配置位)
| 取值 | 含义 | 实际拉取的资源域 `staffType` |
|------|------|------------------------------|
| `GUIDE` | 导游位 | **`GUIDE` + `LEADER` 两类并收**,按 `staffId` 去重,可选多人 |
| `PHOTOGRAPHER` | 摄影位 | 只收 `PHOTOGRAPHER` |
| 其他(含 `DRIVER`) | — | 拒绝,返回 582113 |
### `staffType`(响应字段,资源域人员类型)
本接口可能返回 `GUIDE`(导游)、`LEADER`(领队)、`PHOTOGRAPHER`(摄影)三种。
资源域完整字典还包含 `GUIDE_ASSISTANT` / `LIFE_TEACHER` / `STUDY_TEACHER` / `OTHER`,但**不会出现在本接口响应里**。
### `staffRole`(保存接口入参,取值域对齐 `SettlementStaffRoleEnum`)
| 取值 | 含义 |
|------|------|
| `LEADER` | 领队 |
| `GUIDE` | 导游 |
| `DRIVER` | 司机(**由车务派车投影产生,一般不由本接口写入**) |
| `PHOTOGRAPHER` | 摄影 |
| `OTHER` | 其他 |
---
## 六.6、错误码
段位 `582100-582199`,owner `hl-order-service-v3`(`AssignmentErrorCode`)。
| 码 | 符号 | 消息 | 触发 |
|---|---|---|---|
| `582113` | `STAFF_CANDIDATE_ROLE_INVALID` | 人员配置位不合法,只支持导游位与摄影位 | **本次新增**。`role` 不是 `GUIDE` / `PHOTOGRAPHER` |
| `582103` | `STAFF_INFO_FETCH_FAILED` | 员工信息查询失败,请稍后重试 | 既有码。资源域 `list-available` 返回失败或空结果对象 |
| `400` | — | 员工角色只能是 LEADER / GUIDE / DRIVER / PHOTOGRAPHER / OTHER | **本次新增**。保存接口 `staffRole` 越界 |
---
## 七、不影响范围
- **仅影响**:管理后台团期详情的「配置导游 / 配置摄影」弹窗
- **零影响**:
- 资源域 `hl-resource-service`(**结构与接口零改动**,只是被新调用方使用)
- 团期人员保存后的子订单扇出逻辑(`order_staff_assignment`)
- 车务派车产生的司机行
- 已有的 `GET` / `PUT /v3/admin/group-batch/:groupBatchId/staff` 出参
- 存量 `order_batch_staff` 数据(不迁移,`staffRole` 新校验只在下次保存时触发)
---
## 八、测试环境已验证
✅ **已验证。** 2026-09-03 于测试环境网关实测,真实鉴权(管理端 admin 账号)。
- 网关:`https://api.test.1814.love:9443`
- 路由:`/v3/admin/**` 由网关直挂 hl-order-service-v3(**无 `/order-v3` 前缀**)
- 分支:`dev-v3`
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| 1 | `?role=GUIDE` | 200,并收 GUIDE+LEADER | ✅ 200,7 条,staffType 去重 = `[GUIDE, LEADER]`,staffId 唯一 |
| 2 | `?role=PHOTOGRAPHER` | 200,只含摄影 | ✅ 200,1 条,staffType 去重 = `[PHOTOGRAPHER]` |
| 3 | `?role=DRIVER` | 582113 | ✅ `582113 人员配置位不合法,只支持导游位与摄影位` |
| 4 | `?role=XXX` 非法值 | 582113 | ✅ 同上 |
| 5 | 缺 `role` 参数 | 400 | ✅ `400 缺少必要参数: role` |
| 6 | 无 Authorization | 401 | ✅ `401 缺少有效的 Authorization 头` |
**脱敏核对**:`138****1002` / `139****1011` / `139****1010`,前 3 后 4 生效。
**头像降级核对**:用例 1 中多条 `avatarUrl` 为 `null` 未阻断返回;用例 2 中摄影师返回真实 OSS 地址。
本地单元与架构测试(提交信息记载):
```
GroupBatchStaffConfigServiceTest 27 例全过 ✓
LayerEnforcement / MapperBoundary / RedLine /
ErrorCodeRegistry 45 例全过 ✓
```
### 保存接口 `staffRole` 白名单实测(本次行为变更)
同日于同一网关实测 `PUT /v3/admin/group-batch/:groupBatchId/staff`:
| # | payload `staffList[0].staffRole` | 期望 | 实测 |
|---|---|---|---|
| 7 | `"SUPERVISOR"` 越界值 | 400 | ✅ `400 员工角色只能是 LEADER / GUIDE / DRIVER / PHOTOGRAPHER / OTHER` |
| 8 | `"guide"` 小写 | 400 | ✅ 同上(正则区分大小写) |
| 9 | `""` 空串 | 400 | ✅ `400 …; 该字段不能为空`(@Pattern 与 @NotBlank 同时触发) |
| 10 | `"GUIDE"` 白名单内 | 放行 | ✅ 200,`affectedOrderCount: 0` |
用例 7–9 在 `@Valid` 阶段即被拒,**不产生任何写入**。用例 10 会落库,测试后已用空 `staffList` 数组 PUT 还原,
复核 `GET .../staff` 返回 `data: []`,与测试前一致。
**仍待补**:空候选池分支未构造(需一个无可用人员的团期)。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| **本 PR #6962** | **#6950** | 新增候选列表接口 + `staffRole` 白名单校验 | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#6950](https://git.1814.love:8443/wx/HL/issues/6950)
- 关联 PR: [wx/HL#6962](https://git.1814.love:8443/wx/HL/pulls/6962)
- 合入提交: `809462321`(feat),merge `35d0a8f2c` → `dev-v3`
- 需求与契约: `docs/group/团期模块接口文档-v2.0.html` GB-ADM-014
---
## 关联 / 联系人
- **Issue**: #6950
- **PR**: #6962(合并提交 `35d0a8f2c`,落入 `dev-v3`)
- **服务**: hl-order-service-v3
- **后端**: jw
- **前端**: 待认领(`frontend_status: pending`)
- **口径裁决**: 2026-09-02 jw —— 导游位并收 `GUIDE` 与 `LEADER`,摄影位只收 `PHOTOGRAPHER`,`DRIVER` 显式拒绝
@@ -0,0 +1,511 @@
---
schema: "hl-changelog/v2"
ticket: "6979"
title: "统一供应商暂停状态与拉黑归档流转"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-03"
status_note: "PR #7007 已合并 dev-v3 并部署 TEST;当前状态统一使用 SUSPENDED,新增恢复合作和解除黑名单接口,前端需按状态矩阵调整菜单。"
updated_at: "2026-09-03"
base: "dev-v3"
---
# 供应商模块:统一暂停状态与拉黑归档流转
> **服务**: `hl-resource-service`(8082)
> **Issue**: #6979
> **PR**: #7007
> **影响范围**: 管理后台供应商列表、详情与状态操作菜单
## ⚠️ 关键变化
- 当前供应商状态不再返回 `FROZEN`,暂停合作统一为 `SUSPENDED`。
- 新增“恢复合作”和“解除黑名单”接口;拉黑只允许从 `SUSPENDED` 发起。
- 归档只允许从 `SUSPENDED` 或 `BLACKLIST` 进入门禁;清账能力未交付时仍返回 `395032`,不会归档。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 分页查询供应商 | GET | `/admin/supplier/items/page` | 响应枚举修改 | 当前状态不再返回 `FROZEN` |
| 2 | 有界查询供应商 | GET | `/admin/supplier/items/list` | 响应枚举修改 | 当前状态不再返回 `FROZEN` |
| 3 | 查询供应商详情 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应枚举修改 | 暂停状态统一返回 `SUSPENDED` |
| 4 | 暂停合作 | POST | `/admin/supplier/items/{supplierId}/suspend` | 行为修改 | `ACTIVE → SUSPENDED` |
| 5 | 恢复合作 | POST | `/admin/supplier/items/{supplierId}/resume` | 新增接口 | `SUSPENDED → ACTIVE` |
| 6 | 拉入黑名单 | POST | `/admin/supplier/items/{supplierId}/blacklist` | 行为修改 | 仅允许 `SUSPENDED → BLACKLIST` |
| 7 | 解除黑名单 | POST | `/admin/supplier/items/{supplierId}/unblacklist` | 新增接口 | `BLACKLIST → SUSPENDED` |
| 8 | 清账归档 | POST | `/admin/supplier/items/{supplierId}/archive` | 行为修改 | 仅 `SUSPENDED/BLACKLIST` 可进入清账门禁 |
## 三、接口详情
### 1. 分页查询供应商 `GET /admin/supplier/items/page`
**VO**: `SupplierPageReqVO / PageResult<SupplierListItemRespVO>`
#### 使用场景
供应商列表页分页查询,并依据每行 `status` 显示状态菜单。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| page / pageSize | Query | Integer | 否 | 页码 ≥ 1;每页 ≤ 100 | 分页参数 |
| status | Query | String | 否 | 当前六状态之一 | 状态筛选 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| records[].status | String | 当前状态;不再返回 `FROZEN` |
| records[].updateTime | LocalDateTime | 状态动作的乐观锁版本 |
#### 请求示例
```http
GET /admin/supplier/items/page?page=1&pageSize=20&status=SUSPENDED
```
#### 响应示例
```json
{"code":200,"data":{"records":[{"supplierId":"2094672459314745346","status":"SUSPENDED","updateTime":"2026-09-03 08:03:00"}],"total":1,"page":1,"pageSize":20},"success":true}
```
#### 空数据 / 降级响应
无匹配数据返回 `records: []`;本接口无业务降级分支。
#### 错误响应
```json
{"code":401,"message":"缺少有效的 Authorization 头","data":null,"success":false}
```
#### 业务边界
- `status=FROZEN` 不再是合法筛选值;请改用 `SUSPENDED`。
### 2. 有界查询供应商 `GET /admin/supplier/items/list`
**VO**: `SupplierListReqVO / List<SupplierListItemRespVO>`
#### 使用场景
供应商选择器或短列表查询,并依据 `status` 显示状态菜单。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| status | Query | String | 否 | 当前六状态之一 | 状态筛选 |
| limit | Query | Integer | 否 | 1~200,默认 50 | 最大返回数 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| data[].status | String | 当前状态;暂停合作为 `SUSPENDED` |
| data[].updateTime | LocalDateTime | 状态动作的乐观锁版本 |
#### 请求示例
```http
GET /admin/supplier/items/list?status=BLACKLIST&limit=50
```
#### 响应示例
```json
{"code":200,"data":[{"supplierId":"2094672459314745346","status":"BLACKLIST","updateTime":"2026-09-03 08:04:00"}],"success":true}
```
#### 空数据 / 降级响应
无匹配数据返回 `data: []`;本接口无业务降级分支。
#### 错误响应
```json
{"code":400,"message":"供应商状态不合法","data":null,"success":false}
```
#### 业务边界
- 当前状态只接受 `DRAFT/VETTING/ACTIVE/SUSPENDED/BLACKLIST/ARCHIVED`。
### 3. 查询供应商详情 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
进入供应商详情或执行状态动作前,读取当前状态和最新并发版本。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数雪花 ID | 供应商 ID |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| status | String | 当前六状态之一 |
| updateTime | LocalDateTime | 后续状态动作原样回传 |
#### 请求示例
```http
GET /admin/supplier/items/2094672459314745346/basic-info/view
```
#### 响应示例
```json
{"code":200,"data":{"supplierId":"2094672459314745346","status":"ACTIVE","updateTime":"2026-09-03 08:05:00"},"success":true}
```
#### 空数据 / 降级响应
详情无空对象或降级结果;目标不存在时返回业务错误。
#### 错误响应
```json
{"code":395001,"message":"供应商不存在","data":null,"success":false}
```
#### 业务边界
- 暂停合作的当前主表状态统一返回 `SUSPENDED`,历史记录仍可能展示旧快照 `FROZEN`。
### 4. 暂停合作 `POST /admin/supplier/items/{supplierId}/suspend`
**VO**: `SupplierStatusChangeReqVO / SupplierStatusChangeRespVO`
#### 使用场景
在 `ACTIVE` 行点击“暂停合作”。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数雪花 ID | 供应商 ID |
| reason | Body | String | 是 | 1~500 字符 | 审计原因 |
| expectedUpdateTime | Body | LocalDateTime | 是 | `yyyy-MM-dd HH:mm:ss` | 最近读取的版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| status | String | 固定为 `SUSPENDED` |
| updateTime | LocalDateTime | 新并发版本 |
#### 请求示例
```json
{"reason":"暂停业务合作","expectedUpdateTime":"2026-09-03 08:02:00"}
```
#### 响应示例
```json
{"code":200,"data":{"supplierId":"2094672459314745346","status":"SUSPENDED","updateTime":"2026-09-03 08:03:00"},"success":true}
```
#### 空数据 / 降级响应
写接口无空成功结果,也不提供降级成功。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- 仅 `ACTIVE` 可调用;成功后继续通知车务停用关联车队。
### 5. 恢复合作 `POST /admin/supplier/items/{supplierId}/resume`
**VO**: `SupplierStatusChangeReqVO / SupplierStatusChangeRespVO`
#### 使用场景
在 `SUSPENDED` 行点击“恢复合作”。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数雪花 ID | 供应商 ID |
| reason | Body | String | 是 | 1~500 字符 | 审计原因 |
| expectedUpdateTime | Body | LocalDateTime | 是 | `yyyy-MM-dd HH:mm:ss` | 最近读取的版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| status | String | 固定为 `ACTIVE` |
| updateTime | LocalDateTime | 新并发版本 |
#### 请求示例
```json
{"reason":"恢复业务合作","expectedUpdateTime":"2026-09-03 08:03:00"}
```
#### 响应示例
```json
{"code":200,"data":{"supplierId":"2094672459314745346","status":"ACTIVE","updateTime":"2026-09-03 08:04:00"},"success":true}
```
#### 空数据 / 降级响应
写接口无空成功结果,也不提供降级成功。
#### 错误响应
```json
{"code":395014,"message":"数据已被他人修改,请刷新后重试","data":null,"success":false}
```
#### 业务边界
- 仅 `SUSPENDED` 可调用;恢复供应商不会自动启用车队。
### 6. 拉入黑名单 `POST /admin/supplier/items/{supplierId}/blacklist`
**VO**: `SupplierStatusChangeReqVO / SupplierStatusChangeRespVO`
#### 使用场景
在 `SUSPENDED` 行点击“拉入黑名单”。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数雪花 ID | 供应商 ID |
| reason | Body | String | 是 | 1~500 字符 | 审计原因 |
| expectedUpdateTime | Body | LocalDateTime | 是 | `yyyy-MM-dd HH:mm:ss` | 最近读取的版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| status | String | 固定为 `BLACKLIST` |
| updateTime | LocalDateTime | 新并发版本 |
#### 请求示例
```json
{"reason":"列入合作黑名单","expectedUpdateTime":"2026-09-03 08:03:00"}
```
#### 响应示例
```json
{"code":200,"data":{"supplierId":"2094672459314745346","status":"BLACKLIST","updateTime":"2026-09-03 08:04:00"},"success":true}
```
#### 空数据 / 降级响应
写接口无空成功结果,也不提供降级成功。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- 仅 `SUSPENDED` 可调用;`ACTIVE` 直接拉黑返回 `395005`,成功后通知车务停用关联车队。
### 7. 解除黑名单 `POST /admin/supplier/items/{supplierId}/unblacklist`
**VO**: `SupplierStatusChangeReqVO / SupplierStatusChangeRespVO`
#### 使用场景
在 `BLACKLIST` 行点击“解除黑名单”。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数雪花 ID | 供应商 ID |
| reason | Body | String | 是 | 1~500 字符 | 审计原因 |
| expectedUpdateTime | Body | LocalDateTime | 是 | `yyyy-MM-dd HH:mm:ss` | 最近读取的版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| status | String | 固定为 `SUSPENDED` |
| updateTime | LocalDateTime | 新并发版本 |
#### 请求示例
```json
{"reason":"解除合作黑名单","expectedUpdateTime":"2026-09-03 08:04:00"}
```
#### 响应示例
```json
{"code":200,"data":{"supplierId":"2094672459314745346","status":"SUSPENDED","updateTime":"2026-09-03 08:05:00"},"success":true}
```
#### 空数据 / 降级响应
写接口无空成功结果,也不提供降级成功。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- 仅 `BLACKLIST` 可调用;成功后回到 `SUSPENDED`,不会直接恢复交易或自动启用车队。
### 8. 清账归档 `POST /admin/supplier/items/{supplierId}/archive`
**VO**: `SupplierArchiveRespVO`
#### 使用场景
在 `SUSPENDED` 或 `BLACKLIST` 行点击“清账归档”。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数雪花 ID | 供应商 ID;无请求体 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| data | SupplierArchiveRespVO | 当前清账能力未交付,不会返回成功数据 |
#### 请求示例
```http
POST /admin/supplier/items/2094672459314745346/archive
```
#### 响应示例
```json
{"code":395032,"message":"暂无法确认财务已清账,不能归档","data":null,"success":false}
```
#### 空数据 / 降级响应
当前固定失败关闭,不提供空数据或降级成功。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- `ACTIVE` 先返回 `395005`;`SUSPENDED/BLACKLIST` 进入清账门禁后返回 `395032`,两者均零写入。
## 四、契约约束与正确调用方式
| 当前状态 | 仅显示以下操作 | 正确接口 |
|---|---|---|
| `ACTIVE` | 暂停合作 | `/suspend` |
| `SUSPENDED` | 恢复合作、拉入黑名单、清账归档 | `/resume`、`/blacklist`、`/archive` |
| `BLACKLIST` | 解除黑名单、清账归档 | `/unblacklist`、`/archive` |
- `expectedUpdateTime` 必须使用最近一次详情或列表返回值;成功后使用响应的新版本刷新页面。
- 统一响应可能由 HTTP 200 承载业务失败,必须同时判断 `code` 和 `success`。
## 五、数据库行为
- 当前主表中的旧 `FROZEN` 已转换为 `SUSPENDED`;历史审批与变更快照不改写。
- 四个状态接口成功时更新当前状态和版本,并各写一条既有变更审计;失败路径不写主体、审批或审计。
## 六、边界行为
- 未登录返回业务码 `401`。
- 生命周期写操作要求 `SUPER_ADMIN` 和 `supplier:status:manage`;不满足返回 `395004`。
- `395005` 表示来源状态错误;`395014` 表示版本过期;`395032` 表示清账能力不可用;`100502` 表示 5 秒内重复提交。
- 暂停、拉黑继续通知车务停用关联车队;恢复、解除黑名单不会自动启用车队。
## 六.5、枚举 / 数据字典
### `status`(供应商当前生命周期)
| 值 | 中文 | 菜单语义 |
|---|---|---|
| `ACTIVE` | 合作中 | 仅暂停合作 |
| `SUSPENDED` | 暂停合作 | 恢复、拉黑、清账归档 |
| `BLACKLIST` | 黑名单 | 解除黑名单、清账归档 |
`DRAFT/VETTING/ARCHIVED` 保持既有语义;当前接口不再产生或返回主表状态 `FROZEN`。
## 六.6、修改前后对比
| 场景 | 修改前 | 修改后 |
|---|---|---|
| 暂停合作 | 当前状态进入 `FROZEN` | 当前状态进入 `SUSPENDED` |
| 恢复合作 | 无独立接口 | 新增 `/resume` |
| `ACTIVE` 直接拉黑 | 可进入黑名单 | 返回 `395005`,零写入 |
| 解除黑名单 | 无独立接口 | 新增 `/unblacklist`,返回 `SUSPENDED` |
| 归档来源 | `ACTIVE/BLACKLIST` | `SUSPENDED/BLACKLIST` |
## 六.7、影响评估
- **是否破坏向后兼容**: 是。依赖当前态 `FROZEN` 或 `ACTIVE` 直接拉黑的调用方式必须调整。
- **前端是否必须同步上线**: 是。
- **前端 workaround 清理点**: 删除当前态 `FROZEN` 的筛选、文案与按钮分支。
## 七、不影响范围
- **仅影响**: 管理后台供应商当前状态展示与生命周期操作。
- **零影响**: 草稿建档、注册审批、收款账户、资源绑定、小程序与历史审批/变更快照展示。
## 八、测试环境已验证
- `ACTIVE → SUSPENDED → BLACKLIST → SUSPENDED → ACTIVE` 全链路通过,列表与详情状态一致。
- `ACTIVE` 直接拉黑/归档返回 `395005`;两种可归档来源均返回 `395032` 且零写入。
- 乐观锁、重复提交、未认证门禁通过;四次成功动作写四条变更审计,审批记录未增加。
- 部署提交:[`8d38f69d2d6ebd99fb25fb6b0f5dc50ce4cfa1c0`](https://git.1814.love:8443/wx/HL/commit/8d38f69d2d6ebd99fb25fb6b0f5dc50ce4cfa1c0)。
## 十、相关文档
- [Issue #6979](https://git.1814.love:8443/wx/HL/issues/6979)
- [PR #7007](https://git.1814.love:8443/wx/HL/pulls/7007)
## 前端动作与当前状态
- 移除当前态 `FROZEN`,统一映射 `SUSPENDED` 为“暂停合作”。
- 按状态矩阵接入两个新增接口并收紧按钮显隐;`395032` 不得更新页面为已归档。
- **当前状态:待前端接入。**
## 关联 / 联系人
- **Issue**: [#6979](https://git.1814.love:8443/wx/HL/issues/6979)
- **PR**: [#7007](https://git.1814.love:8443/wx/HL/pulls/7007)
- **后端负责人**: @lc
@@ -0,0 +1,374 @@
---
schema: "hl-changelog/v2"
ticket: "6986"
title: "团期物资支持从备品库选择配置"
consumer: "admin"
author: "jw(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-03"
status_note: "PR #6987 已合入 dev-v3;2026-09-03 经测试环境网关实测,10 条正负向用例全部通过。前端尚未接入"
updated_at: "2026-09-03"
base: "dev-v3"
---
# 团期物资: 新增备品库候选列表接口,并放宽 suppliesName 必填约束
> **服务**: hl-order-service-v3 + hl-resource-service(**跨两个服务**)
> **PR**: #6987
> **Issue**: #6986
> **影响范围**: 管理后台「团期详情 → 物资清单 → 从备品库选择」
---
## ⚠️ 关键变化
**新增物资行 `POST /v3/admin/order/group-batch/:groupBatchId/supplies` 的 `suppliesName` 不再无条件必填。**
- 前端以前以为:`suppliesName` 是 `@NotBlank`,任何情况下都得填。
- 实际现在是:改为 `@AssertTrue` **二选一**——传了 `suppliesResourceId` 就可以不填名称
(名称、分类、计费方式从备品库取,**入参传了也会被库值覆盖**);不传 `suppliesResourceId` 时名称仍必填。
- 这解决的是原先「从备品库选一条加进来,却仍被迫手填名字」与「名称以库为准」自相矛盾的问题。
**部署要求**:本次跨 order-v3 与 resource-service 两个服务,**必须同时部署**。
只发 order-v3 会调不到资源域新增的 `/internal/supplies/list-available`,候选列表直接报 589518。
---
## 一、背景
物资清单原先只能手工逐条录入,名称、分类、计费方式、单价全靠人填,既慢又容易与备品库口径不一致。
本次接入资源域备品库:团期管理员从库里勾选,基础信息由库带出。
**资源域改动**:新增 `GET /internal/supplies/list-available`(内部接口,不对外)。
order-v3 侧通过 Feign 调用,并带降级工厂。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期物资候选列表 | GET | `/v3/admin/order/group-batch/:groupBatchId/supplies/candidates` | **新增接口** | 从备品库拉可选备品,标记本团期已加入项 |
| 2 | 新增团期物资行 | POST | `/v3/admin/order/group-batch/:groupBatchId/supplies` | **请求体校验放宽** | `suppliesName` 由必填改为与 `suppliesResourceId` 二选一 |
---
## 三、接口详情
### 1. 团期物资候选列表 `GET /v3/admin/order/group-batch/:groupBatchId/supplies/candidates`
**VO**: `SuppliesCandidateRespVO`
#### 使用场景
「物资清单 → 从备品库选择」弹窗打开时调用,渲染可选备品列表。
`added=true` 的项应显示为已勾选,其 `batchSuppliesId` 即清单中对应行的 ID,可直接用于删除。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 团期主订单 ID |
| `categoryCode` | Query | String | ❌ | 字典 `supplies_category` | 分类筛选。传不存在的分类返回空数组,不报错 |
| `keyword` | Query | String | ❌ | — | 关键字,匹配备品**名称或副标题**。中文需 URL 编码 |
#### 出参 `Result<List<SuppliesCandidateRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `suppliesResourceId` | Long | 备品资源 ID。**加入清单时回传本值** |
| `suppliesName` | String | 备品名称(来自库) |
| `subtitle` | String | 副标题 |
| `category` | String | 分类编码 |
| `billingType` | String | 计费方式,**已归一为产品域词表** `PER_PERSON` / `PER_QUANTITY` |
| `hasCost` | Boolean | 是否计费(资源域 `isCharged=1` 时为 true) |
| `unitPrice` | BigDecimal | 基础价,加入清单时作为 `unitPrice` 默认值 |
| `unit` | String | 计量单位 |
| `added` | Boolean | 是否已加入本团期清单,**true 时前端应显示为已勾选** |
| `batchSuppliesId` | Long | 已加入时对应的清单行 ID;未加入为 `null` |
#### 请求示例
```http
GET /v3/admin/order/group-batch/1/supplies/candidates?categoryCode=personal_gear
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{
"suppliesResourceId": "2023438566624866305",
"suppliesName": "定制遮阳帽",
"subtitle": "呼籁旅行专属防晒鸭舌帽",
"category": "personal_gear",
"billingType": "PER_PERSON",
"hasCost": true,
"unitPrice": 15.00,
"unit": "顶",
"added": false,
"batchSuppliesId": null
}
]
}
```
#### 空数据 / 降级响应
备品库无匹配项(含分类不存在、关键字无命中)时返回空数组,不报错:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": []
}
```
资源域不可达时**不静默降级为空**,而是显式报 589518,避免前端误判为「库里没有备品」。
#### 错误响应
备品库查询失败(资源域未部署或不可达):
```json
{
"code": 589518,
"message": "备品库查询失败,请稍后重试",
"success": false,
"data": null
}
```
#### 业务边界
- 只读接口,**不产生任何写入**,可安全重复调用
- 资源域只返回可用备品,已下架的不在候选池
- `billingType` 在 order-v3 侧做过归一,前端**不必**再兼容资源域原始词表
- `added` 依据 `suppliesResourceId` 匹配;纯手填(无 resourceId)的清单行不会影响任何候选项的勾选态
- 同一备品在清单中重复存在时,`batchSuppliesId` 取首条
---
### 2. 新增团期物资行 `POST /v3/admin/order/group-batch/:groupBatchId/supplies`
**VO**: `AddSuppliesReqVO`
#### 使用场景
两种来源共用本接口:从备品库勾选加入(传 `suppliesResourceId`),或手工录入临时物资(传 `suppliesName`)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 团期主订单 ID |
| `suppliesResourceId` | Body | Long | ❌ | 与 `suppliesName` **二选一** | 备品资源 ID。传了则名称/分类/计费方式从库取 |
| `suppliesName` | Body | String | ❌ | 与 `suppliesResourceId` **二选一** | **变更前** `@NotBlank` 无条件必填;**变更后**传了 resourceId 时可空,且**传了也会被库值覆盖** |
| `quantity` | Body | Integer | ✅ | `@NotNull` `@Min(1)` | 数量 |
| `category` | Body | String | ❌ | — | 分类,传 resourceId 时以库为准 |
| `hasCost` | Body | Boolean | ❌ | — | 是否计费 |
| `billingType` | Body | String | ❌ | — | 计费方式 |
| `unitPrice` | Body | BigDecimal | ❌ | — | 单价,缺省取库基础价 |
| `sortOrder` | Body | Integer | ❌ | — | 排序 |
| `remark` | Body | String | ❌ | — | 备注 |
#### 出参 `Result<Long>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data` | Long | 新建清单行 ID(`batchSuppliesId`),可直接用于删除或调整数量 |
#### 请求示例
```json
{
"suppliesResourceId": 2023438566624866305,
"quantity": 2
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": "2095411540751519745"
}
```
#### 空数据 / 降级响应
本接口必然返回行 ID 或错误,无空数据形态。校验失败时 `data` 为 `null` 且 `success=false`。
#### 错误响应
名称与备品库 ID 都没传(**本次新增的二选一校验**):
```json
{
"code": 400,
"message": "备品名称不能为空(未指定备品库 ID 时必填)",
"success": false,
"data": null
}
```
所选备品不存在或已下架:
```json
{
"code": 589519,
"message": "所选备品不存在或已下架,请重新选择",
"success": false,
"data": null
}
```
#### 业务边界
- 二选一校验在 `@AssertTrue` 阶段完成,**失败时数据库无任何变更**
- 传了 `suppliesResourceId` 时,入参里的 `suppliesName` / `category` / `billingType` **会被库值覆盖**,不是「以入参优先」
- `quantity` 最小为 1,传 0 或负数返回 400
- 同一备品可重复加入,接口不做去重
- 纯手填行(无 `suppliesResourceId`)不会出现在候选列表的 `added` 判定中
---
## 四、契约约束与正确调用方式
- **加入备品库物资时,只需传 `suppliesResourceId` 和 `quantity`**。不要再从候选列表把名称回填进请求体——
传了也会被库值覆盖,徒增不一致风险。
- **手工录入临时物资时,`suppliesName` 仍然必填**。二选一不等于两个都可以不传。
- **勾选态用 `added` 判断,删除用 `batchSuppliesId`**。候选列表已经把行 ID 回填好,无需再查一次清单。
- **`keyword` 含中文必须 URL 编码**,否则查询条件丢失(实测未编码时请求异常)。
- **589518 与「空数组」含义不同**:前者是资源域挂了,后者是库里确实没有匹配项。前端不应把 589518 展示成「暂无备品」。
- 589518 / 589519 是业务码,HTTP 状态仍为 200,判断成败要读响应体的 `code`。
---
## 五、数据库行为
仅描述外部可观察行为:
- 接口 1(GET candidates)**只读,不产生任何写入**。
- 接口 2(POST supplies)新增一条团期物资清单行并返回其 ID;传 `suppliesResourceId` 时,
名称、分类、计费方式、单价默认值取自备品库快照,落库后不再随备品库变动。
- 二选一校验与数量校验均发生在写入之前,**校验失败时数据库无任何变更**(已实测复核)。
- 本次变更**不涉及表结构调整**,也不对存量物资行做迁移或回填。
---
## 六、边界行为
- 资源域未部署 / 不可达 → 589518,**不降级为空数组**
- 备品库无匹配 → 返回 `[]`,不报错
- `categoryCode` 传不存在的分类 → 返回 `[]`
- 备品库 ID 不存在或已下架 → 589519
- 既无 `suppliesName` 又无 `suppliesResourceId` → 400
- `quantity` 为 0、负数或缺失 → 400
- 未登录 → 401(网关拦截)
---
## 六.5、枚举 / 数据字典
### `billingType`(已归一为产品域词表)
| 取值 | 含义 |
|------|------|
| `PER_PERSON` | 按人计费 |
| `PER_QUANTITY` | 按数量计费 |
> order-v3 侧已对资源域原始词表做过归一,前端直接消费这两个值即可。
### `category`(字典 `supplies_category`)
分类编码由字典维护,如 `personal_gear`(个人装备)、`camping_equipment`(露营装备)等。
本接口不校验分类是否存在,传未知值返回空数组。
---
## 六.6、错误码
段位 `589500-589599`,owner `hl-order-service-v3`(`GroupBatchErrorCode`)。
| 码 | 符号 | 消息 | 触发 |
|---|---|---|---|
| `589518` | `SUPPLIES_LIBRARY_FETCH_FAILED` | 备品库查询失败,请稍后重试 | **本次新增**。资源域 `list-available` 返回失败或空结果对象 |
| `589519` | `SUPPLIES_RESOURCE_NOT_AVAILABLE` | 所选备品不存在或已下架,请重新选择 | **本次新增**。`suppliesResourceId` 在库中查不到 |
| `400` | — | 备品名称不能为空(未指定备品库 ID 时必填) | **本次新增**。二选一校验未通过 |
---
## 七、不影响范围
- **仅影响**:管理后台团期详情的「物资清单」模块
- **零影响**:
- 已有的物资清单查询、调整数量、删除行、确认物料四个接口的出参
- 存量团期物资行数据(不迁移,新校验只在下次新增时触发)
- 资源域备品库自身的管理接口(本次只新增一个内部只读端点)
- 团期状态机与物料确认闸门
---
## 八、测试环境已验证
✅ **已验证。** 2026-09-03 于测试环境网关实测,真实鉴权(管理端 admin 账号)。
- 网关:`https://api.test.1814.love:9443`,分支 `dev-v3`
- 前置确认:order-v3 与 resource-service **两侧均已部署**(候选列表能返回资源域真实数据即为证)
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| 1 | GET candidates 无筛选 | 200,返回候选 | ✅ 200,15 条 |
| 2 | `?categoryCode=personal_gear` | 200,按分类收窄 | ✅ 200,5 条 |
| 3 | `?keyword=帽`(URL 编码) | 200,按名称匹配 | ✅ 200,1 条「定制遮阳帽」 |
| 4 | `?categoryCode` 传不存在分类 | 200 空数组 | ✅ 200,0 条 |
| 5 | GET candidates 无 Authorization | 401 | ✅ `401 缺少有效的 Authorization 头` |
| 6 | POST 既无名称也无 resourceId | 400 | ✅ `400 备品名称不能为空(未指定备品库 ID 时必填)` |
| 7 | POST `quantity=0` | 400 | ✅ `400 数量至少为 1` |
| 8 | POST 缺 `quantity` | 400 | ✅ `400 数量不能为空` |
| 9 | POST `suppliesResourceId` 不存在 | 589519 | ✅ `589519 所选备品不存在或已下架,请重新选择` |
| 10 | POST 只传 resourceId 不传名称 | 放行,名称取库值 | ✅ 200,落库 `suppliesName="定制遮阳帽"` 与库一致,`unitPrice=15.00`、`billingType=PER_PERSON` |
**行为变更核对**:用例 10 是本次核心——入参未传 `suppliesName` 仍成功建行,名称由备品库带出。
**联动核对**:加入后重查候选列表,该项 `added=true`、`batchSuppliesId` 回填为新建行 ID;删除后回到 `added=false`。
**未落库核对**:用例 6–9 执行后复查清单仍为 0 条,证明校验失败不产生写入。
**测试数据还原**:用例 10 建的行已 `DELETE` 删除,清单由测试前 0 条恢复为 0 条。
本地单元测试(提交信息记载):`GroupBatchSuppliesServiceTest` 新增 208 行用例。
---
## 十、相关文档
- 团期需求文档:`docs/group/`(dev-v3 分支)
- 实施单 AC-TD-13(`ad747212d` 已标记 #6986 落地并勘误)
---
## 关联 / 联系人
- **Issue**: #6986
- **PR**: #6987(合并提交 `bcf9332bd`,落入 `dev-v3`)
- **后续修正**: `8806315e3`(备品去重,同时关联 #6950)
- **服务**: hl-order-service-v3 + hl-resource-service
- **后端**: jw
- **前端**: 待认领(`frontend_status: pending`)
@@ -0,0 +1,175 @@
---
schema: "hl-changelog/v2"
ticket: "7013"
title: "供应商清账归档启用"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-03"
status_note: "归档接口已可成功归档;前端需移除旧 395032 禁用提示并接入归档请求。"
updated_at: "2026-09-03"
base: "dev-v3"
---
# 供应商模块:清账归档启用
> **服务**: `hl-resource-service`(8082)
> **Issue**: #7013
> **PR**: #7017
> **影响范围**: 管理后台供应商列表与详情的“归档”操作
## ⚠️ 关键变化
此前“归档固定返回 `395032`、前端保持禁用”的约定已失效。符合条件的供应商现在可调用归档接口,成功后状态变为 `ARCHIVED`。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 归档供应商 | POST | `/admin/supplier/items/{supplierId}/archive` | 行为修改 | `SUSPENDED/BLACKLIST → ARCHIVED` |
## 三、接口详情
### 1. 归档供应商 `POST /admin/supplier/items/{supplierId}/archive`
**VO**: `SupplierArchiveRespVO`
#### 使用场景
在供应商列表或详情中归档当前状态为 `SUSPENDED` 或 `BLACKLIST` 的供应商。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数雪花 ID | 无请求体 |
#### 出参 `Result<SupplierArchiveRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| supplierId | String | 已归档供应商 ID |
| status | String | 固定为 `ARCHIVED` |
| clearanceRequestId | String / null | 当前固定为 `null` |
| checkedAt | String / null | 当前固定为 `null` |
| ledgerRevision | String / null | 当前固定为 `null` |
| updateTime | String | 归档后的并发版本,格式 `yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```http
POST /admin/supplier/items/2094672459314745346/archive
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"supplierId": "2094672459314745346",
"status": "ARCHIVED",
"clearanceRequestId": null,
"checkedAt": null,
"ledgerRevision": null,
"updateTime": "2026-09-03 10:20:00"
},
"success": true
}
```
#### 空数据 / 降级响应
写接口无空成功结果,也不提供降级成功。
#### 错误响应
```json
{
"code": 395005,
"message": "当前状态不允许执行该操作",
"data": null,
"success": false
}
```
#### 业务边界
| code | 场景 | 前端处理 |
|---|---|---|
| `401` | 未登录 | 进入既有登录失效流程 |
| `395004` | 无归档权限 | 不展示或禁止操作 |
| `395005` | 当前状态不是 `SUSPENDED/BLACKLIST`,或已归档后重复请求 | 刷新列表或详情状态 |
| `100502` | 短时间重复提交 | 保持当前页面并提示勿重复操作 |
- `395032` 仅为历史兼容错误码,当前归档入口不再主动返回。
## 四、契约约束与正确调用方式
- 当前状态为 `SUSPENDED` 或 `BLACKLIST` 且用户具备归档权限时,显示可点击的“归档”。
- 点击后调用 `POST /admin/supplier/items/{supplierId}/archive`,不传请求体。
- 必须同时判断 `success` 与 `code`;成功后使用响应中的 `ARCHIVED` 和 `updateTime` 刷新列表或详情。
## 五、数据库行为
归档成功会写入审核记录与状态变更审计,并在同一事务中把供应商主表状态更新为 `ARCHIVED`。
## 六、边界行为
- 仅 `SUSPENDED` 和 `BLACKLIST` 可归档;其他状态返回 `395005`。
- 归档成功后供应商只允许查看,前端不得继续显示写操作。
- 接口路径、请求方式和响应字段均未改变。
## 六.5、枚举 / 数据字典
| 值 | 中文 | 菜单语义 |
|---|---|---|
| `SUSPENDED` | 暂停合作 | 可归档 |
| `BLACKLIST` | 黑名单 | 可归档 |
| `ARCHIVED` | 已归档 | 仅查看,不再显示归档操作 |
## 六.6、修改前后对比
| 场景 | 修改前 | 修改后 |
|---|---|---|
| 合法来源状态归档 | 固定返回 `395032`,前端禁用 | 返回成功结果,状态更新为 `ARCHIVED` |
## 六.7、影响评估
- **是否破坏向后兼容**: 否;接口路径和响应字段未变,仅启用成功行为。
- **前端是否必须同步上线**: 是。
- **前端 workaround 清理点**: 删除“归档(暂无法确认财务已清账)”禁用项及 `395032` 固定失败判断。
## 七、不影响范围
- **仅影响**: 管理后台供应商归档操作。
- **零影响**: 其他供应商状态操作、收款账户、合同、资源绑定及小程序接口。
## 八、测试环境已验证
TEST Gateway 已验证合法来源状态归档返回 `ARCHIVED`,审核记录与主表状态同步更新。
## 十、相关文档
- [Issue #7013](https://git.1814.love:8443/wx/HL/issues/7013)
- [PR #7017](https://git.1814.love:8443/wx/HL/pulls/7017)
- 本条覆盖 [#6979 旧归档约定](https://git.1814.love:8443/wx/hl-api-changelog/src/branch/main/changelogs-v2/2026-09/03_6979_%E7%BB%9F%E4%B8%80%E4%BE%9B%E5%BA%94%E5%95%86%E6%9A%82%E5%81%9C%E7%8A%B6%E6%80%81%E4%B8%8E%E6%8B%89%E9%BB%91%E5%BD%92%E6%A1%A3%E6%B5%81%E8%BD%AC-%E4%BF%AE%E6%94%B9%E6%8E%A5%E5%8F%A3-%E7%AE%A1%E7%90%86%E5%90%8E%E5%8F%B0.md) 中“固定返回 `395032`”的说明。
## 前端动作与当前状态
- 移除归档菜单的固定禁用状态和旧清账提示。
- 对 `SUSPENDED/BLACKLIST` 接入归档请求;成功后刷新为 `ARCHIVED`。
- **当前状态:待前端处理。**
## 关联 / 联系人
- **Issue**: [#7013](https://git.1814.love:8443/wx/HL/issues/7013)
- **PR**: [#7017](https://git.1814.love:8443/wx/HL/pulls/7017)
- **后端负责人**: @lc
@@ -0,0 +1,223 @@
---
schema: "hl-changelog/v2"
ticket: "7029"
title: "供应商新增账户接入企微审批"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-03"
status_note: "后端已改为企微异步审批;前端需切换账户新增路径、展示待审批状态并移除本地人工审批入口。"
updated_at: "2026-09-03"
base: "dev-v3"
---
# 供应商模块:新增账户接入企微审批
> **服务**: `hl-resource-service`(8082)、`hl-user-service`(8081)
> **Issue**: #7029
> **PR**: #7031
> **影响范围**: 管理后台供应商列表的账户管理
## ⚠️ 关键变化
已审核通过的供应商新增账户后不再立即生效,也不再由管理端调用人工审批接口;后端会在企微“账户”模板发起审批,账户先返回 `PENDING`,企微通过并同步后才变为 `ACTIVE`。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 批量新增并提交收款账户 | POST | `/admin/supplier/items/{supplierId}/bank-accounts/add` | 行为修改 | 每个账户独立发起企微审批并返回审批状态 |
## 三、接口详情
### 1. 批量新增并提交收款账户 `POST /admin/supplier/items/{supplierId}/bank-accounts/add`
**VO**: `SupplierBankAccountBatchCreateReqVO` → `List<BankAccountSubmitResultRespVO>`
#### 使用场景
供应商状态为 `ACTIVE` 后,在账户管理中新增一至五十个收款账户。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数雪花 ID | 已生效供应商 |
| accounts | Body | Array | 是 | 1~50 项 | 收款账户列表 |
| accounts[].accountType | Body | String | 是 | `CORPORATE` / `PERSONAL` | 账户类型 |
| accounts[].bankName | Body | String | 是 | 最长 500 | 开户行 |
| accounts[].bankBranch | Body | String | 否 | 最长 500 | 开户支行 |
| accounts[].accountNo | Body | String | 是 | 8~128 位,仅数字、空格、`-` | 账号 |
| accounts[].proofFileUrls | Body | Array | 否 | 最多 20 项 | 证明附件 |
| accounts[].settleMode | Body | String | 否 | `PREPAY` / `MONTHLY` / `SINGLE` | 结算方式 |
| accounts[].accountPeriod | Body | String | 条件必填 | `MONTHLY` 时必填,其他方式必须为空 | 账期 |
| accounts[].invoiceType | Body | String | 否 | `SPECIAL` / `NORMAL` / `NONE` | 发票类型 |
| accounts[].taxRate | Body | String | 条件必填 | 可开票时必填,`NONE` 时必须为空 | `0%`~`100%`,最多两位小数 |
#### 出参 `Result<List<BankAccountSubmitResultRespVO>>`
| 字段 | 类型 | 说明 |
|---|---|---|
| accountId | String | 新账户 ID |
| approvalLogId | String | 账户审批记录 ID |
| requestNo | String | 同一请求重放使用的稳定请求号 |
| provider | String | 新申请固定为 `WECOM` |
| approvalStatus | String | 新申请为 `PENDING`,通过后为 `APPROVED` |
| spNo | String | 企微审批单号 |
| spStatus | String / null | 企微原始状态,完成同步前可为空 |
| syncStatus | String | 正常建单为 `REQUESTING`,完成应用为 `APPLIED`;异常可能为 `APPLY_FAILED` / `RESULT_UNCERTAIN` |
| accountStatus | String | 通过前 `PENDING`,通过后 `ACTIVE` |
| isDefault | String | 新增账户固定为 `NO` |
| submittedAt | String / null | 提交时间 |
| finishedAt | String / null | 完成时间,审批中为空 |
#### 请求示例
```json
{
"accounts": [
{
"accountType": "PERSONAL",
"bankName": "示例银行",
"bankBranch": "示例支行",
"accountNo": "6222 0000 1234 5678",
"proofFileUrls": [],
"settleMode": "SINGLE",
"accountPeriod": null,
"invoiceType": "NONE",
"taxRate": null
}
]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": [
{
"accountId": "353807302408683520",
"approvalLogId": "353807302408683521",
"requestNo": "SUP-ACC-REQ-example",
"provider": "WECOM",
"approvalStatus": "PENDING",
"spNo": "202609030009",
"spStatus": null,
"syncStatus": "REQUESTING",
"accountStatus": "PENDING",
"isDefault": "NO",
"submittedAt": "2026-09-03 15:45:00",
"finishedAt": null
}
],
"success": true
}
```
#### 空数据 / 降级响应
写接口不返回空成功列表。企微结果不确定时仍保留账户和审批事实为 `PENDING`,对应项返回 `syncStatus=RESULT_UNCERTAIN`,前端不得自动重提。
#### 错误响应
```json
{
"code": 395010,
"message": "请先完成供应商注册",
"data": null,
"success": false
}
```
#### 业务边界
| code | 场景 | 前端处理 |
|---|---|---|
| `400` | 账户字段、枚举或组合规则不合法 | 保留表单并展示后端提示 |
| `395001` | 供应商不存在 | 关闭弹窗并刷新列表 |
| `395002` | 无账户管理或提交权限 | 禁止操作 |
| `395010` | 供应商不是 `ACTIVE` | 先完成供应商注册审批 |
| `395011` | 同一账号已有在途审批 | 不重复提交,刷新账户列表 |
| `395027` | 账号已被占用 | 联系财务核实 |
- 每个账户生成独立审批;顶层 `success=true` 后仍须检查每项 `syncStatus` 和 `accountStatus`。
- 企微模板固定展示供应商、账户类型、开户行和账号,审批人由企微模板配置决定,前端不传审批人。
## 四、契约约束与正确调用方式
1. 新增账户只调用 `POST /admin/supplier/items/{supplierId}/bank-accounts/add`,请求体必须使用 `accounts` 数组。
2. 成功建单后展示“待审批”,不得把 `PENDING` 当作可用账户,也不得调用旧的 `/supplier/{supplierId}/accounts/{accountId}/approve`。
3. 通过 `GET /admin/supplier/items/{supplierId}/account-info/list` 刷新状态;只有 `status=ACTIVE` 才展示为生效中。
4. 必须同时判断顶层 `success/code` 与每项 `syncStatus/accountStatus`;`RESULT_UNCERTAIN` 只提示对账,不自动重试。
## 五、数据库行为
提交成功后先持久化 `PENDING`、非默认账户及对应审批记录;企微审批通过后异步更新为 `ACTIVE`,并追加审批关联的账户启用审计。外部提交失败或结果不确定时不会把账户标记为生效。
## 六、边界行为
- 供应商创建时携带的初始账户仍随供应商注册审批,通过前为 `PENDING`,通过后为 `ACTIVE`。
- 后续新增账户与供应商创建时是否携带初始账户无关,只要求供应商当前为 `ACTIVE`。
- 企微驳回或撤销不会激活账户;终态由后端回调或轮询同步。
- 接口使用统一 `Result`;业务错误可能仍为 HTTP 200,必须读取响应体 `code/success`。
## 六.5、枚举 / 数据字典
### accountStatus / 账户列表 status
| 值 | 中文 | 说明 |
|---|---|---|
| `PENDING` | 待审批 | 不可作为生效账户使用 |
| `ACTIVE` | 生效中 | 企微审批通过且后端已完成应用 |
| `DISABLED` | 已停用 | 本工单未修改停用流程 |
## 六.6、修改前后对比
| 行为 | 修改前 | 修改后 |
|---|---|---|
| 新增账户 | 本地自动通过并立即 `ACTIVE` | 企微建单后先 `PENDING`,通过后才 `ACTIVE` |
| 审批动作 | 管理端存在本地人工审批调用 | 仅在企微处理,管理端只刷新状态 |
| 新增路径 | 旧页面调用 `/supplier/{supplierId}/accounts` | 调用 `/admin/supplier/items/{supplierId}/bank-accounts/add` |
## 六.7、影响评估
- **是否破坏向后兼容**: 是;新增账户由同步生效改为异步审批。
- **前端是否必须同步上线**: 是。
- **前端 workaround 清理点**: 删除账户本地审批按钮及 `/supplier/{supplierId}/accounts/{accountId}/approve` 调用。
## 七、不影响范围
- **仅影响**: 管理后台供应商账户新增及状态展示。
- **零影响**: 账户详情读取、默认账户切换、停用流程、合同、资源绑定和小程序接口。
## 八、测试环境已验证
TEST Gateway 已验证:初始账户随注册审批由 `PENDING` 转为 `ACTIVE`;后续账户使用指定企微模板建单并在通过后由 `PENDING` 转为 `ACTIVE`,审批记录和启用审计均可查询;非法账户类型返回业务失败且零写入。
## 十、相关文档
- [Issue #7029](https://git.1814.love:8443/wx/HL/issues/7029)
- [PR #7031](https://git.1814.love:8443/wx/HL/pulls/7031)
## 前端动作与当前状态
- 将账户新增改为批量接口及 `accounts` 请求结构。
- 新增后展示 `PENDING`,轮询或刷新账户列表,直到后端返回 `ACTIVE`。
- 移除管理端账户审批按钮和旧人工审批请求。
- **当前状态:待前端处理。**
## 关联 / 联系人
- **Issue**: [#7029](https://git.1814.love:8443/wx/HL/issues/7029)
- **PR**: [#7031](https://git.1814.love:8443/wx/HL/pulls/7031)
- **Merge commit**: [f5dfa7771a61948bb4d59e35fbedb67d61fcb3f3](https://git.1814.love:8443/wx/HL/commit/f5dfa7771a61948bb4d59e35fbedb67d61fcb3f3)
- **后端负责人**: @lc
@@ -0,0 +1,368 @@
---
schema: "hl-changelog/v2"
ticket: "7036"
title: "供应商主体状态变更接入企微审批"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-03"
status_note: "后端已部署并完成 TEST 验收;四类状态动作改为企微异步审批,前端需适配审批中与终态刷新。"
updated_at: "2026-09-03"
base: "dev-v3"
---
# 供应商模块:主体状态变更接入企微审批
> **服务**: `hl-resource-service`、`hl-user-service`
> **Issue**: #7036
> **PR**: #7043、#7046
> **影响范围**: 管理后台供应商状态操作及审批进度展示
## ⚠️ 关键变化
列入黑名单、解除黑名单和两种清账归档不再同步改变状态:接口先返回企微审批单,审批通过后才异步迁移;解除黑名单需两个不同审批人依次通过,其余三项均为一级审批。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 列入黑名单 | POST | `/admin/supplier/items/{supplierId}/blacklist` | 行为及响应修改 | 先发起一级企微审批 |
| 2 | 解除黑名单 | POST | `/admin/supplier/items/{supplierId}/unblacklist` | 行为及响应修改 | 两个不同审批人串行通过后生效 |
| 3 | 清账归档 | POST | `/admin/supplier/items/{supplierId}/archive` | 行为及响应修改 | 按来源状态发起一级企微审批 |
## 三、接口详情
### 1. 列入黑名单 `POST /admin/supplier/items/{supplierId}/blacklist`
**VO**: `SupplierStatusChangeReqVO` → `SupplierStatusChangeRespVO`
#### 使用场景
对 `SUSPENDED` 供应商发起“列入黑名单”一级审批。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数 | 供应商 ID |
| reason | Body | String | 是 | 1~500 字符 | 变更原因 |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 页面读取到的并发版本 |
#### 出参 `Result<SupplierStatusChangeRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| supplierId | String | 供应商 ID |
| status | String | 审批中仍为 `SUSPENDED`,通过后为 `BLACKLIST` |
| updateTime | String | 当前并发版本 |
| approval.approvalLogId | String | 审批记录 ID |
| approval.provider | String | 固定为 `WECOM` |
| approval.approvalStatus | String | `PENDING` / `APPROVED` / `REJECTED` / `CANCELED` |
| approval.spNo | String | 企微审批单号 |
| approval.syncStatus | String | 本地同步状态 |
| approval.submittedAt | String | 提交时间 |
| approval.finishedAt | String / null | 审批完成时间 |
#### 请求示例
```json
{
"reason": "严重违约",
"expectedUpdateTime": "2026-09-03 10:18:27"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"supplierId": "2094672459314745346",
"status": "SUSPENDED",
"updateTime": "2026-09-03 10:18:27",
"approval": {
"approvalLogId": "2095450331436515330",
"provider": "WECOM",
"approvalStatus": "PENDING",
"spNo": "202609030012",
"syncStatus": "REQUESTING",
"submittedAt": "2026-09-03 18:05:57",
"finishedAt": null
}
},
"success": true
}
```
#### 空数据 / 降级响应
写接口不返回空成功数据;企微提交失败返回业务失败,供应商保持 `SUSPENDED`。
#### 错误响应
```json
{
"code": 395005,
"message": "当前状态不允许执行该操作",
"data": null,
"success": false
}
```
#### 业务边界
- 需要供应商状态管理权限;`expectedUpdateTime` 不一致返回 `395014`。
- 顶层成功仅表示企微建单成功,前端不得立即展示为黑名单。
- 驳回或撤销不改变供应商状态。
### 2. 解除黑名单 `POST /admin/supplier/items/{supplierId}/unblacklist`
**VO**: `SupplierStatusChangeReqVO` → `SupplierStatusChangeRespVO`
#### 使用场景
对 `BLACKLIST` 供应商发起“解除黑名单”两级串行审批。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数 | 供应商 ID |
| reason | Body | String | 是 | 1~500 字符 | 解除原因 |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 页面读取到的并发版本 |
#### 出参 `Result<SupplierStatusChangeRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| supplierId | String | 供应商 ID |
| status | String | 两级审批完成前仍为 `BLACKLIST`,通过后为 `SUSPENDED` |
| updateTime | String | 当前并发版本 |
| approval.approvalLogId | String | 审批记录 ID |
| approval.provider | String | 固定为 `WECOM` |
| approval.approvalStatus | String | 审批状态 |
| approval.spNo | String | 企微审批单号 |
| approval.syncStatus | String | 本地同步状态 |
| approval.submittedAt | String | 提交时间 |
| approval.finishedAt | String / null | 审批完成时间 |
#### 请求示例
```json
{
"reason": "整改完成,申请解除",
"expectedUpdateTime": "2026-09-02 15:49:06"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"supplierId": "2091381911266967553",
"status": "BLACKLIST",
"updateTime": "2026-09-02 15:49:06",
"approval": {
"approvalLogId": "2095450475109527553",
"provider": "WECOM",
"approvalStatus": "PENDING",
"spNo": "202609030013",
"syncStatus": "REQUESTING",
"submittedAt": "2026-09-03 18:06:31",
"finishedAt": null
}
},
"success": true
}
```
#### 空数据 / 降级响应
写接口不返回空成功数据;企微提交失败返回业务失败,供应商保持 `BLACKLIST`。
#### 错误响应
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 必须由企微模板配置的两个不同审批人按顺序通过,只完成一级时状态不变。
- 通过后的目标为 `SUSPENDED`,不会直接恢复到 `ACTIVE`。
- 驳回、撤销或审批链不完整均不改变供应商状态。
### 3. 清账归档 `POST /admin/supplier/items/{supplierId}/archive`
**VO**: `Void` → `SupplierArchiveRespVO`
#### 使用场景
`SUSPENDED` 选择“终止且账清”,或 `BLACKLIST` 选择“拉黑且账清”,发起一级审批并在通过后归档。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数 | 供应商 ID |
#### 出参 `Result<SupplierArchiveRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| supplierId | String | 供应商 ID |
| status | String | 审批中保持来源状态,通过后为 `ARCHIVED` |
| clearanceRequestId | null | 本期不提供权威清账凭证 |
| checkedAt | null | 本期不提供权威清账核验时间 |
| ledgerRevision | null | 本期不提供账本版本 |
| updateTime | String | 当前并发版本 |
| approval.approvalLogId | String | 审批记录 ID |
| approval.provider | String | 固定为 `WECOM` |
| approval.approvalStatus | String | 审批状态 |
| approval.spNo | String | 企微审批单号 |
| approval.syncStatus | String | 本地同步状态 |
#### 请求示例
```http
POST /admin/supplier/items/2095047059194138625/archive
Authorization: Bearer <管理端登录凭证>
无请求体
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"supplierId": "2095047059194138625",
"status": "SUSPENDED",
"clearanceRequestId": null,
"checkedAt": null,
"ledgerRevision": null,
"updateTime": "2026-09-03 09:48:55",
"approval": {
"approvalLogId": "2095450500279549953",
"provider": "WECOM",
"approvalStatus": "PENDING",
"spNo": "202609030014",
"syncStatus": "REQUESTING"
}
},
"success": true
}
```
#### 空数据 / 降级响应
写接口不返回空成功数据;企微提交失败返回业务失败,供应商保持来源状态。
#### 错误响应
```json
{
"code": 395021,
"message": "企业微信申请提交失败,请稍后重试",
"data": null,
"success": false
}
```
#### 业务边界
- `SUSPENDED` 和 `BLACKLIST` 分别映射为两个固定变更事项,但均只需一级审批。
- 接口不接收清账证据;三个预留清账字段继续返回 `null`。
- 驳回或撤销不归档,`ARCHIVED` 仍为只读终态。
## 四、契约约束与正确调用方式
1. 提交动作后以 `approval.approvalStatus` 展示审批中,不以 HTTP 200 推断状态已经变化。
2. 轮询 `GET /admin/supplier/items/{supplierId}/approval-history/page` 展示审批层级及终态,同时刷新 `GET /admin/supplier/items/{supplierId}/basic-info/view` 获取最终主体状态。
3. `GET /admin/supplier/items/{supplierId}/change-records/page` 只展示审批通过后真正发生的状态变化;审批中、驳回和撤销没有对应变更记录。
4. 企微表单的供应商、变更事项、当前状态、目标状态、申请原因、当前余额、最近拉黑记录、详情链接和申请人均由后端填写,前端不传审批人或模板控件值。
## 五、数据库行为
提交成功后可立即查询到独立审批记录,但主体状态和变更记录不变;企微有效审批链全部通过后,状态迁移与新增关联变更记录同时完成。驳回或撤销只结束审批记录,不产生状态变更记录。
## 六、边界行为
- 未登录或无供应商状态管理权限时拒绝写入。
- 同一供应商已有在途状态审批时复用该审批,不重复建单。
- 企微提交失败返回 `395021`;结果不确定返回 `395022`,前端不得自动重复提交。
- 暂停合作和恢复合作仍沿用原直接状态迁移,不进入本审批模板。
## 六.5、枚举 / 数据字典
| 动作 | 来源状态 | 企微变更事项 | 审批层级 | 通过后状态 |
|---|---|---|---:|---|
| 列入黑名单 | `SUSPENDED` | 列入黑名单 | 1 | `BLACKLIST` |
| 解除黑名单 | `BLACKLIST` | 解除黑名单 | 2 | `SUSPENDED` |
| 清账归档 | `SUSPENDED` | 终止且账清 | 1 | `ARCHIVED` |
| 清账归档 | `BLACKLIST` | 拉黑且账清 | 1 | `ARCHIVED` |
## 六.6、修改前后对比
| 行为 | 修改前 | 修改后 |
|---|---|---|
| 列入/解除黑名单 | 请求内直接迁移 | 企微审批通过后迁移 |
| 清账归档 | 旧审批提供方语义 | 指定企微模板一级审批 |
| 解除黑名单 | 无两级企微门禁 | 两个不同审批人串行通过 |
| 接口响应 | 仅返回状态 | 追加 `approval` 审批受理信息 |
## 六.7、影响评估
- **是否破坏向后兼容**: 是;状态动作从同步完成改为异步审批。
- **前端是否必须同步上线**: 是。
- **前端 workaround 清理点**: 移除请求成功即展示目标状态的逻辑,改为展示审批中并刷新审批历史与主体状态。
## 七、不影响范围
- **仅影响**: 管理后台供应商主体的列入黑名单、解除黑名单和清账归档。
- **零影响**: 暂停合作、恢复合作、供应商注册审批、后续新增账户审批、合同及资源绑定。
## 八、测试环境已验证
- 四类动作均通过指定企微模板建单;审批完成前保持来源状态,审批记录与状态变更记录分表保存。
- “列入黑名单”“终止且账清”“拉黑且账清”一级通过后分别迁移为 `BLACKLIST`、`ARCHIVED`、`ARCHIVED`。
- “解除黑名单”一级通过后仍为 `BLACKLIST`,由第二位不同审批人通过后迁移为 `SUSPENDED`。
- 空申请原因返回业务 `400` 且未新增审批记录。
## 十、相关文档
- [Issue #7036](https://git.1814.love:8443/wx/HL/issues/7036)
- [PR #7043](https://git.1814.love:8443/wx/HL/pulls/7043)
- [补充 PR #7046](https://git.1814.love:8443/wx/HL/pulls/7046)
## 前端动作与当前状态
- 三个写接口成功后展示“审批中”,并使用返回的 `approval` 信息追踪企微状态。
- 解除黑名单展示两级进度;一级通过时仍保持黑名单态。
- 仅以主体详情最终状态和变更记录确认动作生效。
- **当前状态:待前端处理。**
## 关联 / 联系人
- **Issue**: [#7036](https://git.1814.love:8443/wx/HL/issues/7036)
- **PR**: [#7043](https://git.1814.love:8443/wx/HL/pulls/7043)
- **Merge commit**: [7c99e96a8f7c0977492a620483d86a7340c400ee](https://git.1814.love:8443/wx/HL/commit/7c99e96a8f7c0977492a620483d86a7340c400ee)
- **后端负责人**: @lc
@@ -0,0 +1,34 @@
---
schema: "hl-changelog/v2"
ticket: "7042"
title: "设置供应商列表按资源类型过滤"
consumer: "admin"
author: "lc(GIT)"
change_type: "前端缺陷"
backend_status: "not_required"
gateway_status: "not_required"
frontend_status: "not_required"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "原前端传入 typeCode 并维护类型映射的要求已作废;改由后端工单 #7059 处理。当前状态:无需前端处理。"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 设置供应商列表按资源类型过滤(更正)
> **更正(2026-09-04)**:原记录要求前端传入 `typeCode` 并维护资源分类与供应商类型映射,该要求已作废。当前状态:**无需前端处理**。
## 更正结论
- 前端无需新增或修改代码,也无需维护资源分类与供应商类型映射。
- 原“资源类型口径”和“前端处理要求”全部撤销,不再作为调用约束。
- 设置供应商候选列表的类型解析与过滤改由后端工单 #7059 处理。
## 关联
- Issue:[#7042](https://git.1814.love:8443/wx/HL/issues/7042)
- 后端更正工单:[#7059](https://git.1814.love:8443/wx/HL/issues/7059)
- 后端联系人:@lc
@@ -0,0 +1,173 @@
---
schema: "hl-changelog/v2"
ticket: "6926"
title: "导出补 opsStage 筛选 + 统计条自校验死代码修正(修改接口)"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-04"
status_note: "2026-09-04 测试服部署 dev-v3@0c497649b;导出三态与 summary 非法 month/opsStage 忽略经网关 200 全通过"
updated_at: "2026-09-04"
base: "dev-v3"
generated: "2026-09-04T10:34:46+08:00"
---
# 团期看板:导出补 opsStage 筛选(GB-ADM-008)+ 统计条自校验死代码修正(GB-ADM-009)
> **存放目录**: 二期(order-v3)→ `changelogs-v2/2026-09/`
>
> **服务**: hl-order-service-v3
> **PR**: #7061(squash 合并 dev-v3@0c497649b)
> **Issue**: [#6926](https://git.1814.love:8443/wx/HL/issues/6926)
> **日期**: 2026-09-04
> **影响范围**: 管理后台团期看板——导出端点新增可选 opsStage 筛选;统计条自校验死代码修正(使未知状态行排除可观测)
---
## ⚠️ 关键变化
**修改接口,向后兼容、无破坏**:① GB-ADM-008 导出新增可选查询参数 `opsStage`(七桶筛选,`FORMED` 展开为 `RESOURCE_PREPARING+MATERIAL_PREPARING` 两态 in 过滤,与 productId/month/keyword 叠加取交集;非法值忽略不报错);② GB-ADM-009 统计条自校验条件 `bucketSum != counted`(永假死代码)改为 `rows.size() != counted`(未知 `batch_status` 行排除后真实触发 ERROR 日志);③ 两端点 month 非法值(如 `2026-13`)跳过月份筛选不 500(与 GB-ADM-001 `parseMonthRange` 同口径);④ `buildCsv` 补 capacity_delta 拟议注释。
---
## 一、背景(选填)
PR #6917(#6904 统计条+导出)复审确认两处要修:008 契约卡查询参数含 `opsStage`(同 GB-ADM-001),实现未接收——前端在桶筛选态点导出时参数被 Spring 静默忽略,导出未按桶过滤的全量数据且无报错;009 的 total 自校验 `bucketSum != counted` 永假(counted 只在桶 merge 成功的同一分支自增,与桶和定义相等),契约要求的不一致记 ERROR 永不触发,未知状态行在 summary 路径被静默丢弃(export 路径已有 ERROR)。另顺手对齐两小口径(month 容错、capacity_delta 注释)。
---
## 变更接口
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | GB-ADM-008 团期导出 | GET | `/v3/admin/order/group-batch/export` | 修改接口 | 新增可选 `opsStage` 筛选参数 + month 容错 |
| 2 | GB-ADM-009 团期统计条 | GET | `/v3/admin/order/group-batch/summary` | 修改接口(行为修正) | 自校验死代码修正(参数/响应不变);month 容错 |
---
## 接口详情
### 1. GB-ADM-008 团期导出 `GET /v3/admin/order/group-batch/export`(修改接口)
**请求参数**(★=本期新增,其余不变)
| 参数 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| Authorization / X-Admin-Id | Header | String/Long | ✓ | 网关注入,客户端传值忽略 |
| productId | Query | Long | | 产品 ID,缺省不限(既有) |
| month | Query | String | | 出团月份 yyyy-MM(既有;本期改容错:非法值跳过筛选不 500,与 001 同口径) |
| keyword | Query | String | | 团期编号/名称模糊关键词(既有) |
| ★ opsStage | Query | String | | 七桶筛选(RECRUIT/FORMED/PENDING_TRIP/TRAVELLING/AUDITING/CHECKED/DISBANDED),FORMED 展开为两态 in 过滤;与 productId/month/keyword 叠加取交集;非法值忽略不报错 |
**响应**: 不变(`text/csv` 文件流,固定 10 列,UTF-8 BOM + CRLF,单次上限 2000 行)。
**错误码**: 不变(589507 无 `group-batch:export`、589517 超 2000 行)。
**内部实现**:opsStage → `GroupBatchStageBuckets.statusCodesOf()`(唯一解析口,001 的 `resolveOpsStatuses` 委托同源)→ `selectBoardRows(..., opsStatuses, ...)` 的 `inIfPresent(GroupBatchDO::getBatchStatus, opsStatuses)`。
### 2. GB-ADM-009 团期统计条 `GET /v3/admin/order/group-batch/summary`(行为修正)
**请求参数**: 不变(productId/month/keyword;**不接受** opsStage——契约原文要求忽略该参数)。
**响应**: 不变(`GroupBatchSummaryVO`:total + 7 桶 + subOrderCount)。
**行为修正**: 未知 batch_status 行排除后,`rows.size() != counted` 时记 ERROR 日志(原先 `bucketSum != counted` 永假不触发),total 仍以桶和为准。
---
## 四、契约约束与正确调用方式(接口类必写)
### ✅ 正确 / ❌ 错误调用对照
| 场景 | 说明 |
|------|------|
| ✅ export?opsStage=FORMED | 两态 in 过滤(RESOURCE_PREPARING+MATERIAL_PREPARING),与 productId/month/keyword 取交集 |
| ✅ export?opsStage=非法值(如 NOT_BUCKET) | 忽略该筛选,不报错,等价不传 |
| ✅ export?month=2026-13 | 跳过月份筛选不 500(2026-13 非法) |
| ✅ summary 带 opsStage | 忽略(009 契约不接受),响应与不带一致 |
| ❌ 导出命中 > 2000 行 | 589517 |
| ❌ 无 group-batch:export 授权 | 589507 |
---
## 五、数据库行为(涉及写操作时必写)
无表变更、无列变更。唯一写操作:导出成功后逐团期写 `group_batch_status_log` BATCH_EXPORT 留痕(REQUIRES_NEW 独立写事务,导出失败不写留痕),与 #6904 一致。
---
## 六、边界行为
- 非法 opsStage(含未知桶)→ warn 日志「未知的 opsStage 筛选值,忽略该筛选」,返回 null 跳过筛选(与 001 口径一致)。
- 非法 month → warn「非法的 month 参数,忽略月份筛选」,跳过月份条件(summary 与 export 均不 500)。
- summary 遇未知 batch_status 行 → 不进入任何桶,total=桶和,ERROR「团期看板统计条口径不一致: rows=N bucketSum=M」真正触发。
- 空结果:export 仅表头(BOM/CRLF 保持),summary 七桶全 0。
- 导出「状态」列对未知状态原样透出(不报错)。
---
## 七、不影响范围(显式声明)
- 未改 CSV 列结构与列序(仍固定 10 列)。
- 未建表/未改表/未加列。
- 009 响应结构不变(7 桶 + total + subOrderCount)。
- 未触碰 #6905(GB-ADM-000~003 看板列表/详情)任何接口与文件。
- 网关路由不动(/v3/admin/** 通配已覆盖),无配置中心变更。
---
## 验证证据
**JUnit 定向测试**(dev-v3@0c497649b 合并后,分支内):
```
mvn -pl hl-order-service-v3 -am test -Dtest=GroupBatchBoardStatsServiceTest,GroupBatchStageBucketsTest,GroupBatchBoardStatsControllerTest,GroupBatchMapperEscapeLikeTest -DfailIfNoTests=false
Tests run: 34, Failures: 0, Errors: 0(含 opsStage 展开/非法忽略/month 容错/ERROR 日志触发 4 新断言)
```
**全量**: `mvn -pl hl-order-service-v3 -am test` 7897 用例,仅基线 6 失败(3 refund 401-envelope + 3 adjustment 日期敏感,与 dev-v3 基线一致,非本单引入)。
**网关实测**(部署 dev-v3@0c497649b 后,经 api.test.1814.love:9443):
```
GET /v3/admin/order/group-batch/export?month=2026-06 → 200 text/csv 附件(group-batch-2026-06.csv) ✓
GET /v3/admin/order/group-batch/export?month=2026-06&opsStage=FORMED → 200 text/csv ✓
GET /v3/admin/order/group-batch/export?month=2026-06&opsStage=NOT_BUCKET → 200 text/csv(非法忽略)✓
GET /v3/admin/order/group-batch/summary?month=2026-13 → 200 code=200 total=14(容错跳过,非 500)✓
GET /v3/admin/order/group-batch/summary?opsStage=FORMED → 200 code=200(忽略,与不带一致 total=14)✓
```
---
## 九、相关历史 PR
- PR #6917(#6904 统计条+导出)——本单为其实施复审返工。
- PR #7061——本单(squash 合并 dev-v3@0c497649b)。
---
## 十、相关文档
- 契约卡 GB-ADM-008 / GB-ADM-009(#6902 地基,#6904 契约)。
- `changelogs-v2/2026-09/01_6904_...`(新增接口原始文档)。
- `changelogs-v2/2026-09/01_6905_...`(看板列表 4 接口,与本单互不覆盖)。
---
## 关联 / 联系人
### 链接
- **Issue**: [#6926](https://git.1814.love:8443/wx/HL/issues/6926)
- **PR**: [#7061](https://git.1814.love:8443/wx/HL/pulls/7061)
- **Merge commit**: [0c497649b](https://git.1814.love:8443/wx/HL/commit/0c497649b)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,306 @@
---
schema: "hl-changelog/v2"
ticket: "7059"
title: "设置供应商候选按资源上下文过滤"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-04"
status_note: "后端已部署并验证;待前端删除本地映射,仅透传资源上下文。"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 资源管理:设置供应商候选按资源上下文过滤
> **服务**: hl-resource-service (8082)
> **PR**: #7064
> **Issue**: #7059
> **日期**: 2026-09-04
> **影响范围**: 管理后台资源管理、车务管理的“设置供应商”候选与绑定
---
## ⚠️ 关键变化
前端不再维护“资源分类 → 供应商类型”映射;候选查询传 `resourceModule`、`resourceId`,后端解析类型并只返回 `ACTIVE` 且匹配的供应商。
## 一、背景
#7042 要求前端传 `typeCode` 的结论已撤销。本次由后端统一解析资源上下文,避免景区混入车队供应商,并修正组合备品误用 `SUPPLIES` 的问题。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 有界查询供应商 | GET | `/admin/supplier/items/list` | 新增可选查询参数 | 完整资源上下文下由后端过滤候选 |
| 2 | 设置或改绑资源供应商 | PUT | `/admin/supplier/resource-relations/{resourceModule}/{resourceId}/update` | 调整类型解析 | 固定映射模块可省略 `requiredTypeCode` |
## 三、接口详情
### 1. 有界查询供应商 `GET /admin/supplier/items/list`
**VO**: `SupplierListReqVO / SupplierListItemRespVO`
#### 使用场景
资源管理或车务管理打开“设置供应商”候选列表时调用;搜索时沿用 `keyword`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `resourceModule` | Query | String | 场景必填 | 与 `resourceId` 同传 | 当前资源模块,不是供应商类型 |
| `resourceId` | Query | String | 场景必填 | 正整数,与 `resourceModule` 同传 | 当前资源 ID |
| `keyword` | Query | String | 否 | 最长 500 | 搜索关键字 |
| `limit` | Query | Integer | 否 | 1~200,默认 50 | 候选列表建议传 200 |
| `typeCode` | Query | String | 否 | 通用列表兼容参数 | 设置供应商场景不要传;资源上下文存在时后端忽略调用方值 |
| `status` | Query | String | 否 | 通用列表兼容参数 | 设置供应商场景不要传;资源上下文存在时后端固定 `ACTIVE` |
#### 出参 `Result<List<SupplierListItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data[].supplierId` | String | 供应商 ID |
| `data[].supplierNo` | String | 供应商编号 |
| `data[].fullName` | String | 供应商全称 |
| `data[].shortName` | String | 供应商简称 |
| `data[].types` | Array | 类型列表;元素含 `typeCode`、`typeName`、`isPrimary` |
| `data[].status` | String | 资源上下文场景恒为 `ACTIVE` |
| 其他既有字段 | - | 响应结构未变化 |
#### 请求示例
```http
GET /admin/supplier/items/list?resourceModule=SCENIC&resourceId=3001000000000000019&limit=200
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": [
{
"supplierId": "2091381643661983746",
"supplierNo": "SUP2091381643661983746",
"fullName": "示例景区供应商",
"types": [{ "typeCode": "SCENIC", "typeName": "景区", "isPrimary": true }],
"status": "ACTIVE"
}
],
"success": true
}
```
#### 空数据 / 降级响应
没有符合条件的供应商时返回 `data: []`;资源不存在、上下文不完整或映射不可用时失败关闭,不降级为全量列表。
```json
{ "code": 200, "message": "成功", "data": [], "success": true }
```
#### 错误响应
```json
{
"code": 400,
"message": "resourceModule与resourceId必须同时提供或同时省略",
"data": null,
"success": false
}
```
#### 业务边界
- 完整资源上下文下,后端校验供应商列表权限、资源关系查看权限、资源存在性和数据范围。
- 后端只返回状态为 `ACTIVE` 且包含映射类型的供应商;调用方传入的 `typeCode`、`status` 不会覆盖该规则。
- 不传资源上下文时,原有 `typeCode`、`status`、`keyword`、`limit` 通用查询行为保持不变。
### 2. 设置或改绑资源供应商 `PUT /admin/supplier/resource-relations/{resourceModule}/{resourceId}/update`
**VO**: `SupplierResourceReassignReqVO / SupplierResourceRelationRespVO`
#### 使用场景
用户确认候选供应商后首次设置或改绑当前资源的供应商。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `resourceModule` | Path | String | 是 | 见映射表 | 当前资源模块 |
| `resourceId` | Path | String | 是 | 正整数 | 当前资源 ID |
| `supplierId` | Body | String | 是 | 正整数 | 候选供应商 ID |
| `requiredTypeCode` | Body | String | 否 | 最长 64 | 七个固定映射模块应省略,由后端解析 |
| `remark` | Body | String | 否 | 最长 500 | 关系备注 |
| `changeReason` | Body | String | 是 | 非空,最长 500 | 设置或改绑原因 |
| `expectedCurrentSupplierId` | Body | String | 改绑时必填 | 与版本时间同传 | 当前关系供应商 ID |
| `expectedRelationUpdateTime` | Body | String | 改绑时必填 | 与当前供应商 ID 同传 | 当前关系并发版本 |
#### 出参 `Result<SupplierResourceRelationRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.supplierId` | String | 生效供应商 ID |
| `data.resourceModule` | String | 资源模块 |
| `data.resourceId` | String | 资源 ID |
| `data.requiredTypeCode` | String | 后端解析并冻结的供应商类型 |
| `data.updateTime` | String | 后续改绑或解绑使用的并发版本 |
| 其他既有字段 | - | 响应结构未变化 |
#### 请求示例
```json
{
"supplierId": "2091381643661983746",
"changeReason": "设置资源供应商"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"supplierId": "2091381643661983746",
"resourceModule": "SUPPLIES_COMBO",
"resourceId": "2046122595429806082",
"requiredTypeCode": "SUPPLIES_COMBO",
"updateTime": "2026-09-04 12:00:00"
},
"success": true
}
```
#### 空数据 / 降级响应
写接口成功时返回完整关系;校验失败时返回业务错误和 `data: null`,不会创建或改写关系。
#### 错误响应
```json
{
"code": 395037,
"message": "供应商类型不满足资源关联要求",
"data": null,
"success": false
}
```
#### 业务边界
- 七个固定映射模块由后端确定 `requiredTypeCode`;前端不要提交本地映射值。
- 供应商必须处于 `ACTIVE` 且包含所需类型;失败时关系保持不变。
- 已有关系改绑仍必须传成对的并发版本字段,既有审计、权限和数据范围规则不变。
## 四、契约约束与正确调用方式
| 场景 | 正确调用 | 错误调用 |
|------|----------|----------|
| 打开候选列表 | `resourceModule=SCENIC&resourceId={id}&limit=200` | 前端把 `SCENIC` 映射为 `typeCode` 后只按类型查询 |
| 搜索候选 | 上述参数继续加 `keyword` | 搜索时丢失资源上下文 |
| 设置/改绑 | Path 传资源上下文,Body 省略 `requiredTypeCode` | 前端根据字典或资源分类拼 `requiredTypeCode` |
前端应删除资源分类与供应商类型的本地映射。`supplier_type` 在“系统管理 → 字典管理”维护供应商类型的名称、状态和排序;它不是资源模块映射配置。资源模块映射由后端统一维护。
## 五、数据库行为
设置或改绑时,关系中冻结后端解析出的 `requiredTypeCode`;本次无表结构、存量数据迁移或跨库写入变化,原有关系审计行为保持不变。
## 六、边界行为
- `resourceModule` 与 `resourceId` 只传一个 → `400`,`resourceModule与resourceId必须同时提供或同时省略`。
- 未知模块 → `395034`,`不支持的资源模块`;资源不存在或已删除 → `395035`。
- `ACTIVITY`、`COST_ITEM`、`STAFF` 本工单没有默认映射,按资源上下文查询会以 `400 requiredTypeCode不能为空` 失败关闭。
- 映射类型在 `supplier_type` 字典缺失或停用 → `400`,`供应商类型不合法或已停用`。
- 未登录 → 应用响应 `401`;无权限或超出资源数据范围 → 拒绝访问。
## 六.5、枚举 / 数据字典
### resourceModule 与 supplier_type
**所属字段**: `SupplierListReqVO.resourceModule / SupplierResourceRelationRespVO.requiredTypeCode` | **类型**: `String`
| `resourceModule` | 后端要求的 `supplier_type` | 页面 |
|------------------|-------------------------------|------|
| `SCENIC` | `SCENIC` | 景区管理 |
| `RESTAURANT` | `RESTAURANT` | 餐厅管理 |
| `SUPPLIES` | `SUPPLIES` | 备品管理 |
| `SUPPLIES_COMBO` | `SUPPLIES_COMBO` | 组合配品 |
| `HOTEL` | `HOTEL` | 酒店管理 |
| `SERVICE` | `SERVICE` | 服务管理 |
| `VEHICLE` | `FLEET` | 车务管理-车队管理 |
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| 候选查询资源上下文 | 无 | 新增可选 `resourceModule`、`resourceId`,必须成对传入 |
| `requiredTypeCode` | 组合备品可能由调用方误传 `SUPPLIES` | 固定映射模块可省略,组合备品由后端解析为 `SUPPLIES_COMBO` |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 设置供应商候选 | 依赖前端映射 `typeCode`,可能展示不对应类型 | 后端按真实资源上下文过滤 `ACTIVE` 且匹配的供应商 |
| 无资源上下文的通用列表 | 按调用方筛选 | 保持不变 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否;无资源上下文的原调用保持兼容。
- **前端是否必须同步上线**: 是。
- **前端 workaround 清理点**: 删除资源分类与供应商类型本地映射;候选查询改传 `resourceModule`、`resourceId`,绑定请求不再拼 `requiredTypeCode`。
## 七、不影响范围
- **仅影响**: 管理后台资源管理、车务管理的供应商候选和设置/改绑类型解析。
- **零影响**: 供应商分页管理、供应商注册审批、既有响应字段、数据库结构、Redis、MQ 和其他前端源码。
## 八、测试环境已验证
```text
SCENIC 候选:忽略错误 typeCode/status,只返回 ACTIVE + SCENIC,FLEET-only 为 0 ✓
SUPPLIES_COMBO 候选及绑定:返回/冻结 SUPPLIES_COMBO,绑定后已解绑恢复原关系状态 ✓
VEHICLE 候选:忽略错误 typeCode/status,只返回 ACTIVE + FLEET ✓
通用列表 typeCode/status/keyword/limit:保持兼容 ✓
上下文不完整、无默认映射、失效类型、资源不存在:均失败关闭 ✓
```
## 九、相关历史 PR
| PR / 提交 | Issue | 说明 | 是否仍有效 |
|-----------|-------|------|------------|
| changelog `5046bce` | #7042 | 原要求前端维护映射 | ❌ 已更正 |
| changelog `a0daab2` | #7042 | 撤销前端映射要求,转后端工单 | ✅ 有效 |
| **PR #7064** | **#7059** | 后端按资源上下文解析、过滤并修正组合备品映射 | ✅ 最新 |
## 十、相关文档
- 关联 Issue: [wx/HL#7059](https://git.1814.love:8443/wx/HL/issues/7059)
- 关联 PR: [wx/HL#7064](https://git.1814.love:8443/wx/HL/pulls/7064)
- 被更正记录: [#7042 Changelog](./03_7042_设置供应商列表按资源类型过滤-前端缺陷-管理后台.md)
## 关联 / 联系人
### 链接
- **Issue**: [#7059](https://git.1814.love:8443/wx/HL/issues/7059)
- **PR**: [#7064](https://git.1814.love:8443/wx/HL/pulls/7064)
- **Merge commit**: [5999b532e9f9725ac3b36f859ee48f6f0b8aa43c](https://git.1814.love:8443/wx/HL/commit/5999b532e9f9725ac3b36f859ee48f6f0b8aa43c)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,537 @@
---
schema: "hl-changelog/v2"
ticket: "7063"
title: "团期导游位下游按配置位归组,GUIDE 与 LEADER 一视同仁"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "not_required"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "PR #7065 已合入 dev-v3(合并提交 f37b6ee0f);2026-09-04 部署测试环境并经网关实测 AC-2/AC-3/AC-4/AC-5/AC-9。第 2 波配置位字典化未做"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 团期人员配置: 导游位下游判定由单一角色改为按配置位归组
> **服务**: hl-order-service-v3
> **PR**: #7065 | **Issue**: #7063 | **合并提交**: `f37b6ee0f`
> **影响范围**: 管理后台「团期详情 → 配置导游/摄影」、「订单详情 → 人员」、行程单打印
---
## ⚠️ 关键变化
**「导游位并收导游与领队」这条裁决此前只落到了候选列表一层,保存之后的下游仍只认单一角色。**
结果是配置人怎么选都会踩一边:
| 存成 | 丢什么 |
|---|---|
| `GUIDE` | `guide_ready` 不置位、`guide_status` 不写、`GUIDE_DONE` 流水不写、订单资源就绪闸门推不动 |
| `LEADER` | 行程单「导游」栏为空 |
**本机实测还暴露了第二处更深的缺陷**:团期人员配置保存时,`ready` 回填用的是**产品侧 batchId**,
而 `order_group_batch` 的 ready 列按**主键**更新,两者不是同一个 ID 空间。
Mapper `rows=0` 只打 WARN 不抛,所以这个洞一直没被发现——
**改前不论配 GUIDE 还是 LEADER,`guide_ready` / `photographer_ready` 从来就没被置位过。**
---
## 一、背景
人员类型 `staff_type` 在服务人员管理里是维护好的字典,团期现场谁带团由业务按实际安排:
有的团派导游,有的团派领队,也有两人都上。2026-09-02 已裁决「导游位并收 GUIDE 与 LEADER,
服务端不替业务做取舍」,本次把这条裁决贯彻到保存之后的全部下游链路。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 全量保存团期人员配置 | PUT | `/v3/admin/group-batch/:groupBatchId/staff` | **副作用变更** | 导游位配 `GUIDE` 也置 `guide_ready`;并修正 ready 回填的 ID 空间 |
| 2 | 订单新增人员 | POST | `/v3/admin/order/:id/staff` | **副作用变更** | `staffRole=GUIDE` 也写 `guide_status` 与 `GUIDE_DONE` 流水 |
| 3 | 订单删除人员 | DELETE | `/v3/admin/order/:id/staff/:staffAssignmentId` | **副作用变更** | 计数按导游位整组,删其一不再误置空 `guide_status` |
| 4 | 打印行程单 | GET | `/v3/admin/order/:id/print-itinerary` | **出参取值口径变更** | 导游栏 `GUIDE` 优先、缺位回退 `LEADER` |
---
## 三、接口详情
### 1. 全量保存团期人员配置 `PUT /v3/admin/group-batch/:groupBatchId/staff`
**VO**: `BatchStaffConfigReqVO`
#### 使用场景
团期详情「配置导游 / 配置摄影」弹窗点保存时调用。全量覆盖语义:传入列表即为最终配置。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | **产品侧团期 batchId**,非 `order_group_batch` 主键 |
| `staffList` | Body | Array | ❌ | 传空则清空 | 人员配置列表,全量覆盖 |
| `staffList[].staffId` | Body | Long | ✅ | — | 资源域人员 ID |
| `staffList[].staffRole` | Body | String | ✅ | `LEADER` `GUIDE` `DRIVER` `PHOTOGRAPHER` `OTHER` | 团期角色 |
| `staffList[].sortOrder` | Body | Integer | ❌ | 缺省 0 | 展示排序 |
#### 出参 `Result<BatchStaffConfigRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `staffList` | Array | 落库后的人员快照,**结构未变** |
| `affectedOrderCount` | Integer | 扇出到的活跃子订单数,**未变** |
> **出参结构完全没有变化**,变的是写库之后的副作用。
#### 请求示例
```json
{
"staffList": [
{
"staffId": 1002,
"staffRole": "GUIDE",
"sortOrder": 0
}
]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"staffList": [
{
"staffId": 1002,
"staffRole": "GUIDE",
"staffName": "李雪梅",
"staffPhone": "138****1002",
"sortOrder": 0
}
],
"affectedOrderCount": 2
}
}
```
#### 空数据 / 降级响应
`staffList` 传空数组即清空该团期全部人员配置,返回空列表:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"staffList": [],
"affectedOrderCount": 2
}
}
```
#### 错误响应
团期不存在时 ready 回填跳过并打 WARN,接口本身仍成功;未携带令牌时:
```json
{
"code": 401,
"message": "缺少有效的 Authorization 头",
"success": false,
"data": null
}
```
#### 业务边界
- 导游位认 `GUIDE` 与 `LEADER` 两种角色,任一有人即置 `guide_ready`
- 摄影位只认 `PHOTOGRAPHER`
- 同时配了导游和领队时 `markGuideReady` 只调一次,幂等
- 团期查不到(未成团 / 已解散)时 ready 回填跳过,不抛异常
- `DRIVER` 不参与任何配置位判定,由车务派车投影产生
### 2. 订单新增人员 `POST /v3/admin/order/:id/staff`
**VO**: `StaffAssignReqVO`
#### 使用场景
订单详情「人员」区手工新增一名服务人员时调用。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `id` | Path | Long | ✅ | — | 订单 ID |
| `staffId` | Body | Long | ✅ | — | 资源域人员 ID |
| `staffRole` | Body | String | ✅ | `LEADER` `GUIDE` `DRIVER` `PHOTOGRAPHER` `OTHER` | 团期角色 |
#### 出参 `Result<StaffAssignmentVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `assignmentId` | Long | 新建行 ID,**未变** |
| `staffRole` | String | 角色,**未变** |
| `staffPhone` | String | 脱敏手机号,**未变** |
#### 请求示例
```json
{
"staffId": 1002,
"staffRole": "GUIDE"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"assignmentId": 2095758914858471425,
"staffId": 1002,
"staffRole": "GUIDE",
"staffName": "李雪梅",
"staffPhone": "138****1002"
}
}
```
#### 空数据 / 降级响应
本接口为写入,无空数据形态;资源域人员查询失败时整批 fail-fast:
```json
{
"code": 582103,
"message": "员工信息查询失败,请稍后重试",
"success": false,
"data": null
}
```
#### 错误响应
同一订单同一角色重复添加同一人:
```json
{
"code": 582101,
"message": "该员工已分配此角色到此订单",
"success": false,
"data": null
}
```
#### 业务边界
- `staffRole=GUIDE` 现在与 `LEADER` 等价地触发 `guide_status=DONE` 与 `GUIDE_DONE` 流水
- 流水姓名脱敏为首字符加星号,操作人由 `operatorResolver` 自动带出
- 写完状态后触发 `maybeAdvanceToConfirm`,尝试推进订单资源就绪
- `DRIVER` 与 `OTHER` 不落任何 staff 状态列
- 团期来源行(`source=GROUP_BATCH`)不允许在订单侧增删改
### 3. 订单删除人员 `DELETE /v3/admin/order/:id/staff/:staffAssignmentId`
**VO**: `StaffAssignmentVO`
#### 使用场景
订单详情「人员」区移除一名手工添加的服务人员。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `id` | Path | Long | ✅ | — | 订单 ID |
| `staffAssignmentId` | Path | Long | ✅ | — | 人员配置行 ID |
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data` | Null | 无返回体,**未变** |
#### 请求示例
```http
DELETE /v3/admin/order/990706300101/staff/2095758914858471425
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": null
}
```
#### 空数据 / 降级响应
行不存在或不属于该订单:
```json
{
"code": 582102,
"message": "员工分配记录不存在",
"success": false,
"data": null
}
```
#### 错误响应
团期共享行不可在订单侧删除:
```json
{
"code": 582109,
"message": "团期共享员工不可在订单侧增删改,请到团期配置页操作",
"success": false,
"data": null
}
```
#### 业务边界
- 删除后按**导游位整组**重新计数,而不是只数被删的那个角色
- 导游位同时有导游和领队时,删掉其一 `guide_status` 保持 `DONE`
- 整组归零时才把 `guide_status` 置空,且不写状态流水
- 摄影位口径不变,仍只数 `PHOTOGRAPHER`
### 4. 打印行程单 `GET /v3/admin/order/:id/print-itinerary`
**VO**: `PrintItineraryRespVO`
#### 使用场景
订单详情点「打印行程单」,抬头区展示司机 / 导游 / 领队的姓名与电话。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `id` | Path | Long | ✅ | — | 订单 ID |
#### 出参 `Result<PrintItineraryRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `guideName` | String | 导游姓名。**取值口径变更**:导游位内 `GUIDE` 优先,缺位回退 `LEADER` |
| `guidePhone` | String | 导游电话,同上口径 |
| `leaderName` | String | 领队姓名,仍只取 `LEADER` 行,**未变** |
| `leaderPhone` | String | 领队电话,**未变** |
#### 请求示例
```http
GET /v3/admin/order/990706300101/print-itinerary
```
#### 响应示例
导游位只配了领队时,导游栏回退取到领队:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"header": {
"guideName": "刘大山",
"guidePhone": "13800001005",
"leaderName": "刘大山",
"leaderPhone": "13800001005"
}
}
}
```
#### 空数据 / 降级响应
导游位无人时四个字段均为 null,接口仍成功:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"header": {
"guideName": null,
"guidePhone": null,
"leaderName": null,
"leaderPhone": null
}
}
}
```
#### 错误响应
非本人名下订单且无数据权限:
```json
{
"code": 581008,
"message": "无权查看此订单",
"success": false,
"data": null
}
```
#### 业务边界
- 只读接口,不产生任何写入
- 导游位同时有导游和领队时,导游栏取导游,不取领队
- `leaderName` 与 `leaderPhone` 语义不变,仍是领队本身
- 导游与领队是同一人时两栏显示同一姓名,属预期
---
## 四、契约约束与正确调用方式
- **导游位的角色集合是 `GUIDE` 与 `LEADER` 两个**,前端配置弹窗可以把两类人员一起列出来,服务端不做取舍。
- **`guideName` 不再等价于「`staffRole=GUIDE` 那一行」**,它是「导游位上的人」。要精确区分导游与领队请分别读 `guideName` 与 `leaderName`。
- **`groupBatchId` 在人员配置接口里是产品侧 batchId**,不是 `order_group_batch` 主键,两者不可互换。
- 判断「导游是否配齐」请读团期的 `guide_ready` 或订单的 `guide_status`,不要自行按角色字符串比对。
---
## 五、数据库行为
无 DDL、无迁移、无新增列。写入行为的变化:
| 表 | 列 | 变化 |
|---|---|---|
| `order_group_batch` | `guide_ready` | 改前因 ID 空间用错**从未被置位**;改后按主键正确置 true |
| `order_group_batch` | `photographer_ready` | 同上 |
| `order_main` | `guide_status` | `staffRole=GUIDE` 的行现在也会写 `DONE`;删除时按导游位整组判空 |
| `order_status_log` | — | `staffRole=GUIDE` 现在也会产生一条 `GUIDE_DONE` 数据流水 |
| `order_batch_staff` / `order_staff_assignment` | — | 结构与写入内容均**未变** |
---
## 六、边界行为
- 导游位配 `GUIDE` → `guide_ready=true`
- 导游位配 `LEADER` → `guide_ready=true`
- 导游位同时配两者 → `markGuideReady` 只调一次
- 团期查不到 → ready 回填跳过并 WARN,接口不报错
- 导游位删剩一人 → `guide_status` 保持 `DONE`
- 导游位清空 → `guide_status` 置空且不写流水
- 行程单导游位只有领队 → 导游栏取领队
## 六.5、枚举 / 数据字典
本次无新增枚举。`staffRole` 取值域与 `SettlementStaffRoleEnum` 保持一致,未扩展。
## 六.6、修改前后对比
| 项 | 变更前 | 变更后 |
|---|---|---|
| `guide_ready` 回填 | **从未生效**(用产品侧 batchId 打主键,rows=0 只 WARN) | 反查真实主键后正确置位 |
| 导游位判定 | 只认 `LEADER` | 认 `GUIDE` 与 `LEADER` |
| `guide_status` 写入 | `GUIDE` 行不写 | `GUIDE` 行同样写 |
| `GUIDE_DONE` 流水 | `GUIDE` 行不写 | `GUIDE` 行同样写 |
| 删除后计数 | 只数被删角色,可能误置空 | 按导游位整组计数 |
| 行程单导游栏 | 只取 `GUIDE` 行,配领队时为空 | `GUIDE` 优先,缺位回退 `LEADER` |
| 接口路径 / 入参 / 出参结构 | — | **全部不变** |
## 六.7、影响评估
| 维度 | 评估 |
|---|---|
| 兼容性 | **只放宽不收紧**。原来不触发的现在会触发,存量 `staffRole=LEADER` 的配置行行为与改前完全一致 |
| 前端 | **无需改动**。路径、入参、出参结构均未变;行程单导游栏由「可能为空」变为「有值」,是修复不是破坏 |
| 数据 | 无 DDL、无迁移、无回填 |
| 性能 | 删除路径的计数由单值等值查询改为 `IN` 两值;行程单导游栏由一次点查改为一次整组查询后按序挑选,**查询次数不增** |
| 回滚 | `git revert`,单一 PR,无数据侧残留 |
| 风险 | 低。`guide_ready` 修复后会让原本推不动的团期开始推进资源就绪闸门,属预期恢复 |
## 七、不影响范围
- **零影响**:所有接口的路径、HTTP 方法、入参、出参结构
- **零影响**:摄影位口径,仍只收 `PHOTOGRAPHER`
- **零影响**:`leaderName` / `leaderPhone` 的语义与取值
- **零影响**:`DRIVER` 与 `OTHER` 角色的处理
- **零影响**:staff-fees 导游费用录入分流逻辑(裁决 D-2,配在导游位的领队不录导游费用,属预期)
- **未新建端点**,未改动任何路径与入参
---
## 八、测试环境已验证
✅ 2026-09-04 于测试环境网关实测,真实鉴权(管理端 `test_admin`,角色定制师)。
- 网关 `https://api.test.1814.love:9443`,分支 `dev-v3`,合并提交 `f37b6ee0f`
- 部署方式:双实例滚动更新(8186 → 8086),各 11s 就绪,零停机
- 部署内容经部署面板 `/api/git/backend` 交叉核对:构建时点 `f37b6ee0f` 已是 `dev-v3` 顶端
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| 1 | 配 `staffType=GUIDE` 到导游位 | `guide_ready` 置 1 | ✅ 0 → 1(AC-2) |
| 2 | 配 `staffType=LEADER` 到导游位 | `guide_ready` 置 1 | ✅ 0 → 1(AC-4) |
| 3 | 配摄影师 | `photographer_ready` 置 1 | ✅ 0 → 1(AC-9) |
| 4 | 订单侧配 `staffRole=GUIDE` | `guide_status=DONE` + `GUIDE_DONE` 流水 | ✅ 流水操作人 `test_admin`,内容脱敏为首字加星 |
| 5 | 导游位删剩领队 | `guide_status` 保持 `DONE` | ✅ 未被置空 |
| 6 | 行程单,导游位只有领队 | 导游栏取到领队 | ✅ `guideName` 为领队姓名,电话一致 |
| 7 | 行程单,导游位两者都有 | 导游栏取导游 | ✅ 导游优先生效 |
| 8 | 扇出 | 团期配置扇出到活跃子订单 | ✅ 2 单,`source=GROUP_BATCH` |
**观察**:部署后双实例持续 running 无重启,服务日志最近 200 行 `ERROR` / `Exception` 命中 **0** 条。
日志中可见 `Parameters: 990706300101(Long), GUIDE(String), LEADER(String)`,即按导游位整组计数的查询确已在测试环境执行。
**本地单测**:`AssignmentServiceTest` 65 例、`GroupBatchStaffConfigServiceTest` 31 例、
`AssignmentServiceCallbackDriverTest` 42 例、`OrderServiceTest` 205 例、
架构与错误码门禁 51 例,合计 **401 例全过**,其中新增 7 例覆盖本次全部行为变化。
**测试数据**:测试环境所造数据一律 `T7063-` 前缀,验证后已物理删除,残留合计 0 条。
所用 `test_admin` 口令为一次性置换,取到令牌后立即按备份还原,与备份逐字节一致。
> ⚠️ **一处未做实测**:团期人员配置的扇出链路(`doFanOutForOrder`)**不调用** `syncStaffStatusToOrder`,
> 因此经团期配置这条路进来时 `order_main.guide_status` 始终为 null。用例 4 走的是**订单侧**新增人员端点。
> 这是先于本次变更就存在的第三处缺口,不在本波范围,已在工单中记录待另议。
---
## 九、相关历史 PR
- #7065 本次变更(第 1 波 P1 止血)
- #6962(Refs #6950)团期人员配置候选列表——本次修的正是它的下游遗漏
- 裁决来源:2026-09-02「导游位并收 GUIDE 与 LEADER,服务端不替业务做取舍」
---
## 十、相关文档
- 团期需求文档:`docs/group/`(dev-v3 分支)
- 一期实施拆分详细设计 v1.0 §0.26.4(AC-TD-13~16)
---
## 关联 / 联系人
- **Issue**: #7063
- **PR**: #7065(合并提交 `f37b6ee0f`)
@@ -0,0 +1,267 @@
---
schema: "hl-changelog/v2"
ticket: "7066"
title: "团期子订单列表补应收总额 totalAmount"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-04"
status_note: "PR #7068 已合入 dev-v3(合并提交 e1ad8550b);2026-09-04 部署测试环境,网关实测 8 组用例全部通过(含取消单归 0)"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 团期子订单: 出参新增应收总额 totalAmount
> **服务**: hl-order-service-v3
> **PR**: #7068 | **Issue**: #7066 | **合并提交**: `e1ad8550b`
> **影响范围**: 管理后台「团期详情 → 子订单」页签
---
## ⚠️ 关键变化
**前端不要再用 `paidAmount + balanceAmount` 反推应收总额,改读新增的 `totalAmount`。**
旧反推在两种场景下**偏大**:
1. `balanceAmount` 被钳在 ≥0(`OrderAmountUtil.calcBalance` 末行),超付或退款后已付大于应收时反推值偏大
2. 已取消子订单 `calcBalance` 直接返 0,反推得到的是已付而非应收
**这不是理论风险**:2026-09-04 测试环境实测,现存子订单 `paidAmount=3270.00`、`balanceAmount=0.00`,
而真实应收 `totalAmount=2943.00`——**旧算法多显示 327.00**。
---
## 一、背景
团期详情「子订单」页签每张卡片显示「已付 / 应收总额」,但列表接口出参此前没有应收总额字段,
前端只能自行反推。本次补齐该字段,口径与订单域既有展示口径统一。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期下子订单列表 | GET | `/v3/admin/order/group-batch/:groupBatchId/orders` | **出参新增字段** | 新增 `totalAmount` 应收总额 |
---
## 三、接口详情
### 1. 团期下子订单列表 `GET /v3/admin/order/group-batch/:groupBatchId/orders`
**VO**: `GroupBatchOrderItemRespVO`
#### 使用场景
团期详情「子订单」页签加载时调用,渲染每户卡片。卡片右下角「已付 ¥X / ¥Y」中的 Y 即本次新增的 `totalAmount`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 运营团期 ID(订单侧 `order_group_batch` 主键) |
| `includeTravelers` | Query | Boolean | ❌ | 缺省 false | 附出行人明细,证件号一律不返回 |
| `includeNeeds` | Query | Boolean | ❌ | 缺省 false | 附房数 / 房型 / 特殊需求 |
| `includeCancelled` | Query | Boolean | ❌ | 缺省 false | 是否含已取消子订单,缺省只返活跃集 |
#### 出参 `Result<List<GroupBatchOrderItemRespVO>>`
本次仅新增一个字段,其余 28 个字段不变:
| 字段 | 类型 | 说明 |
|------|------|------|
| `totalAmount` | String | **本次新增**。应收总额 = `orderAmount + 增项 − 优惠`;**已取消子订单归 0**。2 位小数,字符串输出 |
| `paidAmount` | String | 已支付金额(未变) |
| `balanceAmount` | String | 待支付尾款,**钳在 ≥0**(未变) |
> 三个金额字段均带 `@JsonSerialize(ToStringSerializer)`,**JSON 里是字符串**,前端直接解析为数字会有精度风险,应按字符串处理或用高精度解析。
#### 请求示例
```http
GET /v3/admin/order/group-batch/2089713777065832450/orders
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{
"orderId": 2094597053924487170,
"customerName": "6915验收造单01",
"orderStatus": "CUSTOMIZING",
"orderStatusName": "定制中",
"adultCount": 2,
"childCount": 0,
"participantCount": 2,
"contactPhone": "138****0001",
"paidAmount": "3270.00",
"balanceAmount": "0.00",
"totalAmount": "2943.00"
}
]
}
```
#### 空数据 / 降级响应
团期下无子订单时返回空数组,不报错:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": []
}
```
#### 错误响应
未携带管理端令牌:
```json
{
"code": 401,
"message": "缺少有效的 Authorization 头",
"success": false,
"data": null
}
```
#### 业务边界
- 只读接口,不产生任何写入
- `totalAmount` **不等于** `paidAmount + balanceAmount`,超付、退款、取消单三种情况下都会不等
- 已取消子订单(`includeCancelled=true` 才可见)的 `totalAmount` 与 `balanceAmount` 同为 0,闭合财务勾稽
- 金额一律 2 位小数(HALF_UP),与订单域其他接口格式一致
---
## 四、契约约束与正确调用方式
- **应收总额一律读 `totalAmount`**,不要自行用 `paidAmount + balanceAmount` 计算。
- **`totalAmount` 与 `balanceAmount` 是不同语义**:前者是「客户总共该付多少」,后者是「现在还差多少」。已付清时后者为 0,前者仍是原值。
- **三个金额字段是 JSON 字符串**,不是数字。
- 取消单场景下 `totalAmount` 归 0 是**有意设计**(对齐 `calcBalance` 的取消单归 0),用于闭合「应收 0 / 已付 0 / 已退 0 / 待收 0」的勾稽链,不是缺陷。
---
## 五、数据库行为
本接口只读,**不产生任何写入**,也不涉及表结构调整。`totalAmount` 由既有字段实时计算,不新增存储列。
---
## 六、边界行为
- 超付单 → `totalAmount` 为真实应收,小于 `paidAmount`
- 取消单 → `totalAmount` 归 0
- 团期无子订单 → 返回 `[]`
- 未登录 → 401(网关拦截)
---
## 六.5、枚举 / 数据字典
本次无新增枚举。
---
## 六.6、修改前后对比
| 项 | 变更前 | 变更后 |
|---|---|---|
| 出参字段数 | 28 | **29** |
| 应收总额 | **无字段**,前端用 `paidAmount + balanceAmount` 反推 | 新增 `totalAmount`,服务端按既有 `payableForDisplay` 口径给出 |
| 超付单显示 | 反推值偏大(实测多 327.00) | `totalAmount` 为真实应收 |
| 取消单显示 | 反推得到已付金额,非应收 | `totalAmount` 归 0,与 `balanceAmount` 一致 |
| `paidAmount` / `balanceAmount` | — | **语义与取值均不变** |
| 路径 / 入参 | — | **不变** |
## 六.7、影响评估
| 维度 | 评估 |
|---|---|
| 兼容性 | **纯 additive**,老调用方不读新字段即无感,无破坏性变更 |
| 前端 | 需改为读 `totalAmount`;不改也不会报错,但超付 / 取消单场景显示值偏大 |
| 数据 | 无 DDL、无迁移、无回填;`totalAmount` 实时计算不落库 |
| 性能 | 无额外查询,复用同一 `OrderInfo` 实体计算,零新增 IO |
| 回滚 | 移除字段即可,无数据侧残留 |
| 风险 | 低。口径复用 `AdjustmentService` 已在生产使用的既有方法,未新造公式 |
## 七、不影响范围
- **零影响**:`paidAmount` / `balanceAmount` 的现有语义与取值
- **零影响**:其余 28 个出参字段
- **零影响**:老调用方——纯新增字段,不读即无感
- **未新建端点**,未改动路径与入参
---
## 八、测试环境已验证
✅ 2026-09-04 于测试环境网关实测,真实鉴权(管理端 admin)。
- 网关 `https://api.test.1814.love`,分支 `dev-v3`,合并提交 `e1ad8550b`
- 部署方式:双实例滚动更新(8186 → 8086),各 11s 就绪,零停机
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| 1 | 字段上线 | 出参含 `totalAmount` | ✅ 字段数 27 → 29 |
| 2 | 真实数据口径 | `totalAmount` 为真实应收 | ✅ `2943.00`;旧反推得 `3270.00`,**偏差 327.00** |
| 3 | 序列化格式 | 字符串、2 位小数 | ✅ `"totalAmount":"2943.00"` |
| 4 | `includeCancelled=true` | 正常返回 | ✅ 返回 1 条 |
| 5 | 无 Authorization | 401 | ✅ `401 缺少有效的 Authorization 头` |
| 6 | 老字段回归 | 未受影响 | ✅ 8 个字段逐一核对一致 |
| 7 | 缺省过滤取消单 | 取消单不出现 | ✅ `GET .../orders` 返回 0 条 |
| 8 | **取消单归 0** | `totalAmount = 0` 且与 `balanceAmount` 一致 | ✅ `totalAmount="0.00"`、`balanceAmount="0.00"`、`paidAmount="3270.00"` |
**部署前基线对照**:同一接口部署前出参 27 字段、无 `totalAmount`,确认变更确实生效。
**本地单测**:`GroupBatchConverterTest` 新增 5 例(正常单 / 含增项优惠 / **超付单断言反推不等** / **取消单归 0** / 2 位小数);
本地全量 **750 例全过**。
**取消单分支已实测**(用例 7–8):验收期间该子订单被退单,恰好提供了取消单样本。
实测 `totalAmount="0.00"`,而 `paidAmount="3270.00"`——**旧算法 `paidAmount + balanceAmount` 会把一条
已取消的子订单显示成「应收 3270」,真实应收应为 0**。本字段同时修正了这一场景。
全部 8 组用例通过,无遗留未验分支。
---
## 九、相关历史 PR
- #7068 本次变更
- 口径来源:`OrderAmountUtil.payableForDisplay`(#4456 展示用应收总额,取消单归 0)
- 相关:#5733(`calcBalance` 改为退款冲减应收)、#2896 P1-3(金额统一 2 位小数)
---
## 十、相关文档
- 团期需求文档:`docs/group/`(dev-v3 分支)
- 3 天开发计划:`docs/group/团期模块3天开发计划.md`
---
## 关联 / 联系人
- **Issue**: #7066
- **PR**: #7068(合并提交 `e1ad8550b`)
- **服务**: hl-order-service-v3
- **后端**: jw
- **前端**: 待认领(`frontend_status: pending`)
@@ -0,0 +1,514 @@
---
schema: "hl-changelog/v2"
ticket: "7069"
title: "供应商企微在途状态统一审核中"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-04"
status_note: "后端已部署并通过 TEST;前端需统一展示 statusName。"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 供应商模块:企微在途状态统一显示“审核中”
> **服务**: `hl-resource-service`
> **Issue**: #7069
> **PR**: #7072
> **影响范围**: 管理后台供应商列表、详情和状态动作结果
## ⚠️ 关键变化
前端统一展示新增的 `statusName`:已提交企业微信且仍在审批中的供应商显示“审核中”,不再把来源生命周期状态作为展示文案;原 `status` 字段继续用于筛选和业务判断。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 供应商分页 | GET | `/admin/supplier/items/page` | 响应新增字段 | 每行新增 `statusName` |
| 2 | 供应商有界列表 | GET | `/admin/supplier/items/list` | 响应新增字段 | 每行新增 `statusName` |
| 3 | 供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应新增字段 | 详情新增 `statusName` |
| 4 | 暂停合作 | POST | `/admin/supplier/items/{supplierId}/suspend` | 响应新增字段 | 返回 `statusName=暂停合作` |
| 5 | 列入黑名单 | POST | `/admin/supplier/items/{supplierId}/blacklist` | 响应新增字段 | 企微在途返回 `statusName=审核中` |
| 6 | 恢复合作 | POST | `/admin/supplier/items/{supplierId}/resume` | 响应新增字段 | 返回 `statusName=合作中` |
| 7 | 解除黑名单 | POST | `/admin/supplier/items/{supplierId}/unblacklist` | 响应新增字段 | 企微在途返回 `statusName=审核中` |
| 8 | 清账归档 | POST | `/admin/supplier/items/{supplierId}/archive` | 响应新增字段 | 两类企微归档在途均返回“审核中” |
## 三、接口详情
### 1. 供应商分页 `GET /admin/supplier/items/page`
**VO**: `SupplierPageReqVO` → `PageResult<SupplierListItemRespVO>`
#### 使用场景
供应商管理分页展示及按生命周期状态筛选。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| page / pageSize | Query | Integer | 否 | `page>=1`,`1<=pageSize<=100` | 默认 1 / 20 |
| status | Query | String | 否 | 生命周期枚举 | 仍按原 `status` 筛选 |
| keyword / typeCode / creditLevel / creatorId | Query | String | 否 | 沿用原约束 | 其他筛选条件不变 |
#### 出参 `Result<PageResult<SupplierListItemRespVO>>`
| 字段 | 类型 | 说明 |
|---|---|---|
| data.records[].status | String | 原生命周期状态码,保持不变 |
| data.records[].statusName | String | 对外展示文案;企微审批在途为“审核中” |
#### 请求示例
```http
GET /admin/supplier/items/page?page=1&pageSize=20
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"records":[{"supplierId":"2095701432631017473","status":"VETTING","statusName":"审核中"}],"total":1,"page":1,"pageSize":20},"success":true}
```
#### 空数据 / 降级响应
无匹配供应商时 `data.records` 为空数组,分页元数据仍正常返回。
#### 错误响应
```json
{"code":401,"message":"未登录或登录已过期","data":null,"success":false}
```
#### 业务边界
- `status` 仍只接受既有生命周期状态码;“审核中”是展示文案,不作为筛选值。
### 2. 供应商有界列表 `GET /admin/supplier/items/list`
**VO**: `SupplierListReqVO` → `List<SupplierListItemRespVO>`
#### 使用场景
下拉选择及有界供应商列表展示。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| limit | Query | Integer | 否 | 1~200 | 默认 50 |
| status | Query | String | 否 | 生命周期枚举 | 仍按原 `status` 筛选 |
| keyword / typeCode / resourceModule / resourceId | Query | String | 否 | 沿用原约束 | 其他筛选条件不变 |
#### 出参 `Result<List<SupplierListItemRespVO>>`
| 字段 | 类型 | 说明 |
|---|---|---|
| data[].status | String | 原生命周期状态码,保持不变 |
| data[].statusName | String | 对外展示文案;企微审批在途为“审核中” |
#### 请求示例
```http
GET /admin/supplier/items/list?limit=50
```
#### 响应示例
```json
{"code":200,"message":"成功","data":[{"supplierId":"2095701432631017473","status":"VETTING","statusName":"审核中"}],"success":true}
```
#### 空数据 / 降级响应
无匹配供应商时返回 `data=[]`。
#### 错误响应
```json
{"code":401,"message":"未登录或登录已过期","data":null,"success":false}
```
#### 业务边界
- 返回条数仍受 `limit` 限制;前端展示 `statusName`,业务判断继续使用 `status`。
### 3. 供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `Void` → `SupplierBasicInfoRespVO`
#### 使用场景
供应商详情页展示当前对外状态。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数 | 供应商 ID |
#### 出参 `Result<SupplierBasicInfoRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| data.status | String | 原生命周期状态码,保持不变 |
| data.statusName | String | 对外展示文案;企微审批在途为“审核中” |
#### 请求示例
```http
GET /admin/supplier/items/2095701432631017473/basic-info/view
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"supplierId":"2095701432631017473","status":"VETTING","statusName":"审核中"},"success":true}
```
#### 空数据 / 降级响应
供应商不存在时返回业务失败,不返回空成功详情。
#### 错误响应
```json
{"code":395001,"message":"供应商不存在","data":null,"success":false}
```
#### 业务边界
- 企微审批结束后刷新本接口,即可获得对应终态展示文案。
### 4. 暂停合作 `POST /admin/supplier/items/{supplierId}/suspend`
**VO**: `SupplierStatusChangeReqVO` → `SupplierStatusChangeRespVO`
#### 使用场景
将合作中供应商暂停合作。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数 | 供应商 ID |
| reason | Body | String | 是 | 1~500 字符 | 状态变更原因 |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 页面读取到的并发版本 |
#### 出参 `Result<SupplierStatusChangeRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| data.status | String | `SUSPENDED` |
| data.statusName | String | “暂停合作” |
#### 请求示例
```json
{"reason":"暂停合作","expectedUpdateTime":"2026-09-04 10:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"supplierId":"2095000000000000001","status":"SUSPENDED","statusName":"暂停合作"},"success":true}
```
#### 空数据 / 降级响应
写接口不返回空成功数据;执行失败时状态不变。
#### 错误响应
```json
{"code":395014,"message":"数据已被他人修改,请刷新后重试","data":null,"success":false}
```
#### 业务边界
- 权限、允许状态、并发版本及幂等规则均保持不变。
### 5. 列入黑名单 `POST /admin/supplier/items/{supplierId}/blacklist`
**VO**: `SupplierStatusChangeReqVO` → `SupplierStatusChangeRespVO`
#### 使用场景
对暂停合作供应商发起列入黑名单企微审批。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数 | 供应商 ID |
| reason | Body | String | 是 | 1~500 字符 | 列入黑名单原因 |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 页面读取到的并发版本 |
#### 出参 `Result<SupplierStatusChangeRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| data.status | String | 审批中仍为 `SUSPENDED` |
| data.statusName | String | 审批中为“审核中” |
| data.approval.approvalStatus | String | 审批中为 `PENDING` |
#### 请求示例
```json
{"reason":"严重违约","expectedUpdateTime":"2026-09-04 10:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"supplierId":"2095000000000000002","status":"SUSPENDED","statusName":"审核中","approval":{"provider":"WECOM","approvalStatus":"PENDING","spNo":"202609040001"}},"success":true}
```
#### 空数据 / 降级响应
写接口不返回空成功数据;企微提交失败时保持暂停合作。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- 审批通过后刷新查询接口显示“黑名单”,驳回后显示“暂停合作”。
### 6. 恢复合作 `POST /admin/supplier/items/{supplierId}/resume`
**VO**: `SupplierStatusChangeReqVO` → `SupplierStatusChangeRespVO`
#### 使用场景
将暂停合作供应商恢复合作。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数 | 供应商 ID |
| reason | Body | String | 是 | 1~500 字符 | 状态变更原因 |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 页面读取到的并发版本 |
#### 出参 `Result<SupplierStatusChangeRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| data.status | String | `ACTIVE` |
| data.statusName | String | “合作中” |
#### 请求示例
```json
{"reason":"恢复合作","expectedUpdateTime":"2026-09-04 10:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"supplierId":"2095000000000000003","status":"ACTIVE","statusName":"合作中"},"success":true}
```
#### 空数据 / 降级响应
写接口不返回空成功数据;执行失败时状态不变。
#### 错误响应
```json
{"code":395014,"message":"数据已被他人修改,请刷新后重试","data":null,"success":false}
```
#### 业务边界
- 权限、允许状态、并发版本及幂等规则均保持不变。
### 7. 解除黑名单 `POST /admin/supplier/items/{supplierId}/unblacklist`
**VO**: `SupplierStatusChangeReqVO` → `SupplierStatusChangeRespVO`
#### 使用场景
对黑名单供应商发起解除黑名单企微审批。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数 | 供应商 ID |
| reason | Body | String | 是 | 1~500 字符 | 解除原因 |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 页面读取到的并发版本 |
#### 出参 `Result<SupplierStatusChangeRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| data.status | String | 审批中仍为 `BLACKLIST` |
| data.statusName | String | 审批中为“审核中” |
| data.approval.approvalStatus | String | 审批中为 `PENDING` |
#### 请求示例
```json
{"reason":"整改完成","expectedUpdateTime":"2026-09-04 10:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"supplierId":"2095000000000000004","status":"BLACKLIST","statusName":"审核中","approval":{"provider":"WECOM","approvalStatus":"PENDING","spNo":"202609040002"}},"success":true}
```
#### 空数据 / 降级响应
写接口不返回空成功数据;企微提交失败时保持黑名单。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- 审批通过后刷新查询接口显示“暂停合作”,驳回后显示“黑名单”。
### 8. 清账归档 `POST /admin/supplier/items/{supplierId}/archive`
**VO**: `Void` → `SupplierArchiveRespVO`
#### 使用场景
暂停合作供应商发起“终止且账清”,或黑名单供应商发起“拉黑且账清”企微审批。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| supplierId | Path | String | 是 | 正整数 | 供应商 ID |
#### 出参 `Result<SupplierArchiveRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| data.status | String | 审批中保持 `SUSPENDED` 或 `BLACKLIST` |
| data.statusName | String | 两类审批在途均为“审核中” |
| data.approval.approvalStatus | String | 审批中为 `PENDING` |
#### 请求示例
```http
POST /admin/supplier/items/2095000000000000005/archive
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"supplierId":"2095000000000000005","status":"SUSPENDED","statusName":"审核中","approval":{"provider":"WECOM","approvalStatus":"PENDING","spNo":"202609040003"}},"success":true}
```
#### 空数据 / 降级响应
写接口不返回空成功数据;企微提交失败时保持来源状态。
#### 错误响应
```json
{"code":395021,"message":"企业微信申请提交失败,请稍后重试","data":null,"success":false}
```
#### 业务边界
- 两类审批通过后刷新查询接口均显示“已归档”;驳回后分别显示“暂停合作”或“黑名单”。
## 四、契约约束与正确调用方式
- 请求参数、鉴权、权限、幂等和错误码均不变。
- `status` 是生命周期状态码,继续用于筛选、按钮门禁和业务判断;`statusName` 是中文展示值。
- 状态动作成功后先使用响应中的 `statusName`,后续刷新分页、列表或详情获取审批终态。
## 五、数据库行为
本次不修改数据结构和状态迁移规则;写接口原有业务写入、失败零写入及并发规则不变,仅在响应中增加展示字段。
## 六、边界行为
- 注册审批中:`status=VETTING`,`statusName=审核中`。
- 企业微信状态审批仅在 `provider=WECOM`、`approvalStatus=PENDING` 且已有审批单号时覆盖展示为“审核中”。
- 审批结束后按当前生命周期状态展示,不继续显示“审核中”。
- 未登录时统一返回业务码 `401`;其他错误码保持不变。
## 六.5、枚举 / 数据字典
| 原状态或审批条件 | `statusName` |
|---|---|
| 注册审批中 `VETTING` | 审核中 |
| 企微状态审批在途 | 审核中 |
| `DRAFT` | 草稿 |
| `ACTIVE` | 合作中 |
| `SUSPENDED` 且无在途审批 | 暂停合作 |
| `BLACKLIST` 且无在途审批 | 黑名单 |
| `ARCHIVED` | 已归档 |
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| `statusName` | 不返回 | 列表、详情及五个状态动作响应返回中文展示值 |
| `status` | 生命周期状态码 | 保持不变 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 企微审批在途展示 | 可能继续显示来源状态 | 统一显示“审核中” |
| 审批完成展示 | 读取生命周期状态 | 保持按最终生命周期状态展示 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否,响应仅新增字段,原 `status` 保留。
- **前端是否必须同步上线**: 是,需改为展示 `statusName`。
- **前端 workaround 清理点**: 删除前端自行翻译 `status` 作为展示文案的逻辑。
## 七、不影响范围
- **仅影响**: 管理后台供应商状态文案展示。
- **零影响**: 生命周期状态机、审批通过或驳回迁移、请求参数、权限、数据库结构、配置、Redis 和 MQ。
## 八、测试环境已验证
- 分页、列表和详情均返回 `statusName`;注册审批中返回“审核中”,稳定态分别返回草稿、合作中、暂停合作、黑名单和已归档。
- 四类企微状态审批的完成态与来源状态一致;未登录请求返回业务码 `401`。
## 十、相关文档
- [Issue #7069](https://git.1814.love:8443/wx/HL/issues/7069)
- [PR #7072](https://git.1814.love:8443/wx/HL/pulls/7072)
## 前端动作与当前状态
- 列表、详情和动作结果统一展示 `statusName`;保留 `status` 做筛选和按钮门禁。
- 状态动作后刷新列表或详情,以最新 `statusName` 展示审批终态。
- **当前状态:待前端适配。**
## 关联 / 联系人
- **Issue**: [#7069](https://git.1814.love:8443/wx/HL/issues/7069)
- **PR**: [#7072](https://git.1814.love:8443/wx/HL/pulls/7072)
- **Merge commit**: [c95f1de495d4bcdd8579ccd1f0c895deca29e14a](https://git.1814.love:8443/wx/HL/commit/c95f1de495d4bcdd8579ccd1f0c895deca29e14a)
- **后端负责人**: @lc
@@ -0,0 +1,375 @@
---
schema: "hl-changelog/v2"
ticket: "7079"
title: "团期人员配置位成员改由字典决定,取值域三处对齐"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "not_required"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "PR #7085 已合入 dev-v3(合并提交 d1b724d59);2026-09-04 部署测试环境并经网关实测。含字典初始化脚本 sql/dict_group_batch_staff_slot.sql,需手工执行"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 团期人员配置: 配置位收哪些人员类型改由数据字典决定
> **服务**: hl-order-service-v3
> **PR**: #7085 | **Issue**: #7079 | **合并提交**: `d1b724d59`
> **影响范围**: 管理后台「团期详情 → 配置导游/摄影」弹窗的候选池
---
## ⚠️ 关键变化
**「导游位收哪些人员类型」不再写死在代码里,改由数据字典决定。业务在字典管理页面加一行,最多 5 分钟后生效,不需要发版。**
```
group_batch_staff_slot_guide → GUIDE、LEADER
group_batch_staff_slot_photographer → PHOTOGRAPHER
```
另有一处**落库取值变化**:导游位并收导游与领队,改前不论选的是谁,`order_batch_staff.staff_role` 落的都是前端传的配置位名(往往是 `GUIDE`),「这一位上站的到底是导游还是领队」在数据里就丢了。现在落该人员在资源域的真实 `staffType`。
---
## 一、背景
人员类型 `staff_type` 本来就是「服务人员管理」里维护的字典,实有 7 项。而团期人员配置位只认死了导游位与摄影位两个、成员也写死在 Java 字面量里,要让研学老师也能配进导游位就得改代码发版。本次把这件事交还给业务。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期人员候选列表 | GET | `/v3/admin/group-batch/:groupBatchId/staff/candidates` | **候选池来源变更** | 成员由字典决定;`582113` 文案变更 |
| 2 | 全量保存团期人员配置 | PUT | `/v3/admin/group-batch/:groupBatchId/staff` | **落库取值变更** | `staffRole` 落真实 `staffType` |
---
## 三、接口详情
### 1. 团期人员候选列表 `GET /v3/admin/group-batch/:groupBatchId/staff/candidates`
**VO**: `StaffCandidateRespVO`
#### 使用场景
「配置导游 / 配置摄影」弹窗打开时调用,渲染可选人员列表。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 产品侧团期 batchId |
| `role` | Query | String | ✅ | `GUIDE` / `PHOTOGRAPHER`,大小写敏感 | 配置位标识;未知值返回 `582113` |
#### 出参 `Result<List<StaffCandidateRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `staffId` | Long | 人员 ID,**未变** |
| `staffName` | String | 姓名,**未变** |
| `staffType` | String | 资源域人员类型,**未变** |
| `staffPhone` | String | 脱敏手机号,**未变** |
| `assigned` | Boolean | 是否已选,**未变** |
> **出参结构完全没有变化**,变的是这个列表里会出现哪些人——由字典 `group_batch_staff_slot_*` 决定。
#### 请求示例
```http
GET /v3/admin/group-batch/990707900901/staff/candidates?role=GUIDE
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{
"staffId": 1002,
"staffName": "李雪梅",
"staffType": "GUIDE",
"staffPhone": "138****1002",
"assigned": false
},
{
"staffId": 1005,
"staffName": "刘大山",
"staffType": "LEADER",
"staffPhone": "138****1005",
"assigned": false
}
]
}
```
#### 空数据 / 降级响应
字典读空或字典服务不可用时**不报错**,回落到内置默认值(导游位 `GUIDE`+`LEADER`,摄影位 `PHOTOGRAPHER`)并打 WARN,候选列表照常返回。资源域无可用人员时返回空数组:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": []
}
```
#### 错误响应
配置位标识未知(含小写 `guide`、`DRIVER`):
```json
{
"code": 582113,
"message": "人员配置位不合法,请检查配置位标识",
"success": false,
"data": null
}
```
#### 业务边界
- 配置位成员来自字典,改字典后各实例最多 5 分钟内生效(本地缓存有界 TTL)
- 字典里写了取值域外的人员类型会被忽略并 WARN,其余行照常生效
- 字典整体读不到时回落内置默认值,行为与改前一致
- `role` 大小写敏感,小写不认(沿用既有口径)
- 司机不设配置位,由车务派车投影产生
### 2. 全量保存团期人员配置 `PUT /v3/admin/group-batch/:groupBatchId/staff`
**VO**: `BatchStaffConfigReqVO`
#### 使用场景
「配置导游 / 配置摄影」弹窗点保存时调用,全量覆盖该团期人员配置。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 产品侧团期 batchId |
| `staffList` | Body | Array | ❌ | 传空则清空 | 全量覆盖 |
| `staffList[].staffId` | Body | Long | ✅ | — | 资源域人员 ID |
| `staffList[].staffRole` | Body | String | ✅ | 取值域本次扩至 8 项 | 见下方取值域说明 |
| `staffList[].sortOrder` | Body | Integer | ❌ | 缺省 0 | 展示排序 |
#### 出参 `Result<BatchStaffConfigRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `staffList` | Array | 落库后的人员快照;其中 `staffRole` **取值口径变更** |
| `affectedOrderCount` | Integer | 扇出到的活跃子订单数,**未变** |
#### 请求示例
```json
{
"staffList": [
{
"staffId": 1005,
"staffRole": "GUIDE",
"sortOrder": 0
}
]
}
```
#### 响应示例
请求传的是配置位名 `GUIDE`,但 1005 在资源域是领队,出参与落库都归一为 `LEADER`:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"staffList": [
{
"staffId": 1005,
"staffRole": "LEADER",
"staffName": "刘大山",
"staffPhone": "138****1005",
"sortOrder": 0
}
],
"affectedOrderCount": 1
}
}
```
#### 空数据 / 降级响应
`staffList` 传空数组即清空该团期全部人员配置:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"staffList": [],
"affectedOrderCount": 1
}
}
```
#### 错误响应
`staffRole` 不在取值域内:
```json
{
"code": 400,
"message": "员工角色不在取值域内,取值域对齐 SettlementStaffRoleEnum 与 staff_role 字典",
"success": false,
"data": null
}
```
#### 业务边界
- 落库 `staffRole` 只在**配置位内部**归一:请求角色能解析到配置位、且该人员真实类型也在这个配置位成员里时才替换
- 跨位不匹配(领队被配到摄影位)沿用入参,本次不做拒绝
- `DRIVER` / `OTHER` 不属于任何配置位,沿用入参
- 资源域反查不到人员类型时沿用入参
- 导游位有人(`GUIDE` 或 `LEADER`)即置 `guide_ready`,与改前一致
---
## 四、契约约束与正确调用方式
- **候选池成员不再由代码保证**,前端不要硬编码「导游位只会出现导游和领队」;以接口返回为准。
- **`staffRole` 落库值可能与提交值不同**:提交配置位名,落库是该人员的真实人员类型。前端若需回显角色,读出参里的 `staffRole`,不要沿用自己提交的值。
- **`staffRole` 取值域本次由 5 项扩至 8 项**:新增 `GUIDE_ASSISTANT` / `STUDY_TEACHER` / `LIFE_TEACHER`。
- **改字典不是立即全局生效**:各实例本地缓存 5 分钟有界 TTL,期间不同实例口径可能不同,只影响候选池多列/少列一类人员。
---
## 五、数据库行为
无 DDL、无 Flyway 迁移。**但需手工执行一个字典初始化脚本**:
```
sql/dict_group_batch_staff_slot.sql 新增,幂等,可重复执行
```
| 表 | 变化 |
|---|---|
| `sys_dict_type` | 新增 2 行:`group_batch_staff_slot_guide`(8071) / `group_batch_staff_slot_photographer`(8072) |
| `sys_dict_data` | 新增 3 行配置位成员;`staff_role` 补 `STUDY_TEACHER` / `LIFE_TEACHER` 两行 |
| `order_batch_staff.staff_role` | 结构未变;**新写入的取值口径变了**,存量行不动 |
> `dict_type_id` 取 8071/8072 而非号段顺序的 8021/8022——后者在本机与测试环境**都已被** `contract_status` / `contract_platform` 占用(两处实测确认)。
---
## 六、边界行为
- 字典未初始化 → 回落内置默认值,行为与改前完全一致
- 字典含非法人员类型 → 忽略该行 + WARN,其余生效
- 字典全部非法 → 视同读空,回落默认值
- 字典行 `dictValue` 为空或空白 → 直接跳过,不刷告警
- Feign 返回 null / 失败 / 抛异常 → 三种都回落默认值,不把异常透给调用方
- `role` 小写 / 未知 → `582113`
## 六.5、枚举 / 数据字典
**新增字典类型 2 个**:`group_batch_staff_slot_guide`、`group_batch_staff_slot_photographer`。取值域 = 资源域 `staff_type`。
**`staff_role` 字典扩至 8 项**,与 `SettlementStaffRoleEnum` 和接口 `@Pattern` 三处对齐(测试环境实测三处完全一致)。
## 六.6、修改前后对比
| 项 | 变更前 | 变更后 |
|---|---|---|
| 配置位成员 | 写死在 3 处 Java 字面量 | 由字典决定,加一行即生效 |
| 扩一种人员类型 | 改代码 + 发版 | 改字典,最多 5 分钟生效 |
| 落库 `staffRole` | 前端传的配置位名 | 该人员真实 `staffType` |
| `staffRole` 取值域 | 5 项 | 8 项 |
| `582113` 文案 | 「只支持导游位与摄影位」 | 「请检查配置位标识」 |
| 字典不可用 | — | 回落内置默认值,不中断 |
| 路径 / 入参 / 出参结构 | — | **全部不变** |
## 六.7、影响评估
| 维度 | 评估 |
|---|---|
| 兼容性 | 字典未初始化时回落默认值,与改前**完全一致**,不会坏 |
| 前端 | **无需改动**。路径、入参、出参结构均未变;但不要再硬编码候选池成员,也不要沿用自己提交的 `staffRole` 回显 |
| 数据 | 无 DDL;需手工执行一个幂等字典脚本;存量 `staff_role` 行不动 |
| 性能 | 每个配置位一次字典 Feign,5 分钟缓存;候选池按类型逐类拉取,类型多一种多一次资源域调用 |
| 回滚 | `git revert`;字典行留着无害(回滚后代码不读它) |
| 风险 | 中低。主要风险是下游若有按 `staff_role == 'GUIDE'` 硬比对处会受落库取值变化影响;order-v3 内已全部改为按配置位归组 |
## 七、不影响范围
- **零影响**:两个接口的路径、HTTP 方法、入参字段、出参结构
- **零影响**:摄影位口径,字典默认仍只收 `PHOTOGRAPHER`
- **零影响**:staff-fees 费用录入分流——只加枚举取值域,未动 `SettlementStaffFeeDetailCodec` 的 `GUIDE`/`PHOTOGRAPHER` 两族判定
- **零影响**:存量 `order_batch_staff` / `order_staff_assignment` 数据
- **零影响**:`DRIVER` / `OTHER` 角色的处理
- **未新建端点**
---
## 八、测试环境已验证
✅ 2026-09-04 于测试环境网关实测,真实鉴权(管理端 `test_admin`,角色定制师)。
- 网关 `https://api.test.1814.love:9443`,分支 `dev-v3`,合并提交 `d1b724d59`
- 部署方式:双实例滚动更新(8186 → 8086),各 10s 就绪,零停机
- 部署内容经部署面板 `/api/git/backend` 交叉核对:构建时点 `d1b724d59` 已是 `dev-v3` 顶端
- 字典初始化脚本已在测试环境执行,幂等
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| 1 | 导游位候选(字典 GUIDE+LEADER) | 与改前一致 | ✅ 7 人 |
| 2 | 摄影位候选 | 口径不变 | ✅ 1 人 |
| 3 | `role=DRIVER` | `582113` 新文案 | ✅ 「请检查配置位标识」 |
| 4 | 导游位选领队(提交 `GUIDE`) | 落库真实类型 | ✅ 出参与落库均为 `LEADER` |
| 5 | 同场景 `guide_ready` | 仍置位 | ✅ 1 |
| 6 | `staff_role` 取值域三处对齐 | 字典 = 枚举 = `@Pattern` | ✅ 8 项完全一致 |
| 7 | **字典加一行 `GUIDE_ASSISTANT`** | 无需发版即生效 | ✅ **301 秒后候选 7 → 8 人,陈小燕出现;两个实例连打 8 次均为 8** |
**观察**:双实例持续 running,服务日志最近 200 行 `ERROR` / `Exception` 命中 **0** 条;配置位字典相关 WARN **0** 条,说明字典读取正常、未走降级。
用例 7 是本单的核心证据:15:30:33 往字典插一行,15:35:44(301 秒后)候选列表由 7 人变 8 人——**没有改代码、没有重启服务、没有重新部署**。延迟符合 5 分钟有界 TTL 的设计;随后连打 8 次全部返回 8 人,两个实例都已回源。演示用的那一行验证后已撤除,出厂默认仍是 `GUIDE` + `LEADER`。
**本地单测**:**431 例全过**。新增 `GroupBatchStaffSlotResolverTest` 14 例(正常读取、加行即生效、去重、TTL 命中缓存、读空/Feign null/Feign 异常三种降级、降级后判定仍工作、非法值过滤、全非法回落、空白值跳过、未知角色无配置位、枚举大小写敏感);`GroupBatchStaffConfigServiceTest` 新增 3 例覆盖落库取值三个分支。
**测试数据**:一律 `T7079-` 前缀,验证后物理删除。`test_admin` 口令一次性置换,取到令牌后立即按备份还原,逐字节一致;共享库写入全程持 `test-db` 租约。
---
## 九、相关历史 PR
- #7065(Refs #7063 第 1 波)本 PR 承接它,`StaffSlotRoles` 由事实源退化为兜底默认值
- 裁决来源:#7063 的 D-1 / D-3 / D-4(jw 2026-09-04)
---
## 十、相关文档
- 团期需求文档:`docs/group/`(dev-v3 分支)
- 字典初始化脚本:`sql/dict_group_batch_staff_slot.sql`
---
## 关联 / 联系人
- **Issue**: #7079
- **PR**: #7085(合并提交 `d1b724d59`)
+204 -7
查看文件
@@ -30,6 +30,7 @@ const REQUIRED_KEYS = [
'ticket',
'title',
'consumer',
'author',
'change_type',
'backend_status',
'gateway_status',
@@ -38,9 +39,31 @@ const REQUIRED_KEYS = [
'frontend_ref',
'target_release',
'verified_at',
'status_note',
'updated_at',
'base',
];
const HTTP_METHOD_PATTERN = '(?:GET|POST|PUT|PATCH|DELETE)';
const API_TEMPLATE_SECTION_PREFIXES = [
'二、变更接口清单',
'三、接口详情',
'四、契约约束与正确调用方式',
'六、边界行为',
'七、不影响范围',
'八、测试环境已验证',
'十、相关文档',
'关联 / 联系人',
];
const API_DETAIL_SUBSECTIONS = [
'使用场景',
'入参',
'出参',
'请求示例',
'响应示例',
'空数据 / 降级响应',
'错误响应',
'业务边界',
];
function ruleError(code, file, message) {
return { code, path: file, message };
@@ -68,6 +91,181 @@ function isIsoDateOrTime(value) {
&& Number.isFinite(Date.parse(value));
}
function sectionByPrefix(body, prefix, level = 2) {
const marker = `${'#'.repeat(level)} ${prefix}`;
const lines = String(body ?? '').replaceAll('\r\n', '\n').split('\n');
const start = lines.findIndex((line) => line.trim().startsWith(marker));
if (start < 0) {
return undefined;
}
const nextMarker = '#'.repeat(level);
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
const value = lines[index].trim();
if (value.startsWith(`${nextMarker} `) && !value.startsWith(`${nextMarker}#`)) {
end = index;
break;
}
}
return lines.slice(start + 1, end).join('\n');
}
function apiEndpointKey(method, endpointPath) {
return `${method.trim().toUpperCase()} ${endpointPath.trim()}`;
}
function validateApiTemplate(file, metadata, body) {
const errors = [];
const missingSections = API_TEMPLATE_SECTION_PREFIXES.filter(
(prefix) => sectionByPrefix(body, prefix) === undefined,
);
if (missingSections.length > 0) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
`接口类正文必须仿照 CHANGELOG_TEMPLATE.md,缺少章节: ${missingSections.join('、')}`,
));
}
const listSection = sectionByPrefix(body, '二、变更接口清单');
const detailSection = sectionByPrefix(body, '三、接口详情');
if (listSection === undefined || detailSection === undefined) {
return errors;
}
const requiredHeader = /^\|\s*#\s*\|\s*接口\s*\|\s*方法\s*\|\s*路径\s*\|\s*变更类型\s*\|\s*说明\s*\|\s*$/m;
if (!requiredHeader.test(listSection)) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'“二、变更接口清单”必须使用模板列: #、接口、方法、路径、变更类型、说明',
));
}
const listPattern = new RegExp(
'^\\|\\s*\\d+\\s*\\|\\s*[^|]+\\|\\s*('
+ HTTP_METHOD_PATTERN
+ ')\\s*\\|\\s*`([^`]+)`\\s*\\|\\s*[^|]+\\|\\s*[^|]+\\|\\s*$',
'gm',
);
const listed = [...listSection.matchAll(listPattern)]
.map((match) => apiEndpointKey(match[1], match[2]));
if (listed.length === 0) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'“二、变更接口清单”至少需要一条带 METHOD 和反引号路径的接口记录',
));
}
const detailPattern = new RegExp(
'^###\\s+\\d+\\.\\s+.+?\\s+`('
+ HTTP_METHOD_PATTERN
+ ')\\s+([^`]+)`\\s*$',
'gm',
);
const detailMatches = [...detailSection.matchAll(detailPattern)];
const detailed = detailMatches.map((match) => apiEndpointKey(match[1], match[2]));
if (detailMatches.length === 0) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'“三、接口详情”至少需要一个“### N. 接口名 `METHOD /path`”子节',
));
}
const listedSet = [...new Set(listed)].sort();
const detailedSet = [...new Set(detailed)].sort();
if (
listed.length !== listedSet.length
|| detailed.length !== detailedSet.length
|| listedSet.join('\n') !== detailedSet.join('\n')
) {
errors.push(ruleError(
'E_API_ENDPOINTS',
file,
'接口清单与逐接口详情的 METHOD/path 必须去重且一一对应',
));
}
for (let index = 0; index < detailMatches.length; index += 1) {
const match = detailMatches[index];
const next = detailMatches[index + 1];
const block = detailSection.slice(
match.index + match[0].length,
next ? next.index : detailSection.length,
);
const missing = API_DETAIL_SUBSECTIONS.filter(
(prefix) => sectionByPrefix(block, prefix, 4) === undefined,
);
const usage = sectionByPrefix(block, '使用场景', 4) ?? '';
const input = sectionByPrefix(block, '入参', 4) ?? '';
const output = sectionByPrefix(block, '出参', 4) ?? '';
const requestExample = sectionByPrefix(block, '请求示例', 4) ?? '';
const responseExample = sectionByPrefix(block, '响应示例', 4) ?? '';
const emptyResponse = sectionByPrefix(block, '空数据 / 降级响应', 4) ?? '';
const errorExample = sectionByPrefix(block, '错误响应', 4) ?? '';
const boundary = sectionByPrefix(block, '业务边界', 4) ?? '';
if (!/^\*\*VO\*\*:\s*`[^`]+`/m.test(block)) {
missing.push('VO 契约');
}
if (!usage.trim()) {
missing.push('使用场景说明');
}
if (!/^\|\s*字段\s*\|\s*位置\s*\|\s*类型\s*\|\s*必填\s*\|\s*约束\s*\|\s*说明\s*\|\s*$/m.test(input)) {
missing.push('入参字段表');
}
if (!/^\|\s*字段\s*\|\s*类型\s*\|\s*说明\s*\|\s*$/m.test(output)) {
missing.push('出参字段表');
}
if (!/```(?:json|http)\s*\n[\s\S]+?\n```/.test(requestExample)) {
missing.push('请求示例代码块');
}
if (!/```json\s*\n[\s\S]+?\n```/.test(responseExample)) {
missing.push('响应示例 JSON');
}
if (!emptyResponse.trim()) {
missing.push('空数据 / 降级说明');
}
if (!/```json\s*\n[\s\S]+?\n```/.test(errorExample)) {
missing.push('错误响应 JSON');
}
if (!/^\s*[-*]\s+\S/m.test(boundary)) {
missing.push('业务边界条目');
}
if (missing.length > 0) {
errors.push(ruleError(
'E_API_DETAIL',
file,
`${detailed[index]} 缺少逐接口自包含内容: ${[...new Set(missing)].join('、')}`,
));
}
}
const writeMethods = listed.some((entry) => /^(?:POST|PUT|PATCH|DELETE) /.test(entry));
if (writeMethods && sectionByPrefix(body, '五、数据库行为') === undefined) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'包含写接口时必须按模板提供“五、数据库行为”章节(只写外部可观察行为,不泄露表结构)',
));
}
if (
['修改接口', '删除接口'].includes(metadata.change_type)
&& (
sectionByPrefix(body, '六.6、修改前后对比') === undefined
|| sectionByPrefix(body, '六.7、影响评估') === undefined
)
) {
errors.push(ruleError(
'E_API_TEMPLATE',
file,
'修改/删除接口必须提供“六.6、修改前后对比”和“六.7、影响评估”章节',
));
}
return errors;
}
export function parseFrontmatter(text) {
const value = String(text ?? '').replaceAll('\r\n', '\n');
if (!value.startsWith('---\n')) {
@@ -165,11 +363,14 @@ export function validateV2Document(file, text, { requireV2 = false } = {}) {
errors.push(ruleError('E_REQUIRED', file, `frontmatter 缺少 ${key}`));
}
}
for (const key of ['ticket', 'title', 'consumer', 'change_type', 'backend_status', 'gateway_status', 'frontend_status', 'updated_at', 'base']) {
for (const key of ['ticket', 'title', 'consumer', 'author', 'change_type', 'backend_status', 'gateway_status', 'frontend_status', 'updated_at', 'base']) {
if (!metadata[key]?.trim()) {
errors.push(ruleError('E_REQUIRED', file, `${key} 不能为空`));
}
}
if (metadata.author && !/^\S+\(GIT\)$/.test(metadata.author)) {
errors.push(ruleError('E_AUTHOR', file, 'author 必须使用“登录名(GIT)”格式'));
}
if (!CHANGE_TYPES.has(metadata.change_type)) {
errors.push(ruleError('E_CHANGE_TYPE', file, `change_type 非法: ${metadata.change_type || '(空)'}`));
}
@@ -213,13 +414,9 @@ export function validateV2Document(file, text, { requireV2 = false } = {}) {
if (/\{\{[^{}\n]+\}\}|\bTODO\b|待补充/i.test(body)) {
errors.push(ruleError('E_PLACEHOLDER', file, '正文仍有 TODO、待补充或模板占位符'));
}
// 接口类条目必须有接口清单章节与验证章节;修复/前端类条目结构自由,不强制
// 接口类条目必须逐项遵循根模板,保证前端不依赖 Swagger 或口头补充也能联调。
if (API_CHANGE_TYPES.has(metadata.change_type)) {
const hasApiSection = /^##[^\n]*(变更接口|变更清单|变更内容|接口详情|变更点|接口变化|行为变化)/m.test(body);
const hasEvidenceSection = /^##[^\n]*(验证|测试|复现|证据)/m.test(body);
if (!hasApiSection || !hasEvidenceSection) {
errors.push(ruleError('E_SECTIONS', file, '接口类正文缺少接口清单(变更接口/变更清单)或验证(验证证据/测试)章节'));
}
errors.push(...validateApiTemplate(file, metadata, body));
}
return errors;
}
+90 -2
查看文件
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import test from 'node:test';
@@ -21,6 +21,7 @@ function metadata(overrides = {}) {
ticket: '5218',
title: '工作流治理',
consumer: 'admin',
author: 'lc(GIT)',
change_type: '修改接口',
backend_status: 'deployed',
gateway_status: 'verified',
@@ -41,7 +42,15 @@ function document(overrides = {}) {
const frontmatter = Object.entries(fields)
.map(([key, value]) => `${key}: "${value}"`)
.join('\n');
return `---\n${frontmatter}\n---\n\n# 工作流治理\n\n## 变更接口\n\n- 无业务接口变化。\n\n## 验证证据\n\n- 自动化测试通过。\n`;
return `---\n${frontmatter}\n---\n\n# 工作流治理\n\n## 二、变更接口清单\n\n| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |\n|---|---|---|---|---|---|\n| 1 | 保存配置 | POST | \`/admin/workflow/config\` | 修改请求 | 保存工作流配置 |\n\n## 三、接口详情\n\n### 1. 保存配置 \`POST /admin/workflow/config\`\n\n**VO**: \`WorkflowConfigReqVO / String\`\n\n#### 使用场景\n\n- 管理员保存工作流配置。\n\n#### 入参\n\n| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |\n|---|---|---|---|---|---|\n| name | Body | String | ✅ | 非空 | 配置名称 |\n\n#### 出参 \`Result<String>\`\n\n| 字段 | 类型 | 说明 |\n|---|---|---|\n| data | String | 配置 ID |\n\n#### 请求示例\n\n\`\`\`json\n{ "name": "审批流" }\n\`\`\`\n\n#### 响应示例\n\n\`\`\`json\n{ "code": 200, "message": "成功", "data": "1", "success": true }\n\`\`\`\n\n#### 空数据 / 降级响应\n\n成功时一定返回字符串 ID。\n\n#### 错误响应\n\n\`\`\`json\n{ "code": 400, "message": "name 不能为空", "data": null, "success": false }\n\`\`\`\n\n#### 业务边界\n\n- 业务失败也必须检查响应体 code。\n\n## 四、契约约束与正确调用方式\n\n- 请求体必须使用 JSON。\n\n## 五、数据库行为\n\n- 成功请求保存一份配置,失败请求不产生业务写入。\n\n## 六、边界行为\n\n- 未登录返回 401。\n\n## 六.6、修改前后对比\n\n| 字段 | 改前 | 改后 |\n|---|---|---|\n| name | 可为空 | 必填 |\n\n## 六.7、影响评估\n\n- **是否破坏向后兼容**: 否\n- **前端是否必须同步上线**: 是\n- **前端 workaround 清理点**: 无\n\n## 七、不影响范围\n\n- 查询接口不变。\n\n## 八、测试环境已验证\n\n- POST /admin/workflow/config → 200 ✓\n\n## 十、相关文档\n\n- Issue: #5218\n\n## 关联 / 联系人\n\n- **后端负责人**: @lc\n`;
}
function incompleteApiDocument() {
const fields = metadata();
const frontmatter = Object.entries(fields)
.map(([key, value]) => `${key}: "${value}"`)
.join('\n');
return `---\n${frontmatter}\n---\n\n# 工作流治理\n\n## 变更接口\n\n- POST /admin/workflow/config。\n\n## 验证证据\n\n- 自动化测试通过。\n`;
}
test('parses quoted flat YAML frontmatter', () => {
@@ -50,10 +59,89 @@ test('parses quoted flat YAML frontmatter', () => {
assert.equal(parsed.metadata.frontend_status, 'pending');
});
test('CHANGELOG_TEMPLATE.md contains every section enforced for API handoffs', () => {
const template = readFileSync(
new URL('../CHANGELOG_TEMPLATE.md', import.meta.url),
'utf8',
);
for (const value of [
'author: "{推送者登录名}(GIT)"',
'## 二、变更接口清单',
'## 三、接口详情',
'#### 使用场景',
'#### 入参',
'#### 出参',
'#### 请求示例',
'#### 响应示例',
'#### 空数据 / 降级响应',
'#### 错误响应',
'#### 业务边界',
'## 四、契约约束与正确调用方式',
'## 五、数据库行为',
'## 六、边界行为',
'## 六.6、修改前后对比',
'## 六.7、影响评估',
'## 七、不影响范围',
'## 八、测试环境已验证',
'## 十、相关文档',
'## 关联 / 联系人',
]) {
assert.ok(template.includes(value), `template missing ${value}`);
}
});
test('accepts a complete v2 handoff with pending frontend consumption', () => {
assert.deepEqual(validateV2Document(FILE, document(), { requireV2: true }), []);
});
test('rejects an API handoff that does not follow CHANGELOG_TEMPLATE.md', () => {
const errors = validateV2Document(FILE, incompleteApiDocument(), { requireV2: true });
assert.ok(errors.some(({ code }) => code === 'E_API_TEMPLATE'));
});
test('rejects an endpoint detail without a required self-contained contract block', () => {
const value = document().replace('#### 错误响应', '#### 异常说明');
const errors = validateV2Document(FILE, value, { requireV2: true });
assert.ok(errors.some(({ code }) => code === 'E_API_DETAIL'));
});
test('requires a use case and explicit empty/degraded behavior for every endpoint', () => {
const value = document()
.replace('#### 使用场景', '#### 调用时机')
.replace('#### 空数据 / 降级响应', '#### 无数据');
const errors = validateV2Document(FILE, value, { requireV2: true });
assert.ok(errors.some(({ code, message }) => (
code === 'E_API_DETAIL'
&& message.includes('使用场景')
&& message.includes('空数据 / 降级响应')
)));
});
test('rejects a mismatch between the API list and endpoint detail headings', () => {
const value = document().replace(
'### 1. 保存配置 `POST /admin/workflow/config`',
'### 1. 保存配置 `POST /admin/workflow/other`',
);
const errors = validateV2Document(FILE, value, { requireV2: true });
assert.ok(errors.some(({ code }) => code === 'E_API_ENDPOINTS'));
});
test('requires the template author format and write-operation database section', () => {
const invalidAuthor = validateV2Document(
FILE,
document({ author: 'lc' }),
{ requireV2: true },
);
assert.ok(invalidAuthor.some(({ code }) => code === 'E_AUTHOR'));
const withoutDatabaseBehavior = document().replace(
'## 五、数据库行为',
'## 五、持久化说明',
);
const errors = validateV2Document(FILE, withoutDatabaseBehavior, { requireV2: true });
assert.ok(errors.some(({ code }) => code === 'E_API_TEMPLATE'));
});
test('rejects backend and gateway pending at publication', () => {
const errors = validateV2Document(
FILE,