比较提交

...
260 次代码提交
作者 SHA1 备注 提交日期
API Changelog Bot和Claude Fable 5.1 c5b40bb83e docs: 答复前端 #7324 后端需求(后端零改动)+ 补 #7322 我的团不过滤 batch_status
changelog-filename-gate / validate (push) Successful in 2s
前端据 H1 看板过滤 CANCELLED 的行为,推断「整团释放」按钮无可达路径。
该推断的关键环节不成立:我的团(GET /v3/admin/order/grab-pool/my-claims/
group-batches)刻意不按 batch_status 过滤,H2 详情也没有阶段闸门,
CANCELLED 团期经「我的团 → H2 详情」完全可达。

根因在我方:#7322 的 changelog 从未写过这条行为,前端看不到 javadoc。
本次一并补记,并在需求单上答复 status=answered-no-backend-change。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 10:39:30 +08:00
API Changelog Bot和Claude Fable 5.1 65d786ea87 docs(changelog): #7325 团期房务按日订房确认 H7/H8/confirm-check(已部署实测 dfd5a8f6f)
3 个新端点、8 个新错误码、808616 删除、808602 日期上限改为 endDate-1。
已部署测试服并经网关实测,Flyway V20260910_302 success=1。

起草时「GET /confirm-check 任何后台角色都能调」写错,已更正:
读端点虽不标 @HouseWriteGuarded,但在编排层入口直接调
HouseReadGuard.assertHouseReadPermission(),非房务角色同样 808090。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 10:37:49 +08:00
Mimingguang 559c8da1c1 docs(changelog): #7324 status_note 补后端需求文档交叉引用(4aa3037)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-11 10:06:50 +08:00
Mimingguang 4aa3037c3c docs(backend-request): #7324 放开 H1 看板 batchStatus 对 CANCELLED 的过滤
changelog-filename-gate / validate (push) Successful in 2s
前端 #7324 已交付整团释放写口(commit 6710ad4a),但 H1 列表忽略 CANCELLED
过滤导致已流团团期进不了看板、释放按钮无可达路径。写明需求/现状/期望/
安全性供后端评估排期:放开 H1 对 CANCELLED 过滤(推荐方案 A),前端零改动等待。
2026-09-11 10:06:06 +08:00
Mimingguang 1e52ba5d26 chore(changelog): #7328 frontmatter 回写 verified(0f2f5b1a)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-11 10:01:17 +08:00
Mimingguang b0c68caa15 chore(changelog): #7324 frontmatter 回写 verified(6710ad4a)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-11 09:23:16 +08:00
Mimingguang cad8452b92 chore(changelog): #7387 前端交付 verified(ref 620754fc)
changelog-filename-gate / validate (push) Failing after 3s
2026-09-10 22:35:45 +08:00
lc 95d1789793 docs: 交接餐厅供应商关系契约(#7387)
changelog-filename-gate / validate (push) Successful in 3s
2026-09-10 22:18:14 +08:00
API Changelog Bot 70dbb833e0 docs(changelog): #7328 团期房务整团会话 GROUP_HOUSE(三端点)
changelog-filename-gate / validate (push) Successful in 2s
会话键是 GROUP_HOUSE:{groupBatchId},按团期聚合主键建键而不是子订单 id——
这是与既有 GROUP:{orderId} 的关键差异,前端拿错主键空间会静默查空
(不报错、只是永远没数据)。

三处前端按直觉写会踩的地方,都写进「关键变化」:

1. 团未被认领时的不对称:管理员侧能开会话(消息落团队池,认领后自动改判
   给认领人),房务侧开不了(281002)。

2. peerAdminId 在两个接口上语义不同——这条是部署实测才发现的。
   open-group-house 的响应直接回显 DB 占位值 0(ChatManager.buildFullResp
   无归一化);把「无认领人 → null」做归一化的是会话列表那条路
   (fillOrderSummaries + fillGroupBatchSummaries),而且只对有消息的会话
   生效——selectMyConversations 用 isNotNull(last_message_at) 把无消息的
   占位会话过滤掉了。前端照单接口响应判 null 会渲染出「管理员 #0」。

3. currentClaimerId 刻意没有 @NotNull:RELEASE 事件本来就没有新认领人,
   加了会让释放事件在参数校验阶段被拒、会话永远停在旧认领人身上。

顺带订正一条长期口口相传的错误说法:网关对 /internal/** 并非「一律 403」。
gateway 只给 /v3/internal/** 与 /internal/fleet/** 配了 Path predicate,
/internal/message/** 与 /internal/house/** 根本没有路由,请求在到达
JwtAuthFilter 之前就被路由层拒掉,返回的是 HTTP 200 + 业务体
{"code":404,"message":"接口不存在"}。净效果一致(网关不可达),呈现不同。

backend_status=deployed:两服务已滚到 dev-v3 tip 0f62fb072(PR #7490),
实例 LISTEN + Nacos healthy,三端点各打通,281002 负向用例四个角色全命中,
落库 conversation_key 已用 SQL 证实,测试数据已清理。

Refs #7328
2026-09-10 21:59:00 +08:00
API Changelog Bot d9f30eb9bf docs(changelog): #7324 订正 H1 住期筛选的区间口径(右开 → 两端闭)
changelog-filename-gate / validate (push) Successful in 3s
原文把 stayDateFrom/To 的匹配范围写成团期 `[出发日,结束日)`,右端开区间是错的。
实际谓词是 `depart_date <= stayDateTo AND end_date >= stayDateFrom`
(GroupBatchMapper.selectHouseClaimedPage:779-791),两端都闭。
实测:团期 end_date=2026-07-05 配 stayDateFrom=2026-07-05 命中。
前端照右开写日期选择器会漏掉最后一天。

同一份文档里 H4/H5 校验入住日合法性用的 `[出发日,结束日)` 是**对的**、不动
(assertStayDateInRange:568-575,`!stayDate.isBefore(endDate)`,
结束日是离店日不产生间夜)。两处口径本就不同,已在表格里写明区别,避免混用。

Refs #7324
2026-09-10 19:12:28 +08:00
API Changelog Bot fc6f223419 docs(changelog): #7324 房务团期看板与整团按日订房计划 CRUD(H1-H6 + release-all)
changelog-filename-gate / validate (push) Successful in 2s
七个新端点的前端交接件,重点写清四处「后端行为对、前端按直觉写会踩坑」的地方:

1. H1 列表 total 可能是 -1,语义是「未统计」——传 planStatus 且扫描页(500)被填满时
   走内存过滤降级路径。全仓其它分页都保证 total >= 0,直接拿它算总页数会出负页码。
2. H5 只传 version 的空提交:PENDING 行返 200 且只把 version +1(updateById 默认
   NOT_NULL 策略,null 不进 SET 子句,不清空任何业务字段),CONFIRMED 行抛 808692。
3. release-all 会返回「200 但什么都没释放」——整团都是过去的已确认行时,
   releasedPlanIds 为空、retainedPastPlanIds 非空、warnings=[PAST_STAY_RETAINED],
   与「团里根本没订房」的 808619 幂等出口是两条不同分支。
4. H4 响应恒带两个空数组 warnings / removedManualAllocIds(与 H5 共用 RespVO)。

另记两条与工单口径不一致、以源码为准的事实:
- 错误码实际新增 17 个(工单写 12 个),已按 HouseGroupBatchErrorCode 逐个列出。
- 部署顺序:hl-resource-service 必须先于 hl-order-service-v3,反序会让 order-v3 拿到
  roomCategory=null,fail-closed 房型守卫把每次 H4/H5 提交拒成 808691。

Refs #7324
2026-09-10 18:50:32 +08:00
Mimingguang 1f48714187 chore(changelog): #7456 前端 no-op 回写 not_required
changelog-filename-gate / validate (push) Failing after 2s
2026-09-10 18:21:26 +08:00
jw 6c62b1aa2b docs(changelog): #7456 团期生命周期后半段——核团开工自动推进 + 验团归档 + 反确认
changelog-filename-gate / validate (push) Successful in 2s
2026-09-10 18:10:23 +08:00
Mimingguang 6fee8114b8 chore(changelog): #7455 前端 no-op 回写 not_required
changelog-filename-gate / validate (push) Failing after 2s
2026-09-10 18:01:41 +08:00
jw 953d953bdc docs(changelog): #7455 团期十五个 admin 端点补判权(写口 manage / 详情名单 view / 列表看板 list)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-10 17:56:28 +08:00
Mimingguang 7ecd4da276 chore(changelog): #7322 前端交付 verified(ref 745cd9b8)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-10 17:19:23 +08:00
API Changelog Bot和Claude Fable 5.1 4a284f6323 docs(changelog): #7322 菜单种子已落地(PR #7467),补部署两条注意事项
changelog-filename-gate / validate (push) Successful in 3s
sys_menu 两行抢单池入口已由 V20260910_001 补上。
部署须知:#7322 部署清单本次起必须带 hl-user-service;
菜单树按 roleId 缓存 Redis(TTL 3600s),部署后需 DEL cache:menu:tree:role:*。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:06:07 +08:00
API Changelog Bot和Claude Fable 5.1 7b1601a7c2 docs(changelog): 补全 #7316 的订正——第四节仍有三处「无权限码限制」与已订正段落自相矛盾
changelog-filename-gate / validate (push) Successful in 2s
688c403 只订正了 :182 / :259 / :383 三处,同一文件里另有三处原样保留:
- :215 请求示例的 Authorization 头写「任意已登录管理端 token,无需 group-batch:view」
- :284 契约表行「✅ 任意已登录管理端角色调用」
- :288 「本端点无权限码限制」

其中 :284/:288 落在「四、契约约束与正确调用方式」——正是 mmg 据以实现判权与
589507 兜底的那一节,照它实现会完全不处理 589507,危害最大。

三处均已改为现行口径并标注订正日期,另补一条前端提醒:
FINANCE 角色持 finance:view 但不持 finance:advance,写端点按钮需单独控显隐。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 15:47:31 +08:00
API Changelog Bot和Claude Fable 5.1 d807fad55f docs(changelog): #7322 团期整团抢单与抢单池分流(前端契约交接件)
changelog-filename-gate / validate (push) Successful in 2s
五个新端点(抢单池列表/认领/释放/接管/我的团)+ 五个既有端点改造。
团单户不再进逐户抢单池,改以整团为单位认领;order_group_batch 新增
house_claimer_id / house_claimer_name / house_claimed_at 三列(Flyway V20260910_301 已执行)。

已部署测试服 HEAD 6d41d6148,32 条验收项中 28 条已取证,
含用 5 个真实登录账号过网关实测的归属与接管链路。

⚠️ 正文已注明一项尚缺的交付:sys_menu 的两行抢单池菜单入口尚未随迁移落库,
在补上之前新端点只能直接调用、后台菜单里点不到。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 15:43:36 +08:00
Mimingguang 5cdf062dd2 docs(changelog): #7232 frontmatter 回写 EXPENSE 付款字段勘误前端修复证据
changelog-filename-gate / validate (push) Failing after 2s
2026-09-10 15:42:42 +08:00
Mimingguang 41160bbfad docs(changelog): #7411 frontmatter 回写 not_required
changelog-filename-gate / validate (push) Failing after 3s
2026-09-10 15:32:32 +08:00
API Changelog Bot和Claude Fable 5.1 6cfa38b56d docs(changelog): #7411 团期核单共享成本三端点补判权(前端契约交接件)
changelog-filename-gate / validate (push) Successful in 2s
三端点权限码:
- GET  /v3/admin/order/group-batch/{groupBatchId}/settlement/summary → group-batch:finance:view
- GET  /v3/admin/order/group-batch/{groupBatchId}/settlement/cost    → group-batch:finance:view
- POST /v3/admin/order/group-batch/{groupBatchId}/settlement/cost    → group-batch:finance:advance

不持码返 589507,HTTP 恒 200,前端判 Result.code。
请求与响应结构一字未改,网关零改动(/v3/admin/** 已通配)。

已部署测试服 HEAD 6d41d6148,四档权限矩阵经真实登录态过网关实测,
无权限 POST 已用 SQL 前后对比证明零副作用。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 15:26:39 +08:00
yaosutu be7415b771 docs(finance): 更正 7232 changelog §10 出纳付款登记入参字段名勘误
changelog-filename-gate / validate (push) Failing after 2s
前端联调反馈 cashier/pay bizType=EXPENSE 报 400 出账账户不能为空/付款金额不能为空。
经测试服实调实证:后端 cashier/pay 为全 bizType 统一扁平入参,付款账户/金额字段名
固定为 payAccountId/amount(@NotNull 文案与前端 400 逐字一致),changelog §10 误把
输出字段名 fundAccountId/actualAmount 写进了请求入参表,前端据此调用触发校验失败。

更正内容(§10):
- 入参表 fundAccountId→payAccountId、actualAmount→amount,并补全量必填字段 payDate 等
- 变更点说明补「回写列名非入参字段」澄清
- 新增请求/成功响应示例(可直接照抄)
- 598602 错误文案对齐实测「业务单状态非已批准,不可付款」
- frontmatter status_note 追加勘误说明 + updated_at 升 2026-09-10

验证(测试服,FY-202609100001):
错误字段→复现 400;正确字段 payAccountId/amount→付款成功 LS202609100002、
余额 114894→114594、报销单回写 PAID+fundAccountId+paidAt、重复付款 598602 防重拦下。

Refs #7232
2026-09-10 15:19:23 +08:00
Mimingguang 4281aa8aca docs(changelog): #7390/#7347 frontmatter 回写 not_required
changelog-filename-gate / validate (push) Failing after 1s
2026-09-10 15:09:21 +08:00
API Changelog Bot和Claude Fable 5.1 688c403695 docs(changelog): #7347 finalize 团期住宿户级闸门 + #7390 孤儿持有行自动收敛;订正 #7316 的「无权限码是既有设计」
changelog-filename-gate / validate (push) Successful in 2s
#7347:整单核单 finalize 新增团期住宿户级硬闸门,新增错误码 584130。
存量统计 SQL 结果为 0,全量生效不做灰度。

#7390:双写扣减孤儿持有行自动收敛,解开重新提交配房恒抛 808902 的死锁。
显著标注「本单不修根因」——submitPersist 仍在 try 块外,孤儿行仍会继续产生。

#7316 订正三处:原文写「本端点无权限码要求……这是既有设计」,
那不是设计而是 #7411 要修的缺陷,已改写为发布时点的事实陈述并指向 #7411。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 14:56:19 +08:00
Mimingguang 3370e7a278 docs(changelog): #7389 frontmatter 回写 not_required
changelog-filename-gate / validate (push) Failing after 3s
2026-09-10 12:06:07 +08:00
API Changelog Bot和Claude Fable 5.1 9b67d6f4f0 docs(changelog): #7389 多夜配房释放改逐夜回补 + resource 库存账本告警码拆分
changelog-filename-gate / validate (push) Successful in 3s
PR #7423(合并提交 f5545b148)已合入 dev-v3,测试服 order-v3/resource-service 均已滚动部署。
本单无接口/字段/表结构变化,仅记录:1) 多夜配房释放由只补首夜改为逐夜回补;2) resource
房务库存账本内部一致性告警从共用 310404 拆分为 310417-310420 四个语义码。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 11:58:02 +08:00
Mimingguang b165e41337 docs(changelog): #7164/#7165/#7217/#7232 frontmatter 回写 verified
changelog-filename-gate / validate (push) Failing after 2s
2026-09-10 10:38:39 +08:00
Mimingguang 38a6cf5434 docs(changelog): #7323 frontmatter 回写 not_required
changelog-filename-gate / validate (push) Failing after 2s
2026-09-10 10:05:26 +08:00
API Changelog Bot 5eff130bf2 docs(changelog): #7323 团期房务地基——配房提交新增可选 replaceReason
changelog-filename-gate / validate (push) Successful in 2s
PR #7413(合并提交 1a3d3c43f)。本单为地基单,不新增/下线端点、网关零改动。
对前端唯一直接可见的变化是核心配房提交端点新增可选字段 items[].replaceReason,
语义为「仅记首次、不覆写」——重复提交同一配房不会更新已落库的原因文案。

另附一条实测发现的既有文档缺口:items[].arrange 实际会返回 inquiring,
而 Swagger 只列了 pending/waiting/confirmed/problem,提醒前端做兜底分支。

已部署测试服并经网关四步实测,DDL 已 SHOW CREATE TABLE 逐列比对零差异。
2026-09-10 09:40:34 +08:00
Mimingguang 92eb0956a8 docs(changelogs-v2): #7199 前端 not_required
changelog-filename-gate / validate (push) Failing after 3s
2026-09-10 09:27:08 +08:00
Mimingguang bdeaa623ed docs(changelogs-v2): #7378 前端 verified (hl-admin ref 4d016870)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-10 09:24:14 +08:00
API Changelog Bot 9ae0d869e4 docs(changelog): 修正 #7316 条目日前缀 08 -> 10(发布门禁 E_DAY)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-10 01:49:50 +08:00
API Changelog Bot cdc17540d5 docs(changelog): #7316 团期全团需求汇总补权限码与在团口径 + 结算汇总已核单数纠正
覆盖两个 PR:#7407(d951c0469)判权与 activeOrderCount 在团口径;
#7408(aafc6ae48)settledOrderCount 判据改用 settled 标志。

已部署测试服 aafc6ae48 并完成网关实测,backend_status=deployed、
gateway_status=verified 为真实态,第八节为实测证据。
2026-09-10 01:48:59 +08:00
yaosutu c74fc50926 docs(changelog): 供应商分页/列表出参补 contactPhone(#7199 / PR #7202)
changelog-filename-gate / validate (push) Failing after 2s
出参 SupplierListItemRespVO 新增 contactPhone(联系电话完整原值,管理后台授权场景)。
注:同 PR 曾新增 balance,已被 #7291 移除,changelog 内已显式标注勿消费。
2026-09-10 00:20:54 +08:00
Mimingguang 9d573b9a81 docs(changelogs-v2): #7400 前端 verified (hl-admin ref 02e17345)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-09 23:27:33 +08:00
yaosutu 19d434ef3b docs(finance): 补推期初建账/业务外收支/出纳/费用报销 changelog(#7164/#7165/#7217/#7232)
changelog-filename-gate / validate (push) Failing after 1s
2026-09-09 23:21:35 +08:00
lc 78fe5b7a99 docs(changelogs-v2): #7400 供应商解绑免原因
changelog-filename-gate / validate (push) Successful in 3s
2026-09-09 21:39:43 +08:00
Mimingguang b4ab37a9bc docs(changelogs-v2): #7377 前端回写 not_required
changelog-filename-gate / validate (push) Failing after 2s
保存人员配置 staffList 必填、备品候选 keyword 转义、加备品行定点查三端点
行为变更,前端实查零改动:saveGroupBatchStaff 请求体恒带 staffList,备品
keyword 原样透传,加行 500 上限解除对调用方透明。
2026-09-09 18:03:10 +08:00
jw c95ab182dc docs(changelog): #7378 团期行程逐日汇总与逐户下钻(GB-ADM-018/019,新增接口)
changelog-filename-gate / validate (push) Successful in 3s
2026-09-09 17:57:34 +08:00
Mimingguang d1e45d6d2e chore(changelog): 财务域 8 篇 verified (hl-admin ref 21540997)
changelog-filename-gate / validate (push) Failing after 3s
2026-09-09 17:54:39 +08:00
Mimingguang b99b783557 chore(changelog): #7003 资金账户 verified (hl-admin ref 49359412)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-09 17:02:38 +08:00
yaosutu和Claude Opus 4.8 5025f8d69b docs(changelog): 财务域 账期/费用分类/业务外分类/收款账户/资金流水/资金互转/资金日报 7 篇入 changelogs-v2
changelog-filename-gate / validate (push) Failing after 2s
- 09_6951_财务账期设置:page/开新账期/封账 3 端点
- 09_7129_费用分类:tree/增/改/停用/启用 5 端点(DISABLED 统一+幂等)
- 09_7052_业务外收支分类:查询/增/改/停用/启用 5 端点(自动生码 NONBIZ_)
- 09_7003_收款账户:page/增/改/设默认/停用 5 端点
- 09_7003_资金流水:page/详情 2 端点(只读,含 remark)
- 09_7003_资金互转:page/发起 2 端点(双边成对记账+幂等)
- 09_7003_资金日报:daily 1 端点(closing=opening+收入−支出勾稽)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 16:25:37 +08:00
jw和Claude Opus 5 2b45ee9d44 docs(changelog): 团期人员配置 staffList 必填 + 备品候选关键字转义 + 加备品行改定点查(#7377)
changelog-filename-gate / validate (push) Successful in 2s
收口 2026-09-03 团期人员/备品合并后审计的余项(F-03/F-05/F-06/F-07),随 PR #7028 合入 dev-v3。

两处对外行为变更需前端注意:
- PUT /v3/admin/group-batch/:productBatchId/staff 的 staffList 改为必填,
  字段缺失或名字写错一律 400;改前会被当成「传空=清空」,静音软删整团配置还返 200。
  「显式传 [] 即清空」这条语义不变。
- 备品候选列表的 keyword 转义 LIKE 通配符,搜 % 或 _ 由「返回全库」变为「零结果」。

第三处对调用方透明:加备品行改走资源域新增的定点查,解除「备品库超 500 条后
第 501 条起加不进」的隐性上限,契约与错误码不变。

后端已部署 TEST 并经真实网关逐条实测;order-v3 全量 9303 例、resource 全量 2707 例 0 失败。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 16:25:30 +08:00
yaosutu和Claude Opus 4.8 89be26b98b docs(changelog): 财务域 参数设置(#6951)+资金账户(#7003/#7141) 2 篇入 changelogs-v2
changelog-filename-gate / validate (push) Failing after 3s
- 09_6951_财务系统参数设置:GET/PUT /admin/finance/params 2 端点
- 09_7003_资金账户:10 端点全量(含 #7141 三字段字典化 accountTypeName/natureName/channelName + /options 下拉)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 16:16:00 +08:00
Mimingguang 9d8e1945f4 chore(changelog): 回写 #7304 前端已验证 verified(mmg/51ca6946)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-09 13:55:49 +08:00
jw和Claude Opus 5 b22e67ca69 docs(changelog): #7304 团期时间线只读端点 GB-ADM-096(新增接口,面向 mmg)
changelog-filename-gate / validate (push) Successful in 2s
group_batch_status_log 此前只写不可读:十余条链路持续写入,全仓零生产读回方,
运营在页面上一条也看不到——#7283 那条「同步失败靠时间线让运营看见并手动去产品
后台取消班期」的唯一可见兜底入口因此实际不可见。

新增 GET /v3/admin/order/group-batch/:groupBatchId/status-logs:按 changedAt 升序
全量返回,extra 由 JSON 字符串解析成结构化对象,事件类型与前后状态的中文名由后端
给出(前端不必自建映射表),权限同码 group-batch:view。

backend_status=deployed / gateway_status=verified:已部署 TEST 并经真实网关逐条
取证 AC-1~AC-6,含读回 #7283 的 BATCH_PRODUCT_CANCEL_FAILED 兜底留痕。
frontend_status=pending(mmg 待接团期详情的「操作记录/时间线」区块)。

Refs HL#7304 (PR #7359, 合并提交 0b85f6831)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 13:45:07 +08:00
Mimingguang ce7e1fb007 chore(changelog): 回写 #7357 前端已验证 verified(mmg/15fea12f)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-09 09:55:57 +08:00
lc f03121f0f4 docs: 景区改绑供应商免原因交接(#7274 #7357)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-09 09:47:07 +08:00
API Changelog Bot 74dc7f0ede docs(changelog): 发布 #7339 库存幂等契约
changelog-filename-gate / validate (push) Successful in 2s
2026-09-08 22:30:49 +08:00
jw和Claude Opus 5 b22345d167 docs(changelog): #7294 补充 actualRefundAmount 的暂定值/定稿值口径(PR #7354)
changelog-filename-gate / validate (push) Successful in 2s
退款单在流团事务【提交之后】才建(异步),所以 approve 响应体里的
actualRefundAmount 是暂定值=退款诉求合计;随后服务端逐户回读实际建单结果,
把没建出退款单的户剔掉,改写成定稿值。要展示准确金额请重新拉一次详情。

本文原本已写「actualRefundAmount 只统计成功发起退款的户」,PR #7354 之前
实现其实是 Σ诉求、有户建单失败就对不上;现在实现与本文口径一致了。

顺带修两处当前校验器不再接受的 frontmatter:
consumer hl-ui → admin;not_required 不得保留 frontend_owner / verified_at。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:58:20 +08:00
Mimingguang 0081bc5682 docs(changelog): 回写 #7318 前端 verified(edefb5c4)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 18:17:57 +08:00
Mimingguang 3e669eefde chore(changelog): #7287 团期成团口径接真 verified(ref 7f775213)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 17:36:53 +08:00
lc e24d03a19c docs: 交付供应商账期与证照图片契约(#7318)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-08 17:32:41 +08:00
Mimingguang a244476a3c chore(changelog): #7344 not_required 确认(纯后端 failclosed,前端字典直显零改动)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 17:29:52 +08:00
jw和Claude Opus 5 9c1290d1c7 docs(changelog): #7344 取消班期活跃订单判据 fail-closed(聚合失败取保守侧 CANCELLING,出入参零变化)
changelog-filename-gate / validate (push) Successful in 2s
管理端取消班期在判「班期还有没有活跃订单」时用的是 fail-open 取数:order-v3
实时聚合一失败就退化为「无活跃订单」,班期被落成终态 CANCELLED 且全仓无路径
可自愈。改为聚合失败取保守侧 CANCELLING + WARN,与订单域内部入口口径对齐。

接口 / 字段 / 路径 / 参数 / 枚举 / 错误码零变化,batchStatus 取值集合与语义
不变,CANCELLING 与 CANCELLED 在下游四处判定中等价 → frontend_status=not_required。

backend_status=deployed / gateway_status=verified:已部署测试服,经真实网关实测
三条路径(无订单→CANCELLED、真实下单→CANCELLING、注入聚合失败→CANCELLING+WARN),
落库均以 SQL 复核。

Refs HL#7344 (PR #7348, 合并提交 2071286a1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2LSgt2xhmX2ae9M3rkdZg
2026-09-08 17:24:56 +08:00
Mimingguang a32b384cb1 chore(changelog): #7284/#7294 not_required 确认(出入参零变化,错误码拦截器透)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 16:56:42 +08:00
jw e22dbe465c docs(changelog): #7284 / #7287 / #7294 团期三单交接 mmg(均已部署测试服并实测)
changelog-filename-gate / validate (push) Failing after 2s
- #7284 团期下单校验改判实时库存:满员班期退单后能重新下单;满员时错误码由 581028
  改为 581031 / 581034;产品域库存聚合失败改 fail-closed(下单返 581027,展示链路不变)。
  前端待办:若对 581028 有特殊文案需改判;接口结构零变化。

- #7287 团期达门槛自动成团 + 未成团动作闸:新增自动成团定时任务(落地即暂停,需运营开启);
  团期详情新增 formingRooms / formingPeople / thresholdSource 且两个门槛改取实时值;
  班期表单与批量创建新增 minParticipants;未成团 / 未建团的资源动作拒 589552 / 589553。
  前端三项待办:成团弹窗「已售」改读 formingRooms、班期表单加最低成团人数、补录两个错误码。

- #7294 流团对已付款户也取消并发起退款:审批单「实退金额」不再恒为 0,改为「已发起退款金额」;
  新增 589554「团期下有出行中的子订单,不可流团」,发起与批复两个入口都会返。
  前端待办:补录 589554,并把「实退金额」文案改为「已发起退款金额」。

三份均按 v2 模板逐接口八子节撰写,本地校验器 PASS。
2026-09-08 16:47:02 +08:00
jw d795a2f0bb docs(changelog): #7159 补齐 581034 网关端到端实测证据,作废「未实跑」旧记录
changelog-filename-gate / validate (push) Successful in 3s
#7135/#7159 同 PR #7169 交付,本文档是 #7159 的口径落点。2026-09-06 记录的
「581034 线上端到端未实跑」已于 2026-09-07 09:2x 由网关实测补齐(12-01 期
maxParticipants 改 3 → adultCount=4 返 581034、=2 返 200 建单后取消复原),
原文表述已作废。同时补 Issue #7159 反链,并按前端交付实况回填 target_release。
2026-09-08 16:29:47 +08:00
Mimingguang 74abca87c9 chore(changelog): #7154 团期财务Tab verified(ref af7cc62b),修正陈旧 backend/gateway 为已部署
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 15:56:23 +08:00
Mimingguang d749d22f2b chore(changelog): 回写 #7105 verified(合同保险面板嵌芯片Tab已交付 838e77da)
changelog-filename-gate / validate (push) Failing after 3s
2026-09-08 14:48:31 +08:00
Mimingguang fc2b0f9090 chore(changelog): 回写 批次A汇总 verified(2eb27845) + #7204 not_required(chipAggState 已覆盖)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 14:06:12 +08:00
Mimingguang 990db59ea8 chore(changelog): 回写 #7197+#7149 verified(#7149 第二批冻结置灰已交付 22eb9b03)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 13:30:19 +08:00
Mimingguang 8992795688 chore(changelog): 回写 #7095 verified(团期转订单已交付 cc07a40d)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 13:18:09 +08:00
Mimingguang 76ba72bf81 chore(changelog): 回写 #7196 verified + #7283 not_required (#7196 流团改申请审批已交付 20905206)
changelog-filename-gate / validate (push) Failing after 1s
2026-09-08 12:49:35 +08:00
Mimingguang beeb6d9cdd chore(changelog): 回写 #7211 前端交付验证(verified, ref 86340b24)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 12:16:13 +08:00
jw 627cbe1fe0 docs(changelog): #7283 流团后班期自动置为不可售 + 已流团团期下单返 589551
changelog-filename-gate / validate (push) Successful in 2s
后端已部署测试服并逐条实测(AC-1~AC-18 全过)。接口出入参零变化,
前端两项待办:补录错误码 589551、知悉班期会显示「取消中/已取消」。
上线顺序硬约束:product-v2 先、order-v3 后,回滚反序。
2026-09-08 11:35:22 +08:00
Mimingguang a4b44cedce chore(changelog): 回写 #7265 前端交付验证(verified, ref 626bc9e5)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 11:14:06 +08:00
Mimingguang 6d966716fb chore(changelog): 回写 #7174 前端交付验证(verified, ref 8c5574c4)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-08 10:33:14 +08:00
Mimingguang 104c31e08c chore(changelog): 回写 #7210 前端交付验证(verified, ref 96ef0c07)
changelog-filename-gate / validate (push) Failing after 3s
2026-09-08 10:23:44 +08:00
Mimingguang d2ed4973f7 chore(changelog): 回写批次A(#7188/#7189/#7190/#7250)与 #7291 前端交付验证
changelog-filename-gate / validate (push) Failing after 2s
五条均 frontend_status verified、owner mmg、verified_at 2026-09-08;批次A 四项 frontend_ref=2eb27845,#7291 frontend_ref=41f0bacc(均 hl-admin v2.1 可达)。
2026-09-08 08:02:04 +08:00
lc a7a3a0bb3e docs(changelog): publish supplier field removal (#7291)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 22:52:09 +08:00
Mimingguang eb7502169d docs(changelog): #7067 前端 verified,U1-U7 全落地(commit af4ad08c)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-07 21:29:21 +08:00
Mimingguang cb4262b39b docs(changelog): #7290 前端 verified(commit 87cf8ba8)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-07 20:23:24 +08:00
lc 0b86d2593f docs: 下发供应商合同必填与去原因契约(#7290)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 19:53:41 +08:00
Mimingguang 0c042ba736 docs(changelog): #7203 verified 回写;#7067 记 U5 已交付
changelog-filename-gate / validate (push) Failing after 2s
2026-09-07 19:02:13 +08:00
wx和Claude Opus 5 350c7a7124 docs(changelog): #7149/#7210 第二轮复审返工对外变更交接 mmg
changelog-filename-gate / validate (push) Successful in 2s
打回原因长度 512→500(逐单打回与 dispatch 此前无上限)、confirm-check 新增
NIGHTS_MISMATCH / DAY_NUMBER_INVALID 两个缺失原因与 expectedNights/actualNights 字段、
房务详情页驳回按钮改按订单判配房并新增一条 disabledReason 文案,另附冻结期被房务退回后
可重提、待出发/行程中可整团确认、需求改版配房跟随迁移三条行为变化。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:43:27 +08:00
Mimingguang 689df8b520 docs(changelog): #7274/#7244 前端 verified 回写
changelog-filename-gate / validate (push) Failing after 2s
2026-09-07 17:35:20 +08:00
lc eba4d9e7dc 景区首次设置供应商免变更原因(#7274)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 16:55:26 +08:00
Mimingguang 8347561971 docs(changelog): #7023 团期物资阶段门 前端 not_required 复核确认
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 16:54:29 +08:00
jw和Claude Opus 4.8 c276b2790c docs(changelog): 补 #7023 团期物资阶段门(验收补做时发现缺 changelog)
changelog-filename-gate / validate (push) Successful in 2s
#7023 三端点新增阶段门(非 MATERIAL_PREPARING 返 589520)+ 合同保险前置门,
属接口行为变更但原工单未列 changelog。走验收标准时补上。

已合入 dev-v3;2026-09-07 TEST 网关实测非物资准备态 POST supplies 返 589520。
纯服务端拒绝,前端按错误码提示(frontend not_required)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 16:43:53 +08:00
Mimingguang a246b25e22 docs(changelog): #7234 敏感读取判权统一到token角色 前端 not_required
changelog-filename-gate / validate (push) Failing after 1s
2026-09-07 16:37:29 +08:00
jw和Claude Opus 4.8 133955c190 docs(changelog): #7234 敏感读取判权统一到 token 当前角色(车务明文+供应商域)
changelog-filename-gate / validate (push) Successful in 2s
契约不变,仅拒绝口径与审计语义:判权依据由库默认角色改为本次请求 token 当前角色(X-Admin-Role),
撤权最迟 10 分钟生效。TEST 网关实测通过(AC-10/11/12),PR #7272 已合 dev-v3。
backend_status=deployed / gateway_status=verified。

Refs #7234

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 16:27:38 +08:00
Mimingguang 278730fc75 chore(changelog): #7095 转订单接真入 backlog 待统一审;#7080 前端实查判 not_required
changelog-filename-gate / validate (push) Failing after 2s
2026-09-07 15:56:09 +08:00
jw和Claude Opus 4.8 15d63f0ba2 docs(changelog): #7095 #7080 TEST 网关实测通过,回写 deployed
changelog-filename-gate / validate (push) Successful in 2s
两单 2026-09-07 部署 dev-v3 到测试服并过网关实测:
- #7095 团期转订单:候选查询、成功转期(计数守恒 2/1→0/0 与 0/0→2/1、金额重算、
  订单归属改到目标期、订单侧 GROUP_BATCH_TRANSFER timeline)、容量守卫拒绝路径全过。
  含后续修复 #7270(订单 timeline 漏实现)、#7278(容量 0=不限口径,网关实测暴露)。
- #7080 团期配人扇出:第7期55户配导游后 guide 芯片 TODO(0/55)→DONE(55/55),
  清空后回落 TODO(0)——改前扇出不同步订单侧状态、芯片恒 TODO,此为修复直接证据。

backend_status pending→deployed,gateway_status→verified,verified_at 2026-09-07。
文件名日前缀按推送日改为 07。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 15:49:31 +08:00
API Changelog Bot和Claude Opus 5 4c24a515cf fix(7250): 约/保异常文案改「合同异常/保险异常」+ 芯片明细编号更正
changelog-filename-gate / validate (push) Successful in 2s
后端 GroupBatchChipResolver 的 CONTRACT_FAILED 含「作废中+已作废」、INSURANCE_FAILED 含
「已取消+投保失败」,原文案「合同已作废」「投保失败」以偏概全(正在作废会被说成已作废、
主动退保会被说成投保失败)。统一改为「{error} 户合同异常」「{error} 户保险异常」,
精确原因走明细接口 GB-ADM-094(约) / GB-ADM-095(保)。房/车「已打回」保持不变。

同步更正「精确分布请点进 GB-ADM-092 / 093」的编号错误(092/093 是导/摄),
改为该芯片对应的逐户明细 GB-ADM-090~095 并列出六芯片映射。

涉及 07_7250_* 与 07_frontend_团期看板对齐后端契约待办汇总-* 两篇。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FJVi8pe9KYmLwLARnaYhhC
2026-09-07 15:48:22 +08:00
Mimingguang b16d54e89e chore(changelog): #7265 原地修改推翻 #7182 新记录式假设,入 backlog 待统一审
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 15:34:54 +08:00
API Changelog Bot和Claude Opus 5 c656b273f0 fix(#7203): 更正二轮复审返工的三个新错误码与核心口径
changelog-filename-gate / validate (push) Successful in 2s
二轮复审(PR #7275)推翻了旧口径「同步失败=订单侧未收到」的断言。实际上:
- 远端已提交成功但回执丢失时,发送端重试耗尽后也会进隔离
- 此时订单侧已是 DONE,直接排车会被 605906 拒
- 故撤销接口需回查订单侧实况后分流三个错误码

本次更新:
1. 补充新增的两个错误码(605923/605924)及其触发条件和前端处置
2. 限定「不必先撤销」仅对 605921 分支成立,605923/605924 必须走运维对账
3. 推翻行 422-425 的核心断言,澄清隔离不等于订单侧未收到
4. 更新 syncStatus 值表 FAILED 的说明文案
5. 注明测试证据表中该行是旧口径的记录
6. 更新 front-matter status_note 记录二轮复审已合并

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:31:09 +08:00
lc 0854f5753f docs: 发布供应商账户原地修改与变更明细契约 (#7265)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 15:28:14 +08:00
Mimingguang ec158880e4 chore(changelog): #7263 前端实查判 not_required(无兜底/反推,值由错变对自动改善)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 15:17:44 +08:00
jw和Claude Opus 4.8 699fc4c04e docs(changelog): #7263 大期数团期产品库存字段静默降级修复(修复,前端无需改动)
changelog-filename-gate / validate (push) Successful in 2s
分片下沉 product-v2 GroupBatchStockHelper,≥约260期 GROUP 产品的库存/已报名/
有效订单数/展示态由静默错值恢复准确;无接口/字段/路径/参数/枚举/错误码变化。
后端 PR #7268 squash f1eb72444 合入 dev-v3,测试服 313 期 AC-1~AC-13 实测通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T5raGi2iBzrbDwiENbpWY2
2026-09-07 15:13:58 +08:00
Mimingguang 59dd1eac9a chore(changelog): #7105 合同保险 Tab 入 backlog 待 #7067 后统一审,保持 pending
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 14:53:39 +08:00
jw和Claude Opus 5 347d5ec664 docs(changelog): 团期合同保险面板与逐户出具 #7105
changelog-filename-gate / validate (push) Successful in 2s
新增面板读、手动开、作废重开、批量催签四个端点。

如实标注三条:
1. 出具依赖房/车/导/摄四项 ready,而 hotel_ready 线上无任何写入方(#4132),
   故面板 issuable 恒 false、三个写动作恒返 589548 —— 前端可先接,
   但 #4132 落地前看不到出具效果;面板读与催签不受限制,已实测可用。
2. 出具成功的正向路径未在网关侧实测(该状态在 TEST 造不出来),
   仅单测覆盖;589549 同样未在真实环境命中。
3. 上线必须先部署 hl-user-service 再部署 order-v3,否则写动作全返 589507。

门的口径以 #7248/PR#7249 修正后为准(判四项 ready,不判团期状态)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:34:51 +08:00
Mimingguang 1c2156fbda chore(changelog): #7203 决策并入 #7067 U5 向导四步化一起做,保持 pending
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 14:14:15 +08:00
API Changelog Bot 8e77a7b30a docs(changelog): #7244 补记后端复测结论——选品页无法选班期是前端未消费 batches,非后端问题
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 14:00:36 +08:00
API Changelog Bot 9f27d9bfb8 changelog(7203): 车务显式声明「整段不用车」——声明/撤销/回显三个新增端点
changelog-filename-gate / validate (push) Successful in 2s
#7067 去槽位化后占位行退役,「车务决定整段不派车」在车管侧就是零派车行,
与「还没开始排车」在库里逐字相同、无法区分,订单侧的用车需求因此永久卡在处理中。
本单新增需求级显式动作承载这个语义(不按零行推断、不放宽常规快照门禁、不复活占位行)。

三个端点各自自包含:入参/出参/请求示例/响应示例/空数据形态/错误响应/业务边界。
另附枚举字典、正确 vs 错误 payload 对照、数据库行为、边界行为与不影响范围。

前端最要紧的三条已写在开头:
- syncStatus 不是订单状态,SYNCED 只表示「送到了」,order-v3 可能按自身规则忽略;
- declarationStatus=REVOKED 时 syncStatus 为 null,不要展示同步状态;
- 撤销按钮只在 syncStatus=SYNCED 时可点,但「只想继续排车」不必先撤销。

实测证据章节为测试服网关真实往返(HEAD 02d7e59be,fleet 双实例),
逐条核对了订单侧 status / assignment_snapshot_revision /
assignment_completion_disposition / assignment_count / assignment_no_vehicle_reason,
不是只看 HTTP 200。
2026-09-07 13:41:46 +08:00
Mimingguang 55e4c68e36 chore(changelog): #7067 记 U2 读取面已交付(ref 44dafb4d),保持 pending 待 U3-U7
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 13:29:49 +08:00
jw和Claude Opus 5 0f2c589874 docs(changelog): 前端待办汇总第 5 项解除依赖——#7250 changelog 已发布
changelog-filename-gate / validate (push) Successful in 2s
完成 #7250 AC-9 后半句:把第 5 项芯片计数 tooltip 的依赖指向本单 changelog,
并更新为「依赖已解除,可开工」。

两处过时表述:
- status_note 原写「其 changelog 已写好但待测试服实测后才发布,故本条仍挂起」
- 第 5 项正文原写「后端 #7250 的 changelog 发布后再做…该篇待测试服实测后发布」

现均改为已发布:07_7250_* 于 2026-09-07 通过测试服 AC-1~AC-9 实测后推送
(commit 16e0133),PR #7254 已合入 dev-v3 (47c9a6d29)。

注:本次提交同时带入该文件此前未提交的两行改动(指向 #7250 文件名、
status_note 补 PR #7254 说明),二者与本次更新是同一件事的前后半段,
分开提交会留下自相矛盾的中间态。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:54:16 +08:00
Mimingguang db278d432c chore(changelog): #7244 前端待办入 backlog,待 U2 后统一汇总审派发
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 12:42:36 +08:00
API Changelog Bot和Claude Opus 5 97608cfca6 docs(changelog): #7244 选品接口透出 productType 与团期班期列表
changelog-filename-gate / validate (push) Successful in 3s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7Xpgcv9nncadAXQtWhg4P
2026-09-07 12:40:28 +08:00
Mimingguang eb535dbc50 chore(changelog): #7250 补记解除汇总工单第5项挂起,并入 backlog 待 U2 后统一派发
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 12:23:00 +08:00
jw和Claude Opus 5 16e0133257 docs(changelog): #7250 团期看板芯片透出 chipStats 计数 — 实测通过,解除挂起
changelog-filename-gate / validate (push) Successful in 2s
PR #7254 合入 dev-v3(47c9a6d29)后,草稿的 status_note 写明「待部署测试服
并过网关实测后改 deployed 再推送」。2026-09-07 已部署 dev-v3 到测试服并逐条
走完 AC-1~AC-9,故回写 backend_status=deployed / gateway_status=verified /
verified_at=2026-09-07 并推送。

网关实测要点:
- chipStats 六项与 chips/{chip} 明细端点的 totalCount/doneCount 逐一相等
- error 计数与明细 items 里失败态户数相等(vehicle 芯片:55 户中 1 户
  REJECTED_TO_CONSULTANT,chipStats.vehicle.error=1)
- 跨 6 个产品 55 行普通聚合分支满足「error>0 当且仅当 chips=ERROR」,违例 0
- 54 个零户行计数全零,未建团行 chips 仍为 TODO(本单不统一零户状态)
- board / 详情 / export / chips 明细四个接口均不含 chipStats,无回归
- VO 纯新增 58 行零删除;Service 层无查询增减,aggregateAll 仍单次调用

AC-6d 原缺 listPage_notFormedRow_chipStatsAllZero(只有 zeroOrderRow 那条,
覆盖的是命中行不是未建团行),已由 PR #7258 补齐(6d3b38de7)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 12:17:38 +08:00
yaosutu 16c68b6b25 docs(mp): C端发票切流v3状态枚举值域变化+email必填收紧 (#7252)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-07 12:05:07 +08:00
Mimingguang a6c9a3cb00 chore(changelog): #7245 前端实查判 not_required;#7210 复审返工增量并入待办保持 pending
changelog-filename-gate / validate (push) Successful in 3s
2026-09-07 12:04:48 +08:00
API Changelog Bot ca5e4073ce changelog(7067): 补第三轮返工的契约增量九之三——requirementReopened 语义扩宽与 NO_DISPATCH_ANCHOR
changelog-filename-gate / validate (push) Successful in 2s
第二轮返工合并后的独立复审发现 2 处缺口(PR #7237 已修并部署测试服):
- 存量订单二次改派后最终方案被误判不完整(改派血缘退休),
- 接送机清空后「开关补开再重存」无法收敛(重开判据由跃迁改为补偿式)。

对前端无破坏性变更(无字段增删、无改名改类型),只有:
C1 requirementReopened 触发面变宽(跃迁 → 状态补偿),重复保存幂等;
C2 reopenBlockedReason 新增取值 NO_DISPATCH_ANCHOR(原来这种情况沉默回 null);
C3 补充九之二 B6「重存一次即可收敛」的前提——要求日上得有可配置的派车行;
C4 内部修复说明(无契约变更)。

front matter 的 status_note 同步更新为三轮返工的口径。
2026-09-07 12:01:57 +08:00
jw和Claude Opus 5 3b2883d1d3 docs(changelog): #7245 团期看板班期名去空格 + 不限容量 remain 统一 null + board 库存同源
changelog-filename-gate / validate (push) Successful in 2s
三个读端点(GB-ADM-001 分页 / board / GB-ADM-002 详情)的响应值修正,
路径、入参、响应结构均未变。

关键变化:remainRooms 与 remainParticipants 为 null 表示「不限」,前端
不要按 === 0 判满员。改前容量为 0 时返 0,而同列表未建团行返 null,
两类行语义打架,运营会看到「剩余 0 人」误判满员。

另两处:batchName 由服务端去前后空白(产品侧存量脏值经合并层实时透出,
存量列不自愈);board 未建团行已用房数改取产品侧 occupiedRooms(含线下
占位),与分页及 product schedule/list 同源——改前容量 8/占位 3/线上 0
时 board 回「剩 8」而实际剩 5。

2026-09-07 测试环境网关实测 AC-1/2/4/4b/5/6 全部通过;本地全量 8728 例
0 failures。backend_status=deployed / gateway_status=verified。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 11:57:10 +08:00
API Changelog Bot 800c9bbab3 Merge remote-tracking branch 'origin/main'
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 11:55:26 +08:00
API Changelog Bot和Claude Opus 5 deef7dc56f changelog: #7210 复审返工补记——权限改按 token 当前角色判权、逐单打回清团级标记(PR #7242)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-07 11:52:02 +08:00
Mimingguang 0be1a7f362 chore(changelog): 汇总工单补 c39fb8a 芯片 tooltip 口径修正说明(#7250 仍挂起)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 11:38:15 +08:00
API Changelog Bot c39fb8ac24 changelog(frontend): 芯片 tooltip 文案修正——done 是已完成数不是待配数、异常措辞按芯片区分、error>0 不等于红色(后端有覆盖规则)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 11:22:13 +08:00
Mimingguang aacc31a3da chore(changelog): 团期看板前端待办汇总标 pending 入队(#7188/7189/7190 归集,#7250 挂起)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 11:13:38 +08:00
API Changelog Bot ce97c51aa9 changelog(frontend): 芯片计数依赖单号更正为 #7250
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 11:08:13 +08:00
API Changelog Bot ae20c451a1 changelog(frontend): 团期看板对齐后端契约待办汇总——第N期标题 / scope 班期范围 / 出行完毕页签 / 未建团行交互 / 芯片计数(依赖后端 #7246)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 11:05:22 +08:00
Mimingguang ae41bbcdee chore(changelog): #7196 团期流团申请审批标 pending 入队
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 10:22:58 +08:00
jw和Claude Opus 5 da07ac2ebf docs(changelog): 团期流团改为申请与审批 #7196
changelog-filename-gate / validate (push) Successful in 3s
发起流团端点语义变更(立即执行 → 提交申请,理由改必填,新增权限与阶段限制),
新增审批中心列表 / 申请详情 / 同意 / 拒绝 4 个端点。

后端已部署 TEST 并实测 R1-R10 全过;前端待接。
如实记录三条限制:批准后无客户通知、抄送定制师只记录不推送、
已付款户流团不会被取消/退款(存量问题,本单未修,另行排期)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:14:31 +08:00
Mimingguang de56358ea8 chore(changelog): #7182 供应商账户审批前端交付标 verified(ref 326a1523)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-07 09:51:52 +08:00
API Changelog Bot和Claude Fable 5.1 bab71b6bdf changelog: #7211 定制师↔团期管理员站内会话 GROUP:{orderId}——open-group 新增、聊天/列表/已读/未读改造、itinerary 与团期名单表新增字段、SSE targetPermissionCode(新增接口·管理后台)
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-07 04:57:00 +08:00
wx 1548a68d4b docs(7067): 补返工二轮与端到端阻断修复的契约增量(四步向导 step 号、finalPlanPublished、门禁可为 null)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 04:39:11 +08:00
API Changelog Bot和Claude Fable 5.1 de167a0c1c changelog: #7210 团期管理员确认/打回改造 M1/M2——confirm-check 预检、589533/589534/589535、reject body 破坏性变更、逐单入口权限(修改接口·管理后台)
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-07 01:45:02 +08:00
API Changelog Bot 8af302c942 Merge remote-tracking branch 'origin/main'
changelog-filename-gate / validate (push) Successful in 2s
2026-09-07 00:57:42 +08:00
API Changelog Bot a591ff1d7a changelog(7189): chips 键名 photo、未建团行金额串口径修正 2026-09-07 00:56:12 +08:00
API Changelog Bot f53f52ca15 changelog(7189): 按门禁补齐 export/board/products 逐接口自包含段落与 TODO 反引号 2026-09-07 00:55:36 +08:00
API Changelog Bot b794316e5a changelog(7189): 团期看板以产品全班期为基底 + scope 班期范围筛选 + 页签期数 product 侧计数(PR #7219),已部署 dev-v3 网关复测 23/23 + AC-14 2026-09-07 00:54:11 +08:00
yaosutu 60e683921a docs(changelog): 定制需求管理后台接口迁移 v3 路径(Epic #7171)
changelog-filename-gate / validate (push) Failing after 2s
customize 定制需求域由 order-v2 迁往 order-v3,管理后台 5 个接口
base 路径 /admin/order/customize → /v3/admin/order/customize,
仅路径前缀变化,字段/方法/出入参契约零变更。旧 v2 路径已下线 404。

Refs #7174
2026-09-07 00:49:24 +08:00
API Changelog Bot和Claude Fable 5.1 c744e20340 changelog(7188,7190): 团期看板 batchLabel 序号透出(PR #7209)+ 团期九态/八桶与出行完毕自动推进(PR #7212),均已部署 dev-v3 网关复测
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7Xpgcv9nncadAXQtWhg4P
2026-09-06 21:51:08 +08:00
API Changelog Bot和Claude Fable 5.1 2a906d67f7 changelog(7197): 订单详情 data.main 透传 groupBatchStatus(修改接口·管理后台)
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 21:45:25 +08:00
API Changelog Bot和Claude Fable 5.1 375520d4bd changelog(7204): 团期看板导/摄芯片聚合态零指派 TODO(PR #7207,已部署 dev-v3 网关复测)
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7Xpgcv9nncadAXQtWhg4P
2026-09-06 20:33:08 +08:00
API Changelog Bot和Claude Fable 5.1 7f634e509d gate: 占位符检测忽略代码围栏与反引号内文本——接口枚举值本身可能叫 TODO(团期芯片聚合态,#7204)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7Xpgcv9nncadAXQtWhg4P
2026-09-06 20:33:05 +08:00
lc e1b80226fc docs(changelog): 发布7182供应商账户审批接口
changelog-filename-gate / validate (push) Successful in 2s
2026-09-06 19:57:03 +08:00
Mimingguang f9ff754579 chore(changelog): #5935 端点已被 #7067 删除标 not_required;#7154 后端 pending 保持挂起
changelog-filename-gate / validate (push) Failing after 2s
2026-09-06 19:32:17 +08:00
Mimingguang 35715d7ba0 chore(changelog): #7149 补 0451e4c 错误码优先级更正前端零改动说明
changelog-filename-gate / validate (push) Successful in 2s
2026-09-06 19:25:19 +08:00
Mimingguang 85dec7085f chore(changelog): #7067 记 U1 交付进度;团期看板子订单恒空缺陷单标 verified(ref 7d652bdc)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-06 19:24:54 +08:00
API Changelog Bot和Claude Fable 5.1 0451e4ceaa changelog(7149): 更正错误码优先级口径——入口结构校验先于状态校验,对外只变团单 582099 先于 582016;冻结期例外按最高版本组
changelog-filename-gate / validate (push) Successful in 2s
复审指出原文把事务内 doUpsertHotelRequirement 的校验顺序写成公开接口整体优先级,忽略了直提入口进事务前的团单/结构前置校验。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 19:12:17 +08:00
API Changelog Bot和Claude Fable 5.1 077845c8aa changelog(frontend): 团期看板展开行子订单列表恒空——store.currentGroupBatchId 只在详情页赋值致 PeriodRow 门控恒空;GB-ADM-003 实测正确(关联 #7204/#7188/#7189/#7190)
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7Xpgcv9nncadAXQtWhg4P
2026-09-06 18:57:13 +08:00
Mimingguang d80b3ee60b chore(changelog): #7149 status_note 折入 PR #7200(直提入口房数0统一582099),前端无新增改动
changelog-filename-gate / validate (push) Successful in 2s
2026-09-06 18:28:55 +08:00
API Changelog Bot和Claude Fable 5.1 0623aecfaa changelog(7149): 补 PR #7200(直提入口团单房数 0 统一 582099)
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 17:51:06 +08:00
API Changelog Bot 79da505a41 docs(7067): 派单去槽位化按行程日配车 + 接送机独立配置四步向导交接件
changelog-filename-gate / validate (push) Successful in 2s
14 个接口的自包含契约(含 4 个删除端点与 1 个改名端点的前端迁移指引),
backend_status=deployed / gateway_status=verified,网关实测证据已回填。

Refs wx/HL#7067
2026-09-06 17:50:13 +08:00
Mimingguang 82026c2a92 chore(changelog): #7149 status_note 记冻结置灰待后端 #7197 透传 groupBatchStatus
changelog-filename-gate / validate (push) Successful in 2s
2026-09-06 17:32:13 +08:00
Mimingguang 5a74bf554c chore(changelog): #7149 记第一批前端交付(必填+入口恢复),冻结置灰待 batchStatus 数据源
changelog-filename-gate / validate (push) Successful in 2s
2026-09-06 17:22:02 +08:00
API Changelog Bot和Claude Fable 5.1 184ca5ed44 changelog(7149): PR #7184 三轮——订单详情 days[].customerSelfBooked 新增字段说明
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 17:10:27 +08:00
API Changelog Bot和Claude Fable 5.1 fbe1de4d91 changelog(7149): PR #7184 二轮——错误码优先级变化(核心订单也受影响)、调整入口 582099 同口径、汇总不保证逐日连续、详情统计同步跳自订晚
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 16:56:52 +08:00
jw和Claude Opus 5 41f3f69500 docs(changelog): #7060 成团驱动子订单流程推进,打通定制师配房配车待办
changelog-filename-gate / validate (push) Successful in 2s
POST /v3/admin/order/group-batch/:groupBatchId/group 新增副作用:成团时把
该团活跃子订单从 AWAITING_PROFILE 推进到 RESOURCE_PREPARING,由既有
syncFlowTodos 产生配房/配车待办派给定制师。

接口路径、入参、出参均未变,前端无需改动;变化在服务端副作用。
调用方若缓存过子订单状态需刷新。

边界:只从 AWAITING_PROFILE 推进,AWAITING_PAY 不越过支付闸门;重复成团
幂等(CAS);待办派发走独立事务,失败不回滚成团。

2026-09-06 测试环境造数补验,6 组用例通过。核心两条在真实数据上确证:
成团后子订单 flowStatus 由 AWAITING_PROFILE 变为 RESOURCE_PREPARING;
另一单成团时为 AWAITING_PAY,成团后未被推进。

backend_status=deployed / gateway_status=verified。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 16:41:24 +08:00
Mimingguang 473ac246ed chore(changelog): 回写 #7178 前端交付状态
changelog-filename-gate / validate (push) Failing after 2s
2026-09-06 16:40:20 +08:00
API Changelog Bot和Claude Fable 5.1 fc87e3280a changelog(7149): 复审补漏 PR #7184——汇总跳过自订晚、团单房数为空/0 统一 582099、校验顺序说明
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 16:40:06 +08:00
Mimingguang a107bc9f18 chore(changelog): 回写 #7135/#7142/#7143/#7158/前端缺陷条前端交付状态
changelog-filename-gate / validate (push) Failing after 2s
2026-09-06 16:33:44 +08:00
jw和Claude Opus 5 70c1f44650 新增团期调整满团名额同步产品域库存接口说明(#7178)
changelog-filename-gate / validate (push) Failing after 2s
修的是「调了不生效」:调名额此前只写订单域,而库存权威在产品域,
导致加名额放不出空位、减名额停不了售,但看板数字会变。

请求体不兼容变更:{maxParticipants,maxRooms} 两个绝对值 →
{capacityDelta,reason} 净增量且只调户数;新增阶段门(仅招募中、
资源准备中可调)与 group-batch:manage 权限校验;响应改为返回
调整前后值、已报名与余量。

后端已部署 TEST 并实测:加减名额驱动满团即停/放开继续招募、
低于已报名拒绝且零写入、物料准备起阶段门、已成团加名额仍保持成团。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 16:21:40 +08:00
jw和Claude Opus 5 a7c44c0caa docs(changelog): 团期财务总览与预支复用订单预支(#7154 / PR #7170)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 16:20:04 +08:00
API Changelog Bot和Claude Fable 5.1 354d9083a7 scripts: validate-changelog-frontmatter 增加 --files 提交前自检模式(与 pre-push 门禁同规则,供作者/Agent 与 hl-workflow lint 复用)
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 16:18:27 +08:00
API Changelog Bot和Claude Fable 5.1 7b9b60b9ac changelog(7149): 按 CHANGELOG_TEMPLATE 重写——三接口自含入参/出参/示例/错误/边界,补对比与影响评估(门禁)
changelog-filename-gate / validate (push) Successful in 2s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 16:11:30 +08:00
API Changelog Bot和Claude Fable 5.1 c1d7fed7b6 changelog(7149): 按 CHANGELOG_TEMPLATE 重排章节(门禁)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 16:08:18 +08:00
API Changelog Bot和Claude Fable 5.1 c5c69d10aa changelog(7149): 边界行为补「团单不接受空白占位段」与「物资准备=物料准备中」说明
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 15:59:33 +08:00
API Changelog Bot和Claude Fable 5.1 c5adab6346 changelog(7149): 团期子订单支付后即可提房车需求、物资准备起冻结 589536、团单房型间数必填 582099(PR #7177,Refs #7149)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYL5S9SsBtkg7aGyFAbrrQ
2026-09-06 15:58:44 +08:00
API Changelog Bot和Claude Opus 4.8 c4968b5233 docs(changelog): 团期创单收紧+人数预查(#7135/#7159)、判团字段(#7142)、看板productId(#7143)、前端缺陷指引
changelog-filename-gate / validate (push) Successful in 2s
- 06_7135:填 PR #7169 / 合并提交 977be08e / 折入 #7159(581034 改读 remainingParticipants)/ 修正单测计数 / 补部署 dev-v3 网关复测 15/15 证据
- 06_frontend:更正后端已加固并部署验证(不再是零改动),补 581055/581056/581057、581034 触发条件与影响范围
- 06_7142 / 06_7143:早前已随 PR #7155/#7157 合并,随本批提交

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7Xpgcv9nncadAXQtWhg4P
2026-09-06 15:23:27 +08:00
jw和Claude Opus 5 62c52b8024 新增团期手动成团门槛与提前成团留痕接口说明(#7158)
changelog-filename-gate / validate (push) Successful in 3s
成团接口新增可选请求体 formedNote、新增 group-batch:manage 权限校验、
重复成团改为语义化 589537;团期详情与产品班期两侧新增最低成团户数 minToForm。

后端已部署 TEST 并实测:带理由成团成功、重复成团 589537、
不传 body 兼容路径均通过;网关链路已验证。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 14:56:28 +08:00
Mimingguang 713b15a640 chore(changelog): #7148 前端停用审批已交付,回写 verified(mmg/7245780a/2026-09-06)
changelog-filename-gate / validate (push) Failing after 1s
2026-09-06 12:59:28 +08:00
lc 293aa77f98 新增供应商账户停用审批接口说明(#7148)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-06 12:48:11 +08:00
Mimingguang 2f6a989fcf chore(changelog): 补齐 17 条消费闭环 frontmatter 回写
changelog-filename-gate / validate (push) Failing after 2s
11 条有业务交付改判 verified(#6397/6903/6904/6905/6950/6979/6986/7013/7029/7036/7066,owner=mmg+对应业务 commit ref+交付日 verified_at);
6 条实证零改动改判 not_required(#6014/6016/6140/6938/6842/7087,仅翻 frontend_status 不填 owner/ref)。
#5935 挂起待后端补字段,保持 pending 不动。sync-log 均已记账。
2026-09-06 10:43:20 +08:00
Mimingguang 54b4d7a86e chore(changelog): #7137 前端中文映射已交付,回写 verified(mmg/9d8115ed/2026-09-06)
changelog-filename-gate / validate (push) Failing after 2s
2026-09-06 10:25:32 +08:00
lc f3f11505d4 docs: 将 #7137 调整为后端验收口径
changelog-filename-gate / validate (push) Successful in 2s
2026-09-06 10:16:22 +08:00
lc d1fe0c3bf0 docs: 说明供应商账户状态中文映射(#7137)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-06 10:04:25 +08:00
lc 4e52f26972 补充供应商账户与合同修改删除接口说明
changelog-filename-gate / validate (push) Successful in 2s
2026-09-06 10:02:59 +08:00
Mimingguang c399b3b0f1 chore(changelog): 回写 #7101 前端 verified
changelog-filename-gate / validate (push) Failing after 2s
2026-09-05 11:35:01 +08:00
Mimingguang 0dec0584f9 chore(changelog): 回写 #7100 前端 verified
changelog-filename-gate / validate (push) Failing after 2s
2026-09-05 11:20:24 +08:00
lc a269c0bc97 供应商合作中资料变更接入企微审批(#7101)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-05 09:17:56 +08:00
jw和Claude Opus 5 be5afcf30a 团期退单户改为「提交申请 + 管理员审批」,070 破坏性变更 + 072~075 新增(#7100)
changelog-filename-gate / validate (push) Successful in 2s
070 由「调用即退款」改为「只建待审申请单」,响应体 Result<Void> → 对象;
新增审批列表/详情/通过/取消退单四个端点,审批限 ADMIN/SUPER_ADMIN 角色。

金额口径写明两个数(已付 / 预计退):早期阶段退的是订金应付额、与已付无关,
后期阶段以已付额按退改政策扣减——这条是 TEST 实测暴露 #7126 缺陷后更正的说法。

已过网关真测(2026-09-05):19 项含提交/驳回后该户仍在团里/再次提交/通过后名额回落,
预估与实退一致;验证产生的两张退款单已作废,测试环境资金零变动。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 08:18:03 +08:00
Mimingguang 125014ac9b chore(changelog): 回写 #7094 前端 verified
changelog-filename-gate / validate (push) Failing after 2s
修改原因:#7094 前端已交付(commit 2e8ed57c,sync-log 已记 done)但 frontmatter 仍为 pending,
发布门禁前端核验状态与真实交付不符。

修改内容:frontend_status=verified、frontend_owner=mmg、frontend_ref=2e8ed57c、verified_at=2026-09-04;
target_release 与 status_note 保持原值,正文未动。

实际验证:git diff 逐字段核对仅改 frontmatter 5 字段。
2026-09-04 23:34:24 +08:00
lc 19c3abe79e 补充供应商账户驳回终态(#7094)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 23:19:02 +08:00
API Changelog Bot a67e24b9b9 更正 #7098 收敛:04_7070 抢单池/我的接单 productType 筛选下推 SQL,我的接单 productType 入参此前被静默忽略现生效(#7098 复审,PR #7117 合入 dev-v3 合并提交 cd21ae0)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 22:22:18 +08:00
Mimingguang 81139ab8c3 chore(changelog): 回写 #7090 前端 verified
changelog-filename-gate / validate (push) Failing after 2s
修改原因:#7090 前端已交付(commit 32a1a289,sync-log 已记 done)但 frontmatter 仍为 pending,
发布门禁前端核验状态与真实交付不符。

修改内容:frontend_status=verified、frontend_owner=mmg、frontend_ref=32a1a289、verified_at=2026-09-04;
target_release 与 status_note 保持原值,正文未动。

实际验证:git diff 逐字段核对仅改 frontmatter 5 字段。
2026-09-04 20:43:38 +08:00
lc 8973353356 交付供应商信用等级与公司资料字段契约(#7090)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 20:09:22 +08:00
Mimingguang 17490fff88 chore(changelog): 补齐6条已实现条目的前端verified回写
changelog-filename-gate / validate (push) Failing after 2s
修改原因:6 条 changelog 前端已落地并交付(sync-log 已记 done),但 frontmatter 仍为 pending,
导致发布门禁的前端核验状态与真实交付不符。

修改内容:按 hl-changelog/v2 口径回写 frontend_status=verified、frontend_owner=mmg、
frontend_ref=对应业务 commit 短哈希、verified_at=2026-09-04;target_release 与 status_note 保持原值。

- #6926 导出补 opsStage 筛选 -> ea1b9803
- #7059 设置供应商候选按资源上下文过滤 -> a1a78906
- #7069 供应商企微在途状态统一审核中 -> f6ddd94c
- #7070 房务列表/详情补产品类型标签 productType -> 7476250d
- #7078 供应商企微审核中冻结资料修改 -> f4d3e1c9
- #7087 供应商新建修改必填资料校验 -> 72d63c8e

实际验证:逐条 diff 核对仅改 frontmatter 5 字段,status_note/target_release/正文未动。
2026-09-04 20:04:16 +08:00
API Changelog Bot a97809f105 更正 #7097 收敛:04_7066 应收字段以 totalPrice 为准(totalAmount 未上线即收敛);6905 取消单 totalPrice 返 0.00 两位小数(#7097 复审)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 20:00:00 +08:00
lc 0a324bb6dc 更正 #7062 前端状态为无需处理
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 17:02:19 +08:00
lc 8696bbb083 补充供应商新建修改必填资料说明(#7087)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 16:52:18 +08:00
lc eede11917a 供应商清账归档后修改入口置灰(#7062)
changelog-filename-gate / validate (push) Successful in 1s
2026-09-04 16:16:05 +08:00
lc e3aace57c5 交付供应商审核中资料冻结联调说明(#7078)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 15:56:31 +08:00
API Changelog Bot 4ff1480683 补全 7070 房务 productType 标签接口文档(完整模板+网关实测)
changelog-filename-gate / validate (push) Successful in 2s
2026-09-04 15:50:54 +08:00
API Changelog Bot 8c38fc6b89 docs(changelog): 7070 房务列表/详情补产品类型标签 productType(管理后台) 2026-09-04 15:50:54 +08:00
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
lc 6461e1ce39 docs(changelog): 交付行政区划主数据接口(#6350)
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-26 15:52:27 +08:00
lc 48b1d069c8 Merge pull request 'docs(changelog): 交付供应商暂停与拉黑接口(#6392)' (#76) from chore/6392-api-changelog into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 15:45:47 +08:00
lc 36a85b8669 docs(changelog): 交付供应商暂停与拉黑接口(#6392)
changelog-filename-gate / validate (pull_request) Successful in 2s
2026-08-26 15:45:16 +08:00
lc 43b9c16a36 Merge pull request 'docs(changelog): 交付行政区划 ID 字符串契约(#6409)' (#75) from chore/6409-api-changelog into main
changelog-filename-gate / validate (push) Successful in 2s
2026-08-26 15:30:01 +08:00
共修改 138 个文件,包含 48729 行新增和 79 行删除
+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` 是机器可识别的唯一映射源;
@@ -0,0 +1,153 @@
---
schema: "hl-backend-request/v1"
title: "放开 H1 看板列表 batchStatus 对 CANCELLED 的过滤(让整团释放入口可达)"
consumer: "backend"
requester: "mmg(前端)"
author: "mmg"
target_service: "hl-order-service-v3"
status: "answered-no-backend-change"
related_issue: "7324"
related_frontend_commit: "6710ad4a"
created_at: "2026-09-11"
answered_at: "2026-09-11"
answered_by: "Claude(后端管理者)"
backend_verdict: "可达路径已存在,后端零改动;我方 #7322 changelog 漏写「我的团」不过滤 batch_status,前端的判断在当时信息下是合理的"
---
# 需求:放开 H1 看板列表 `batchStatus` 对 `CANCELLED` 的过滤
> **来源工单**: #7324 房务团期看板与整团按日订房计划 CRUD
> **服务**: `hl-order-service-v3`
> **提出方**: 前端(mmg),2026-09-11
> **状态**: 待后端评估 / 排期
---
## 一、一句话需求
房务团期看板列表 `GET /v3/admin/house/group-batches`(H1)当前把 `batchStatus=CANCELLED` 从过滤里**忽略**(传了被静默丢弃,回退默认四态),导致已流团团期永远进不了看板列表;而「整团释放」写口仅对 CANCELLED 团期放行,前端「整团释放」按钮因此**没有任何用户可达路径**。请放开 H1 对 CANCELLED 的过滤,让已认领的已流团团期能进入看板列表。
---
## 二、现状行为(契约原文,#7324 changelog)
`#7324` changelog 两处明确了当前过滤行为:
1. H1 `batchStatus` 入参表:「传 `CANCELLED` **被忽略**」——默认四态 = `RESOURCE_PREPARING, MATERIAL_PREPARING, PENDING_DEPARTURE, TRAVELLING`(`HouseGroupBatchBoardManager.java:60-64`)。
2. 边界行为:「`batchStatus` 若传入非法状态码或全部被过滤掉(如只传 `CANCELLED`),**静默回退为默认四态**,不报错」(`HouseGroupBatchBoardManager.java:224-236`)。
链路后果:
- 看板列表 H1 **结构性不返回** CANCELLED 团期。
- 详情弹层(H2)只能从 H1 行点开,所以永远拿不到一个 `batchStatus==='CANCELLED'` 的 detail。
- 前端「整团释放」按钮的显隐条件是 `detail.batchStatus === 'CANCELLED'`(`canReleaseAll`),恒为 false,按钮永不渲染。
- release-all 端点 `POST .../room-plans/release-all` 本身仅对 CANCELLED 放行(其它态 808618,`HouseGroupBatchPlanGate.PLAN_RELEASE_ONLY_STATUSES` 只含 CANCELLED),端点行为自洽、无 bug——**只是前端够不到一个 CANCELLED 团期去调它**。
---
## 三、期望行为
让 H1 在「按认领维度」下能返回已流团(CANCELLED)团期,二选一均可:
- **方案 A(推荐)**:`batchStatus` 入参放开 `CANCELLED`——前端显式传 `batchStatus=CANCELLED` 时后端按此过滤,不再忽略/回退。改动最小,不影响不传参时的默认四态。
- **方案 B**:新增一个独立查询参数(如 `includeCancelled=true` 或 `scope` 扩展),在认领维度下附带返回 CANCELLED 团期。
无论哪个方案,都请保持「未认领团不进本页」的既有边界(H1 业务边界第 1 条),CANCELLED 放开只作用于「已被房务认领」的团。
---
## 四、为什么安全 / 不破坏既有
- **阶段闸门已天然兜住写口**:`HouseGroupBatchPlanGate.PLAN_WRITABLE_STATUSES`(可写六态)不含 CANCELLED,H4/H5 对 CANCELLED 团期本就走 808600;H6/release-all 走更宽的 `releaseOnly` 分支(CANCELLED 放行)。放开 H1 过滤**不改变任何写口的阶段闸门判定**——只是让 CANCELLED 团期「能被看见」,写口权限仍由各端点自己的闸门控制。
- **release-all 端点无需改**:它已正确地对 CANCELLED 放行、对其它态 808618,本次只动 H1 读侧。
- **向后兼容**:不传 `batchStatus` 的既有调用方仍走默认四态,行为零变化;只有显式传 `CANCELLED` 的调用方才看到新返回。
---
## 五、前端配套(已就绪,零改动等待)
前端「整团释放」按钮与 release-all 调用**已实现并保留**(`#7324` 前端 commit `6710ad4a`),显隐条件 `canReleaseAll` 已按「`batchStatus==='CANCELLED'` 且非组长只读」写好。后端一旦放开 H1 的 CANCELLED 过滤,CANCELLED 团期进入看板 → 详情弹层 `detail.batchStatus` 即为 CANCELLED → 按钮自动出现并可点,**前端无需任何改动**。
后端部署后通知前端即可,前端会回归验证「整团释放」入口可达。
---
## 关联
- Issue: [#7324](https://git.1814.love:8443/wx/HL/issues/7324)
- 后端 PR: [#7465](https://git.1814.love:8443/wx/HL/pulls/7465)
- 前端交付 commit: `6710ad4a`(hl-admin `v2.1`)
- 相关源码: `HouseGroupBatchBoardManager.java`(H1 编排,`:60-64` 默认四态、`:224-236` batchStatus 过滤)、`HouseGroupBatchPlanGate.java`(阶段闸门,无需改)
---
## 🔧 后端答复(2026-09-11)
> **结论:本单不需要后端改动——可达路径已经存在。而你们不知道它存在,是因为我们的 #7322 changelog 漏写了一条关键行为。这个缺口在我方。**
### 一、结论先行
「整团释放按钮没有任何用户可达路径」这一判断**不成立**。已经存在的路径是:
```
GET /v3/admin/order/grab-pool/my-claims/group-batches ← 我的团(#7322 已交付且已验收)
↓ 该列表不过滤 batch_status,已流团(CANCELLED)团期就在里面
↓ 行上直接带 groupBatchId / batchStatus / batchStatusLabel
GET /v3/admin/house/group-batches/{groupBatchId} ← H2 详情(无阶段闸门)
↓ detail.batchStatus === 'CANCELLED' → canReleaseAll 成立
POST /v3/admin/house/group-batches/{groupBatchId}/room-plans/release-all
```
### 二、逐环节证据(五条,全部对源码复核过,不是从文档转述)
| # | 环节 | 证据 |
|---|---|---|
| 1 | **我的团不过滤 `batch_status`** | `HouseGroupGrabService.listMyGroups` javadoc 原话:「**不限制 batch_status**:已认领的团可能已流团 / 已结束,本单不自动释放指针,**房务仍要看得见并手动释放**」。代码 `q.setBatchStatus(req.getBatchStatus())` 原样下推,**不传就是不加这个条件**,没有 H1 那种「过滤空了回退默认四态」的逻辑 |
| 2 | **服务层也不注入状态** | `GroupBatchService.pageHouseClaimed` 直接把 query 下推 mapper,零注入 |
| 3 | **同一文件里的对照(最强证据)** | 紧挨着的 `GroupBatchService.pageHouseGrabPool` 却**强制**写死 `q.setBatchStatuses(REQUIREMENT_CONFIRMABLE_STATUSES)`。一个注入、一个不注入,**是刻意区分不是遗漏** |
| 4 | **我的团的行携带判定所需字段** | `HouseMyGroupItemRespVO extends HouseGroupGrabPoolItemRespVO`,父类有 `groupBatchId`、`batchStatus`、`batchStatusLabel` |
| 5 | **H2 详情没有阶段闸门** | `HouseGroupBatchBoardController.detail` 的门**只有归属**(未认领 808612 / 他人认领 808613),**不判 `batchStatus`**——CANCELLED 团只要是本人认领的就能打开 |
你对 release-all 端点本身的判断是对的:它只对 CANCELLED 放行、其它态 808618,端点自洽无 bug。
### 三、H1 过滤掉 CANCELLED 是刻意设计,且原注释就指明了入口在详情页
`HouseGroupBatchBoardManager.resolveBatchStatuses` 上方注释原话:
> 「为什么已流团的团不许出现在看板:本页是『要干活的团』,流团团的唯一动作是整团释放,**那个入口在详情页而不是列表页**;混在一起会让房务每天都在列表里看到一堆做不了任何事的团。」
注释写明了「详情页」,**但没写明从哪个列表进详情页**——这就是这次误判的接缝所在。
### 四、这次的根因在我方,不在你们
你们的推理链条本身是严密的:读 #7324 changelog → H1 明确写了「传 CANCELLED 被忽略」→ 详情从 H1 点开 → 因此 CANCELLED 详情不可达。**在你们掌握的信息里,这个结论是对的。**
问题出在 **#7322 的 changelog 从头到尾没有提过「我的团」不过滤 `batch_status`**(我逐行搜过那篇,零命中)。这条行为是后端刻意设计并写进 javadoc 的,却**没有进入交接件**——而 javadoc 你们看不到。
**所以这是我方的文档缺口,不是你们的疏忽。** #7322 的 changelog 我会补这一条。
### 五、前端需要做什么(如果需要)
把「我的团」列表行接到 H2 详情弹层——同一个弹层,入参就是行上已有的 `groupBatchId`。两端 API 都已交付并验收过(#7322 changelog `frontend_status: verified`),这是**接线**不是新功能。
如果「我的团」页当前打开的不是 H2 那个弹层,这一步是前端改动;**后端仍然零改动**。
补一条使用上的细节:我的团的 `batchStatus` 入参是**透传**的,所以
- 不传 → 全部已认领团(含 CANCELLED);
- 传 `CANCELLED` → **只看已流团的团**,做一个「待释放」筛选很自然。
这和 H1 的行为正好相反(H1 传 `CANCELLED` 会被丢弃并回退默认四态),**两个列表的 `batchStatus` 语义不同,别套用同一套前端逻辑**。
### 六、留给 wx 的产品判断(这一条不是 bug,是口径)
方案 A(H1 放开 CANCELLED)实现成本确实很低,你对「不破坏既有」的分析也站得住:写口闸门 `PLAN_WRITABLE_STATUSES` 不含 CANCELLED,放开读侧不影响任何写口判定——这点我复核过,属实。
**但我不建议改**,理由是它与「看板 = 要干活的团」这条既有定案直接冲突,而该定案是有成文理由的(见第三节注释)。已流团团期在看板里是纯噪声:房务对它只能做一个动作,而那个动作有专门的入口。
如果 wx 判断「房务更习惯在看板里找」,那我照做——**但那时它是一次产品口径调整,不是修 bug**,会另开工单并同步改注释里的定案说明。这个判断我交给 wx,不自行拍板。
---
**后端本单零改动。#7322 的 changelog 会补「我的团不过滤 batch_status」一条。**
@@ -0,0 +1,156 @@
---
schema: "hl-changelog/v2"
ticket: "7252"
title: "C端发票切流 order-v3:状态枚举值域变化 + email 必填收紧"
consumer: "mp"
author: "yst(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-07"
status_note: "C端发票 4 接口后端由 order-v2 切到 order-v3,路径/入参/出参字段名零变化;但状态枚举值域变化(PENDING→REQUESTED 等)+ email 必填收紧,前端需适配状态映射与表单校验。后端/网关已测试服验证,前端待消费。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 【修改接口·小程序端】C端发票切流 order-v3:状态枚举值域变化 + email 必填收紧(#7252)
## 1. 接口背景
order-v2 全量下线工程,C 端发票 invoice 由 order-v2 切流到 order-v3 发票域承接。**4 个对外接口的路径、请求方法、入参字段名、出参字段名全部不变**,前端无需改请求地址与字段名。
但后端切换带来 **2 处对外可见的行为变化**,前端需要适配:
1. **发票状态 `status` 枚举值域变化**(v2 → v3 值不同)
2. **email 由可选变为必填**(v3 服务端强制校验)
## 2. 变更清单
| 接口 | 路径 | 方法 | 变化 |
|---|---|---|---|
| 申请开票 | `/mp/invoice/apply` | POST | 出参 status 值域变化;email 必填收紧 |
| 发票详情 | `/mp/invoice/{id}` | GET | 出参 status 值域变化 |
| 按订单查发票 | `/mp/invoice/order/{orderId}` | GET | 出参 status 值域变化;未开票仍返 data=null |
| 发票换开 | `/mp/invoice/{invoiceId}/reissue` | POST | 出参 status 值域变化;email 必填收紧 |
## 3. 接口详情
路径 / 方法 / Content-Type 均不变。仅出参 `status` 的**取值集合**与入参 `email` 的**必填性**变化。
## 4. 入参
入参字段名、类型、必填性**基本不变**,唯一变化:
- `email`(申请开票 / 换开):**由可选变为必填**。前端若不传 email,v3 服务端返回 400「收件邮箱不能为空」。建议前端表单将邮箱设为必填项。
## 5. 出参
出参字段名、类型不变。**唯一变化是 `status` 字段的取值集合**:
| 维度 | v2 旧值(切流前) | v3 新值(切流后) |
|---|---|---|
| status 枚举 | `PENDING` / `ISSUED` / `CANCELLED` | `REQUESTED` / `ISSUED` / `VOIDED` / `FAILED` |
映射关系(供前端文案/样式适配参考):
- `REQUESTED` ≈ 旧 `PENDING`(已申请待开票)
- `ISSUED` = 旧 `ISSUED`(已开票,不变)
- `VOIDED` ≈ 旧 `CANCELLED`(已作废)
- `FAILED` 为 v3 新增(开票失败)
其余出参字段(id / invoiceId / orderId / invoiceTitle / taxNumber / titleType / amount / invoiceUrl / pdfUrl / fileUrl / createdAt / issuedAt)字段名与类型均不变。
## 6. 枚举/数据字典
发票状态 `status`:
- `REQUESTED` 待开票(已提交申请)
- `ISSUED` 已开票
- `VOIDED` 已作废(换开后原发票作废为此态)
- `FAILED` 开票失败
抬头类型 `titleType`(不变):`PERSONAL` 个人 / `COMPANY` 单位。
## 7. 错误码
- `600004` 订单ID格式错误(apply 时 orderId 非数字兜底)
- email 缺失:v3 服务端 Bean Validation 返 400「收件邮箱不能为空」
- `581500` 发票不存在(含归属校验:非本订单属主查询时也返此码)
## 8. 示例
### 典型:申请开票(email 必填)
```json
POST /mp/invoice/apply
{
"orderId": "2091418470443810817",
"invoiceTitle": "上海呼籁旅行科技有限公司",
"titleType": "COMPANY",
"taxNumber": "91310115MA1K48XXXX",
"email": "finance@hulalv.com",
"remark": "对公开票"
}
```
响应(status 为新值域):
```json
{
"code": 200,
"data": {
"id": "2091422818997612545",
"invoiceId": "2091422818997612545",
"orderId": "2091418470443810817",
"invoiceTitle": "上海呼籁旅行科技有限公司",
"taxNumber": "91310115MA1K48XXXX",
"titleType": "COMPANY",
"status": "REQUESTED",
"invoiceUrl": null,
"pdfUrl": null,
"fileUrl": null,
"createdAt": "2026-09-07 12:00:00",
"issuedAt": null
}
}
```
### 边界:按订单查发票(未开票)
```json
GET /mp/invoice/order/2091418470443810817
→ { "code": 200, "data": null }
```
### 异常:email 缺失
```json
POST /mp/invoice/apply (body 无 email)
→ { "code": 400, "message": "收件邮箱不能为空" }
```
## 9. 业务边界
- `invoiceUrl` / `pdfUrl` 为历史遗留字段,恒为 null(v2 侧从未赋值),前端不应依赖,请使用 `fileUrl`。
- 申请开票时 `invoiceType` 固定按增值税普通发票(VAT_NORMAL)提交,C 端入参无发票类型选择字段。
- 换开成功后旧发票置 VOIDED,返回新发票完整信息。
## 10. 修改前后对比
| 项 | 修改前(v2) | 修改后(v3) |
|---|---|---|
| 后端服务 | order-v2 | order-v3 |
| status 值域 | PENDING/ISSUED/CANCELLED | REQUESTED/ISSUED/VOIDED/FAILED |
| email 必填 | 可选 | 必填(缺失返 400) |
## 11. 影响评估 / 回滚
- 前端需适配:①状态文案/样式映射按新枚举 ②邮箱表单必填。
- 回滚:后端可切回 v2 client(需回滚 mp-service 代码并重新部署)。
## 12. 注意事项
- 路径与字段名零变化,前端**不需要改请求地址**,只需处理状态枚举值与 email 必填。
- 若前端对 status 有硬编码判断(如 `status === 'PENDING'`),必须改为新值 `REQUESTED`。
## 13. 关联 / 联系人
- Issue:https://git.1814.love:8443/wx/HL/issues/7252
- PR:https://git.1814.love:8443/wx/HL/pulls/7255
- 负责人:yst(GIT)
@@ -7,12 +7,12 @@ author: "wx"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_status: "not_required"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "后端完成:PR #5985 已合并 dev-v3(1fe44677a)并部署 TEST(hl-fleet-service 双实例 UP)。软取消语义/范围隔离/拆行引擎复用/Step2 快照收敛/占用按子集重算由 7 个新增单测覆盖(AssignmentServiceTest 506/506 全绿)。"
status_note: "本端点 POST /slots/{slotId}/clear-residue-dates 已被 #7067 去槽位化整体删除(404),无前端落地对象。前端在 #7067 U1 已随死端点拆除清掉其全部调用链,无需实现。本条 not_required(被后续单取代),残留清理语义由 POST /batch 整批 diff 覆盖。"
updated_at: "2026-08-16"
base: "dev-v3"
generated: "2026-08-16T00:35:00+08:00"
@@ -7,7 +7,7 @@ author: "wx"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_status: "not_required"
frontend_owner: "mmg"
frontend_ref: ""
target_release: ""
@@ -7,7 +7,7 @@ author: "wx"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_status: "not_required"
frontend_owner: "mmg"
frontend_ref: ""
target_release: ""
@@ -7,7 +7,7 @@ author: "wx"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_status: "not_required"
frontend_owner: "mmg"
frontend_ref: ""
target_release: ""
@@ -0,0 +1,877 @@
---
schema: "hl-changelog/v2"
ticket: "6350"
title: "行政区划主数据与三级联动"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
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 已部署 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
>
> **影响范围**: 管理后台行政区划维护、省/市/县三级选择器和历史值父链回显
---
## ⚠️ 关键变化
- 本次补文档不改变 TEST 上已经部署的接口行为;它把原 Changelog 中分散的说明整理为可直接联调的完整契约。
- 前端不得再把行政区划 ID 当 JavaScript number:响应中的所有行政区划 ID 均为 JSON string,缺失层级保持真正的 `null`。
- 三级联动不允许通过行政区编码截位推断父子关系;必须逐级调用 `children`,编辑回显先调用 `path`。
- `PREFECTURE` 是第二层导航层,不保证一定是可选择的法定地级行政区;直辖市等链路会返回 `AGGREGATION` 聚合节点,必须继续下钻且不得作为最终业务值。
- 当前没有单节点详情接口,读响应也不返回 `extension`;更新接口省略或传 `null` 都会清空该字段,前端不能把它理解成“保持原值”。
- 业务失败可能仍使用 HTTP 200,调用方必须同时判断响应体 `code`、`success`、`message` 和 `data`。
---
## 一、背景
管理后台需要一套统一的行政区划主数据能力,同时支持:
- 省、市、县三级选择器逐级加载;
- 编辑历史业务数据时,从任意节点恢复完整父链;
- 具有专用权限的管理员创建自定义行政区、带来源证据的新法定版本或导航聚合节点;
- 使用 `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 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员令牌>
Accept: application/json
```
根层省级列表:
```http
GET /admin/region/children HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员令牌>
Accept: application/json
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"parentId": "1",
"items": [
{
"id": "35",
"parentId": "1",
"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",
"navigable": true,
"selectable": true,
"hasChildren": true,
"currentlyEffective": true,
"status": "ACTIVE",
"validFrom": "2025-12-31",
"validTo": null,
"sortOrder": 10,
"rowVersion": 0
},
{
"id": "35",
"parentId": "1",
"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
},
{
"id": "376",
"parentId": "35",
"codeStandard": "GB/T 2260",
"regionCode": "110101",
"levelCode": "COUNTY",
"depth": 3,
"name": "东城区",
"shortName": null,
"nodeKind": "ADMINISTRATIVE",
"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
}
```
#### 空数据 / 降级响应
本接口不返回“成功但空父链”。目标不存在、父链不完整或超过当前三级安全边界时使用错误响应,不返回部分路径。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",
"parentId": "35",
"levelCode": "COUNTY",
"nodeKind": "ADMINISTRATIVE",
"name": "示例业务区",
"shortName": "示例区",
"navigable": true,
"selectable": true,
"validFrom": "2026-08-26",
"validTo": null,
"status": "ACTIVE",
"sortOrder": 10000,
"sourceVersion": "ADMIN-20260826",
"sourceRef": "工单或数据批次定位",
"extension": {
"businessNote": "示例扩展信息"
}
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": "2090000000000001001",
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
#### 空数据 / 降级响应
本接口成功时一定返回字符串 ID,不存在 `data=null` 的成功语义。失败时事务回滚且不创建节点;本接口没有“Redis 不可用仍受理写入”的公开降级承诺。
#### 错误响应
层级代码与父链派生深度不一致:
```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",
"name": "示例业务区(更新)",
"shortName": "示例区",
"navigable": true,
"selectable": true,
"validFrom": "2026-08-26",
"validTo": null,
"status": "ACTIVE",
"sortOrder": 10001,
"extension": {
"businessNote": "名称已更新"
},
"rowVersion": 0
}
```
#### 响应示例
```json
{
"code": 200,
"message": "行政区划更新成功",
"data": null,
"traceId": "a1b2c3d4-e5f6-7890",
"success": true
}
```
#### 空数据 / 降级响应
本接口成功时 `data=null` 是固定契约,调用方以 `code=200 && success=true` 判断成功。本接口没有“Redis 不可用仍受理写入”的公开降级承诺,也不会返回更新后的节点快照。
#### 错误响应
提交了已经过期的 `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 返回。
---
## 五、数据库行为
| 前端操作 | 外部可观察的持久化行为 | 失败行为 |
|---|---|---|
| 查询 `children` | 只读,不改变节点、版本或审计 | 不写入 |
| 查询 `path` | 只读,不改变节点、版本或审计 | 不写入,不返回部分父链 |
| 创建节点 | 成功新增一个节点,初始 `rowVersion=0`;数据库事务提交后异步刷新节点和父级列表缓存 | 参数、权限、父链、有效期或唯一性失败时不新增节点 |
| 更新节点 | 成功更新一个节点并令 `rowVersion+1`;数据库事务提交后异步刷新节点、旧/新父级及上层列表缓存 | 版本冲突或业务 guard 失败时原节点保持不变 |
写接口成功只证明数据库事务已经提交,不承诺紧随其后的第一个缓存读取已经反映新值。前端不得自行操作缓存;需要立即回显时应短暂重读,并始终以新的 `rowVersion` 为准。读取阶段 Redis 故障会降级数据库;写入口还受公共幂等保护,不能据此推导“Redis 故障时写入一定成功”。
---
## 六、边界行为
- 未登录:业务码 `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 | 典型接口与场景 |
|---:|---|---|
| `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` | 存在子节点的行政区划不能移动或变更层级 | `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` 格式错误 |
---
## 六.5、枚举 / 数据字典
### `levelCode`(行政区划层级)
**所属字段**: `AdministrativeRegionCreateReqVO.levelCode`、`AdministrativeRegionUpdateReqVO.levelCode`、`AdministrativeRegionItemVO.levelCode` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `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)
---
## 撤回
本次 #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
@@ -0,0 +1,114 @@
---
schema: "hl-changelog/v2"
ticket: "6392"
title: "供应商暂停合作与拉黑原因必填接口"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "49b071b0"
target_release: ""
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"
---
# 供应商暂停合作与拉黑原因必填接口
供应商管理新增两个窄状态命令:暂停合作与列入黑名单。服务端强制要求业务原因和调用方读取到的并发版本,并沿用可信管理员身份、专用平台权限、聚合锁、短窗幂等、行锁、状态机及同事务审计。
## 变更接口
| 方法 | 路径 | 允许来源状态 | 目标状态 |
|---|---|---|---|
| POST | `/admin/supplier/items/{supplierId}/suspend` | `ACTIVE` | `SUSPENDED` |
| POST | `/admin/supplier/items/{supplierId}/blacklist` | `ACTIVE`、`SUSPENDED` | `BLACKLIST` |
两个接口都要求真实 `SUPER_ADMIN` 身份且拥有 `supplier:status:manage` 平台权限。前端按钮可按角色和状态控制展示,但不能替代服务端门禁。
## 公共请求
```json
{
"reason": "供应商连续违约,暂停合作复核",
"expectedUpdateTime": "2026-08-26 15:40:00"
}
```
| 字段 | 类型 | 必填 | 规则 |
|---|---|---|---|
| `reason` | string | 是 | 去除首尾空白后必须非空,最长 500 字符;规范化后的原因为状态审计内容 |
| `expectedUpdateTime` | string | 是 | 调用方最近一次读取到的供应商 `updateTime`,格式 `yyyy-MM-dd HH:mm:ss` |
路径参数 `supplierId` 必须为正 Long。请求体不接受角色、操作者或目标状态,身份仅来自 Gateway 验证后的可信属性。
## 成功响应
暂停合作成功:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "1900000000000000001",
"status": "SUSPENDED",
"updateTime": "2026-08-26 15:40:01"
}
}
```
拉黑成功时结构相同,`status` 为 `BLACKLIST`。`supplierId` 固定为 JSON string;后续状态命令必须使用响应或详情中的新 `updateTime`,不能继续提交旧版本。
每次成功迁移与一条 `supplier_change_log` 状态审计在同一本地事务内提交。审计记录包含 `fromStatus`、`toStatus`、操作者、角色、Trace ID 和规范化后的 `reason`;审计写入失败时状态整体回滚。
## 失败语义
| 场景 | 业务码 | 结果 |
|---|---:|---|
| 未登录或 Token 无效 | `401` | Gateway/服务认证拒绝,零业务写入 |
| `reason` 缺失、空白、纯空格、超过 500 字符,或缺少版本 | `400` | 参数/服务双层拒绝,零业务写入 |
| 供应商不存在或已软删除 | `395001` | 零业务写入 |
| 非 `SUPER_ADMIN` 或缺少 `supplier:status:manage` | `395004` | 专用状态权限失败关闭,零业务写入 |
| suspend 来源不是 `ACTIVE`;blacklist 来源不是 `ACTIVE/SUSPENDED` | `395005` | 状态机拒绝,零业务写入 |
| `expectedUpdateTime` 与数据库秒级版本不一致 | `395014` | 状态和审计均不写入,调用方应刷新后重试 |
| 5 秒内同操作者、供应商和相同请求重复提交 | `100502` | 重复请求被拒绝,不产生第二条状态审计 |
统一响应可能以 HTTP 200 承载业务失败,客户端必须同时检查 `code`、`success`、`message` 和 `data`。
## 前端联调事项
- 在合作中供应商上提供“暂停合作”和“列入黑名单”;暂停合作供应商仅提供“列入黑名单”。其他状态不展示这两个动作。
- 点击动作后弹出必填原因输入框,前端限制 500 字符;提交详情或列表中最近一次读取到的 `updateTime`。
- `395014` 应提示数据已变化并刷新详情;`395005` 应刷新当前状态;`100502` 应提示不要重复提交。
- 成功后使用响应中的 `status` 和 `updateTime` 更新页面或重新拉取详情/列表。
- 前端显示控制不构成授权;不得从请求体传入操作者、角色或目标状态。
## 验证证据
- 代码:PR #6403 合并提交为 `3f3a0bf734737ca0e84ed1cfcad1801835cd46d5`;供应商目标测试 62 项通过,Resource 全量 2100 项通过、0 失败、0 错误(38 项既有条件跳过),Gateway 路由/认证 9 项通过,`hl-verify` 全部通过。
- 部署:原规范任务 `d08c52f6` 成功,目标/实际提交均为 `4b31612aeba5c0d3ac690cc6af15b66290bd8b48`;只读恢复绑定原任务期样本,5 个可验证样本均健康、3 个本地不可验证样本、0 个故障样本,没有重复部署。
- 当前制品:后续同服务规范任务 `da90f211` 将 TEST 更新为 `dev-v3@692547a5989ec1f8fa1b1495fc8dd501cddc0fe0`,该提交包含本工单合并提交;6/6 个任务期样本均为双进程、Nacos 双 healthy/enabled 且端口匹配,0 不可验证、0 故障样本。本工单最终业务验收在该当前制品上完成。
- 真实 Gateway:验证 `ACTIVE→SUSPENDED→BLACKLIST` 和 `ACTIVE→BLACKLIST`,成功响应、详情状态、并发版本与同事务审计一致;原因首尾空白按规范化值入审计。
- 失败分支:未认证、真实 `CUSTOMIZER` 越权、原因缺失/空白/超长、缺版本、旧版本、DRAFT 来源、不存在对象、重复状态与短窗重复请求全部返回预期错误,主体版本、状态和审计计数均保持不变。
- 清理:仅创建 3 个随机 TEST 草稿,并以 ID、随机全名、当前状态三重条件设置 2 个 ACTIVE 夹具;未触碰既有供应商。验收后精确恢复 2 行为 DRAFT,再通过业务删除接口软删除全部 3 个夹具;有效供应商总量恢复为 4,`HL6392-` 活跃命名空间为 0。9 条脱敏审计按系统约定保留;两枚验收会话已注销,幂等键等待 6 秒自然过期,未手工修改 Redis。
## 撤回
1. 从最新 `dev-v3` 创建独立回退分支,执行 `git revert -m 1 --no-edit 3f3a0bf734737ca0e84ed1cfcad1801835cd46d5`,经评审 PR 合入;不要回退后续无关提交。
2. 通过 Deploy Panel API 对精确回退目标执行新鲜预检和显式部署 `hl-resource-service`,复核双实例、Nacos、日志以及既有供应商查询、建档、资料更新和审批链路。
3. 本次无 DDL、配置、Redis、MQ 或跨服务写入变更,不执行 schema、缓存或消息回退。
4. 已实际发生的 `SUSPENDED`、`BLACKLIST` 状态和审计不会随代码回退自动反转;禁止直接改库,只能由后续合法恢复合作/解除黑名单业务流程处理。
5. 回退后两个新增 POST 接口恢复不可用;前端在回退部署前隐藏/停用对应动作,并继续兼容既有供应商接口。
## 关联 / 联系人
- **Issue**: [#6392](https://git.1814.love:8443/wx/HL/issues/6392)
- **PR**: [#6403](https://git.1814.love:8443/wx/HL/pulls/6403)
- **合并提交**: [3f3a0bf73](https://git.1814.love:8443/wx/HL/commit/3f3a0bf734737ca0e84ed1cfcad1801835cd46d5)
- **后端负责人**: @lc
@@ -1,7 +1,7 @@
---
schema: "hl-changelog/v2"
ticket: "6397"
title: "供应商注册合同聚合信息"
title: "供应商注册合同聚合信息(已废弃)"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
@@ -10,32 +10,44 @@ gateway_status: "verified"
frontend_status: "verified"
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"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "c91163fc"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "c91163fc"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "f6b849eb"
target_release: ""
verified_at: "2026-09-02"
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.00"**,#7097 已补两位小数);预计毛利 = 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.00",#6929/#7097) |
| 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.00"`**(#7097 已补两位小数;JSON 为字符串,勿数值化)。**请勿用 `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: "not_required"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "44f51c13"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "a67bbd79"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "4d82bdb5"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "f3134941"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "908e691f"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "68ecb96b"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "ea1b9803"
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: "verified"
frontend_owner: "mmg"
frontend_ref: "a1a78906"
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,54 @@
---
schema: "hl-changelog/v2"
ticket: "7062"
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: "管理端已实现 status=ARCHIVED 时置灰修改入口,前端无需修改代码或接收通知。"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 供应商模块:清账归档后修改入口置灰
> **Issue**: [#7062](https://git.1814.love:8443/wx/HL/issues/7062)
> **影响范围**: 管理后台供应商列表、详情、账户和合同写入口
## 关键变化
管理端已实现:供应商 `status="ARCHIVED"` 时,详情查看入口保留,修改入口禁用并呈灰色。前端无需修改代码。
## 接口与字段
| 方法 | 路径 | 前端使用字段 | 请求 / 响应变化 |
|---|---|---|---|
| GET | `/admin/supplier/items/page` | `data.records[].status` | 无变化;`ARCHIVED` 表示已归档 |
| GET | `/admin/supplier/items/list` | `data[].status` | 无变化;`ARCHIVED` 表示已归档 |
| GET | `/admin/supplier/items/{supplierId}/basic-info/view` | `data.status` | 无变化;详情以该字段刷新置灰状态 |
- `status` 用于按钮门禁;`statusName` 只用于展示“已归档”。
- 本条不变更请求、响应字段或前端调用方式。
## 正确调用方式与边界
- 已归档供应商保持现有置灰和只读展示行为。
- 后端业务码 `395031` 继续作为绕过页面时的服务端兜底。
- 本工单只处理后端归档不可写缺口,不要求前端联调。
## 前端动作与当前状态
- 未提交:前端无需修改代码或接收通知。
- **当前状态:无需前端处理。**
## 关联 / 联系人
- **Issue**: [#7062](https://git.1814.love:8443/wx/HL/issues/7062)
- **后端负责人**: @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,269 @@
---
schema: "hl-changelog/v2"
ticket: "7066"
title: "团期子订单列表补应收总额 totalPrice"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "8980e197"
target_release: ""
verified_at: "2026-09-04"
status_note: "PR #7068 已合入 dev-v3(合并提交 e1ad8550b);2026-09-04 部署测试环境,网关实测 8 组用例全部通过(含取消单归 0)。#7097 复审收敛:应收字段名 totalAmount 未上线即收敛为 totalPrice(#7113 合入 dev-v3 合并提交 11b677198),前端以 totalPrice 为准"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 团期子订单: 出参新增应收总额 totalPrice
> **服务**: hl-order-service-v3
> **PR**: #7068 | **Issue**: #7066 | **合并提交**: `e1ad8550b`
> **影响范围**: 管理后台「团期详情 → 子订单」页签
---
## ⚠️ 关键变化
**前端不要再用 `paidAmount + balanceAmount` 反推应收总额,改读新增的 `totalPrice`。**
> **字段名收敛说明(#7097)**:本单初版字段名 `totalAmount`,与 #6929 已在 003 接口下发的 `totalPrice` 构成同义双字段;复审 #7097 在未上线前收敛为 `totalPrice`,`totalAmount` 已移除。本 changelog 全文按收敛后字段名 `totalPrice` 表述,字段语义与两位小数格式不变。
旧反推在两种场景下**偏大**:
1. `balanceAmount` 被钳在 ≥0(`OrderAmountUtil.calcBalance` 末行),超付或退款后已付大于应收时反推值偏大
2. 已取消子订单 `calcBalance` 直接返 0,反推得到的是已付而非应收
**这不是理论风险**:2026-09-04 测试环境实测,现存子订单 `paidAmount=3270.00`、`balanceAmount=0.00`,
而真实应收 `totalPrice=2943.00`——**旧算法多显示 327.00**。
---
## 一、背景
团期详情「子订单」页签每张卡片显示「已付 / 应收总额」,但列表接口出参此前没有应收总额字段,
前端只能自行反推。本次补齐该字段,口径与订单域既有展示口径统一。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期下子订单列表 | GET | `/v3/admin/order/group-batch/:groupBatchId/orders` | **出参新增字段** | 新增 `totalPrice` 应收总额 |
---
## 三、接口详情
### 1. 团期下子订单列表 `GET /v3/admin/order/group-batch/:groupBatchId/orders`
**VO**: `GroupBatchOrderItemRespVO`
#### 使用场景
团期详情「子订单」页签加载时调用,渲染每户卡片。卡片右下角「已付 ¥X / ¥Y」中的 Y 即本次新增的 `totalPrice`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 运营团期 ID(订单侧 `order_group_batch` 主键) |
| `includeTravelers` | Query | Boolean | ❌ | 缺省 false | 附出行人明细,证件号一律不返回 |
| `includeNeeds` | Query | Boolean | ❌ | 缺省 false | 附房数 / 房型 / 特殊需求 |
| `includeCancelled` | Query | Boolean | ❌ | 缺省 false | 是否含已取消子订单,缺省只返活跃集 |
#### 出参 `Result<List<GroupBatchOrderItemRespVO>>`
本次仅新增一个字段,其余 28 个字段不变:
| 字段 | 类型 | 说明 |
|------|------|------|
| `totalPrice` | 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",
"totalPrice": "2943.00"
}
]
}
```
#### 空数据 / 降级响应
团期下无子订单时返回空数组,不报错:
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": []
}
```
#### 错误响应
未携带管理端令牌:
```json
{
"code": 401,
"message": "缺少有效的 Authorization 头",
"success": false,
"data": null
}
```
#### 业务边界
- 只读接口,不产生任何写入
- `totalPrice` **不等于** `paidAmount + balanceAmount`,超付、退款、取消单三种情况下都会不等
- 已取消子订单(`includeCancelled=true` 才可见)的 `totalPrice` 与 `balanceAmount` 同为 0,闭合财务勾稽
- 金额一律 2 位小数(HALF_UP),与订单域其他接口格式一致
---
## 四、契约约束与正确调用方式
- **应收总额一律读 `totalPrice`**,不要自行用 `paidAmount + balanceAmount` 计算。
- **`totalPrice` 与 `balanceAmount` 是不同语义**:前者是「客户总共该付多少」,后者是「现在还差多少」。已付清时后者为 0,前者仍是原值。
- **三个金额字段是 JSON 字符串**,不是数字。
- 取消单场景下 `totalPrice` 归 0 是**有意设计**(对齐 `calcBalance` 的取消单归 0),用于闭合「应收 0 / 已付 0 / 已退 0 / 待收 0」的勾稽链,不是缺陷。
---
## 五、数据库行为
本接口只读,**不产生任何写入**,也不涉及表结构调整。`totalPrice` 由既有字段实时计算,不新增存储列。
---
## 六、边界行为
- 超付单 → `totalPrice` 为真实应收,小于 `paidAmount`
- 取消单 → `totalPrice` 归 0
- 团期无子订单 → 返回 `[]`
- 未登录 → 401(网关拦截)
---
## 六.5、枚举 / 数据字典
本次无新增枚举。
---
## 六.6、修改前后对比
| 项 | 变更前 | 变更后 |
|---|---|---|
| 出参字段数 | 28 | **29** |
| 应收总额 | **无字段**,前端用 `paidAmount + balanceAmount` 反推 | 新增 `totalPrice`,服务端按既有 `payableForDisplay` 口径给出 |
| 超付单显示 | 反推值偏大(实测多 327.00) | `totalPrice` 为真实应收 |
| 取消单显示 | 反推得到已付金额,非应收 | `totalPrice` 归 0,与 `balanceAmount` 一致 |
| `paidAmount` / `balanceAmount` | — | **语义与取值均不变** |
| 路径 / 入参 | — | **不变** |
## 六.7、影响评估
| 维度 | 评估 |
|---|---|
| 兼容性 | **纯 additive**,老调用方不读新字段即无感,无破坏性变更 |
| 前端 | 需改为读 `totalPrice`;不改也不会报错,但超付 / 取消单场景显示值偏大 |
| 数据 | 无 DDL、无迁移、无回填;`totalPrice` 实时计算不落库 |
| 性能 | 无额外查询,复用同一 `OrderInfo` 实体计算,零新增 IO |
| 回滚 | 移除字段即可,无数据侧残留 |
| 风险 | 低。口径复用 `AdjustmentService` 已在生产使用的既有方法,未新造公式 |
## 七、不影响范围
- **零影响**:`paidAmount` / `balanceAmount` 的现有语义与取值
- **零影响**:其余 28 个出参字段
- **零影响**:老调用方——纯新增字段,不读即无感
- **未新建端点**,未改动路径与入参
---
## 八、测试环境已验证
✅ 2026-09-04 于测试环境网关实测,真实鉴权(管理端 admin)。
- 网关 `https://api.test.1814.love`,分支 `dev-v3`,合并提交 `e1ad8550b`
- 部署方式:双实例滚动更新(8186 → 8086),各 11s 就绪,零停机
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| 1 | 字段上线 | 出参含 `totalPrice` | ✅ 字段数 27 → 29 |
| 2 | 真实数据口径 | `totalPrice` 为真实应收 | ✅ `2943.00`;旧反推得 `3270.00`,**偏差 327.00** |
| 3 | 序列化格式 | 字符串、2 位小数 | ✅ `"totalPrice":"2943.00"` |
| 4 | `includeCancelled=true` | 正常返回 | ✅ 返回 1 条 |
| 5 | 无 Authorization | 401 | ✅ `401 缺少有效的 Authorization 头` |
| 6 | 老字段回归 | 未受影响 | ✅ 8 个字段逐一核对一致 |
| 7 | 缺省过滤取消单 | 取消单不出现 | ✅ `GET .../orders` 返回 0 条 |
| 8 | **取消单归 0** | `totalPrice = 0` 且与 `balanceAmount` 一致 | ✅ `totalPrice="0.00"`、`balanceAmount="0.00"`、`paidAmount="3270.00"` |
**部署前基线对照**:同一接口部署前出参 27 字段、无 `totalPrice`,确认变更确实生效。
**本地单测**:`GroupBatchConverterTest` 新增 5 例(正常单 / 含增项优惠 / **超付单断言反推不等** / **取消单归 0** / 2 位小数);
本地全量 **750 例全过**。
**取消单分支已实测**(用例 7–8):验收期间该子订单被退单,恰好提供了取消单样本。
实测 `totalPrice="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: "verified"
frontend_owner: "mmg"
frontend_ref: "f6ddd94c"
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,556 @@
---
schema: "hl-changelog/v2"
ticket: "7070"
title: "房务列表/详情补产品类型标签 productType"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "7476250d"
target_release: "hl-ui@7476250d"
verified_at: "2026-09-04"
status_note: "2026-09-04 测试服部署 dev-v3@19ca3d702;详情/待办/抢单池 productType 网关实测与订单一致;前端 mmg 已 verified。#7098 复审:productType 筛选下推 SQL(抢单池/我的接单),我的接单 productType 入参此前被静默忽略现生效;PR #7117 合入 dev-v3(合并提交 cd21ae0)"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 房务: 各订单列表与详情补产品类型标签 productType
> **服务**: hl-order-service-v3
> **PR**: [#7077](https://git.1814.love:8443/wx/HL/pulls/7077) / [#7084](https://git.1814.love:8443/wx/HL/pulls/7084) / [#7117](https://git.1814.love:8443/wx/HL/pulls/7117)(#7098 复审)
> **Issue**: [#7070](https://git.1814.love:8443/wx/HL/issues/7070) / [#7098](https://git.1814.love:8443/wx/HL/issues/7098)(复审)
> **日期**: 2026-09-04
> **影响范围**: 管理后台房务——抢单池、我的接单、订单详情、待办列表、日历下钻、月度对账订单明细、需求历史
---
## ⚠️ 关键变化
🔧 房务各订单维度列表行/详情新增或接通 `productType` 字段,前端据此渲染「核心订单 / 线路订单 / 定制订单 / 团期订单」标签。取值口径统一、稳定性提升(此前部分场景在产品服务不可用时返回空,现已消除该空值)。`productType` 为 null 的订单前端不渲染标签。
> **复审收敛说明(#7098)**:初版 `productType` 筛选在查询返回后按当前页内存匹配,且「我的接单」接口的 `productType` 入参未透传(被静默忽略)。#7098 复审将筛选下推为 SQL 条件(MyBatis-Plus WrapperX 条件 eq),跨页结果精确;「我的接单」`productType` 入参现生效。内部同时收敛 `TodoEnrichment` 为单一构造器并补 5 处断言护栏,无对外 API 变更。
---
## 一、背景(选填)
房务在抢单、配房、对账时需要直观区分订单产品类型。此前抢单池/我的接单/详情虽能取到产品类型,但在产品服务不可用时可能返回空;待办/日历/对账/需求历史则完全没有该字段。本次统一为订单主数据直读并补齐缺失场景。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 抢单池列表 | GET | `/v3/admin/order/grab-pool/hotel-requirements` | 修改接口 | `productType` 取值口径统一,稳定性提升 |
| 2 | 我的接单列表 | GET | `/v3/admin/order/grab-pool/my-claims/hotel` | 修改接口 | `productType` 取值口径统一 |
| 3 | 房务订单详情 | GET | `/admin/house/orders/{orderId}` | 修改接口 | `order.productType` 取值口径统一 |
| 4 | 房务待办列表 | GET | `/v3/admin/order/todos` | 修改接口 | 行项新增出参 `productType` |
| 5 | 日历某天下钻 | GET | `/admin/house/calendar/day` | 修改接口 | 单团项新增出参 `productType` |
| 6 | 月度对账订单明细 | GET | `/v3/admin/house/reconciliation/monthly/hotel-orders` | 修改接口 | 订单明细行新增出参 `productType` |
| 7 | 需求历史列表 | GET | `/admin/house/orders/{orderId}/requirement-history` | 修改接口 | 历史版本项新增出参 `productType` |
---
## 三、接口详情
### 1. 抢单池列表 `GET /v3/admin/order/grab-pool/hotel-requirements`
**VO**: `HouseGrabPageItemRespVO`
#### 使用场景
房务打开抢单池页,浏览待抢的用房需求卡片。本变更让每张卡片可稳定显示产品类型标签。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| page | Query | Integer | 否 | 默认 1 | 页码 |
| pageSize | Query | Integer | 否 | 默认 20 | 每页条数 |
| keyword | Query | String | 否 | ≤32 字 | 订单号/团号/客人/产品名模糊 |
| productType | Query | String | 否 | CORE/ROUTE/CUSTOM/GROUP | 产品类型筛选(既有入参,语义不变) |
| productName | Query | String | 否 | - | 产品名精准 |
| consultantId | Query | Long | 否 | - | 定制师筛选 |
| guestName | Query | String | 否 | - | 客人模糊 |
| departDateFrom | Query | Date | 否 | yyyy-MM-dd | 出发日起 |
| departDateTo | Query | Date | 否 | yyyy-MM-dd | 出发日止 |
| sortBy | Query | String | 否 | - | 排序 |
#### 出参
| 字段 | 类型 | 说明 |
|------|------|------|
| records[].productType | String | 产品类型枚举值 CORE/ROUTE/CUSTOM/GROUP;取值稳定(不再受产品服务可用性影响);历史数据为 null 时前端不渲染 |
| records[].productNo | String | 产品编号(不受本变更影响) |
#### 请求示例
```http
GET /v3/admin/order/grab-pool/hotel-requirements?page=1&pageSize=10 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员token>
```
(GET 无请求体)
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"total": 10,
"records": [
{ "orderId": "30456", "orderNo": "HL20260901101825666", "productType": "CORE", "productNo": "C-2026-001" }
]
}
}
```
#### 空数据 / 降级响应
`productType` 为 null(历史数据)时该字段返回 null,前端不渲染标签;列表其它字段不受影响。
#### 错误响应
```json
{
"code": 401,
"message": "未登录或登录已过期",
"success": false,
"data": null
}
```
#### 业务边界
- `productType` 与订单一致,只读。
- `productType` 筛选入参语义不变;#7098 起由「查询后当前页内存匹配」改为 **SQL 下推**(WrapperX 条件 eq),跨页筛选结果精确。
### 2. 我的接单列表 `GET /v3/admin/order/grab-pool/my-claims/hotel`
**VO**: `HouseMyOrderItemRespVO`
#### 使用场景
房务查看「我的接单」列表。本变更让每行可稳定显示产品类型标签。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| page | Query | Integer | 否 | 默认 1 | 页码 |
| pageSize | Query | Integer | 否 | 默认 20 | 每页条数 |
| keyword | Query | String | 否 | - | 模糊关键词 |
| productType | Query | String | 否 | CORE/ROUTE/CUSTOM/GROUP | 产品类型筛选(既有入参,语义不变) |
| status | Query | String | 否 | - | 状态筛选 |
| productName | Query | String | 否 | - | 产品名筛选 |
| consultantId | Query | Long | 否 | - | 定制师筛选 |
| guestName | Query | String | 否 | - | 客人筛选 |
| departDateFrom | Query | Date | 否 | yyyy-MM-dd | 出发日起 |
| departDateTo | Query | Date | 否 | yyyy-MM-dd | 出发日止 |
#### 出参
| 字段 | 类型 | 说明 |
|------|------|------|
| list[].productType | String | 产品类型枚举值;取值稳定,与订单一致;null 时前端不渲染 |
#### 请求示例
```http
GET /v3/admin/order/grab-pool/my-claims/hotel?page=1&pageSize=10 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员token>
```
(GET 无请求体)
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"total": 3,
"list": [
{ "orderId": "30456", "orderNo": "HL2026...", "productType": "GROUP" }
]
}
}
```
#### 空数据 / 降级响应
订单信息取不到时 `productType` 落 null,列表照常返回,不阻断。
#### 错误响应
```json
{
"code": 401,
"message": "未登录或登录已过期",
"success": false,
"data": null
}
```
#### 业务边界
- 取值与订单一致,只读不改写。
- `productType` 筛选入参 #7098 起**生效并 SQL 下推**(此前被静默忽略)。
### 3. 房务订单详情 `GET /admin/house/orders/{orderId}`
**VO**: `HouseOrderDetailRespVO`
#### 使用场景
房务打开订单详情弹窗。本变更让详情头部订单块稳定显示产品类型标签。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| orderId | Path | Long | 是 | - | 订单 ID |
| requirementId | Query | Long | 否 | - | 指定需求版本(查看历史作废版本) |
#### 出参
| 字段 | 类型 | 说明 |
|------|------|------|
| order.productType | String | 产品类型枚举值;取值稳定,与订单一致;null 时前端不渲染 |
#### 请求示例
```http
GET /admin/house/orders/30456 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员token>
```
(GET 无请求体)
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"order": { "orderId": "30456", "orderNo": "HL2026...", "productType": "CUSTOM" }
}
}
```
#### 空数据 / 降级响应
订单不存在时订单块仅含 orderId,`productType` 为 null,不返回 500。
#### 错误响应
```json
{
"code": 401,
"message": "未登录或登录已过期",
"success": false,
"data": null
}
```
#### 业务边界
- 取值与订单一致,只读。
### 4. 房务待办列表 `GET /v3/admin/order/todos`
**VO**: `HouseTodoItemVO`
#### 使用场景
房务待办列表(订单聚合行)。本变更**新增** `productType` 出参,每行可显示产品类型标签。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| page | Query | Integer | 否 | 默认 1 | 页码 |
| pageSize | Query | Integer | 否 | 默认 20 | 每页条数 |
| scope | Query | String | 否 | mine/others/all | 范围,默认 all |
| todoType | Query | String | 否 | 多选逗号分隔 | 待办类型筛选 |
| status | Query | String | 否 | OPEN/RESOLVED | 状态筛选 |
| urgency | Query | String | 否 | danger/warn/normal | 紧急度筛选 |
| keyword | Query | String | 否 | ≤32 字 | 关键词 |
| orderId | Query | Long | 否 | - | 订单筛选 |
| ownerUserId | Query | Long | 否 | - | 归属房务筛选 |
| hotelId | Query | Long | 否 | - | 酒店筛选 |
#### 出参
| 字段 | 类型 | 说明 |
|------|------|------|
| list[].productType | String | **新增**:产品类型枚举值;无关联订单(酒店维度待办)或取不到订单时为 null,前端不渲染 |
#### 请求示例
```http
GET /v3/admin/order/todos?page=1&pageSize=10 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员token>
```
(GET 无请求体)
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"total": 10,
"list": [
{ "id": "70500", "orderNo": "HL2026...", "productType": "CORE" }
]
}
}
```
#### 空数据 / 降级响应
订单信息取不到时 `productType` 落 null,列表照常返回。
#### 错误响应
```json
{
"code": 401,
"message": "未登录或登录已过期",
"success": false,
"data": null
}
```
#### 业务边界
- 订单维度待办行与订单一致;酒店维度待办(无订单)productType 为 null。
### 5. 日历某天下钻 `GET /admin/house/calendar/day`
**VO**: `DayTourItemVO`
#### 使用场景
房务日历点击某天下钻查看当天各「团」明细。本变更**新增** `productType` 出参。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| date | Query | Date | 是 | yyyy-MM-dd | 下钻日期 |
| scope | Query | String | 否 | mine/all | 范围,默认 mine |
| status | Query | String | 否 | 多选 | 状态筛选 |
#### 出参
| 字段 | 类型 | 说明 |
|------|------|------|
| [].productType | String | **新增**:产品类型枚举值;团期折叠时取代表子订单;null 时前端不渲染 |
#### 请求示例
```http
GET /admin/house/calendar/day?date=2026-05-22&scope=all HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员token>
```
(GET 无请求体)
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{ "teamKey": "B:900", "isGroup": true, "orderNo": "HL100", "productType": "GROUP" }
]
}
```
#### 空数据 / 降级响应
当天无团返空列表;展示信息缺失时 `productType` 为 null。
#### 错误响应
```json
{
"code": 400,
"message": "scope 非法",
"success": false,
"data": null
}
```
#### 业务边界
- 团期折叠场景取代表子订单(订单号最小)的产品类型,与同团其它子订单一致。
### 6. 月度对账订单明细 `GET /v3/admin/house/reconciliation/monthly/hotel-orders`
**VO**: `HotelReconciliationOrderVO`
#### 使用场景
房务月度对账,查看某酒店下各订单明细行。本变更**新增** `productType` 出参。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| month | Query | String | 是 | yyyy-MM | 对账月份 |
| hotelId | Query | Long | 否 | - | 酒店筛选 |
| hotelName | Query | String | 否 | - | 酒店名筛选 |
#### 出参
| 字段 | 类型 | 说明 |
|------|------|------|
| [].productType | String | **新增**:产品类型枚举值;订单缺失时为 null,前端不渲染 |
#### 请求示例
```http
GET /v3/admin/house/reconciliation/monthly/hotel-orders?month=2026-08&hotelId=1001 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员token>
```
(GET 无请求体)
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{ "orderId": "30456", "orderNo": "HL2026...", "productName": "额吉的故乡", "productType": "CUSTOM" }
]
}
```
#### 空数据 / 降级响应
无配房订单返空列表;订单信息缺失时 `productType` 为 null。
#### 错误响应
```json
{
"code": 400,
"message": "month 格式非法",
"success": false,
"data": null
}
```
#### 业务边界
- 仅订单维度明细行新增;酒店汇总行无订单粒度,不含此字段。
### 7. 需求历史列表 `GET /admin/house/orders/{orderId}/requirement-history`
**VO**: `RequirementHistoryItem`
#### 使用场景
房务查看订单的用房需求历史版本。本变更**新增** `productType` 出参。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| orderId | Path | Long | 是 | - | 订单 ID |
| includeDiff | Query | Boolean | 否 | - | 是否含字段级 diff |
| onlyReturned | Query | Boolean | 否 | - | 仅看退回版本 |
#### 出参
| 字段 | 类型 | 说明 |
|------|------|------|
| list[].productType | String | **新增**:产品类型枚举值;订单缺失时为 null,前端不渲染 |
#### 请求示例
```http
GET /admin/house/orders/30456/requirement-history HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <管理员token>
```
(GET 无请求体)
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"list": [
{ "requirementId": "70123", "version": 2, "productType": "CORE" }
]
}
}
```
#### 空数据 / 降级响应
无历史返空列表;订单缺失时 `productType` 为 null。
#### 错误响应
```json
{
"code": 401,
"message": "未登录或登录已过期",
"success": false,
"data": null
}
```
#### 业务边界
- 各历史版本项产品类型同源同值(同订单)。
---
## 四、契约约束与正确调用方式
- `productType` 为**只读出参**,无需也不应在请求体中提交;抢单池/我的接单原有的 `productType` **筛选入参**语义不变,#7098 起下推 SQL 且「我的接单」入参生效。
- 前端按枚举值渲染标签,`null` 时不渲染。
---
## 六、边界行为
- 未登录 → 401(网关拦截)。
- 非法参数 → 400。
- 订单/需求信息缺失 → `productType` 落 null,不 500 不阻断列表/详情。
- 历史数据(无产品类型)→ 字段为 null,前端不渲染标签。
---
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| 抢单池/我的接单/详情 productType | 产品服务不可用时可能返回空 | 取值稳定,不受产品服务可用性影响 |
| 待办/日历/对账/需求历史 productType | 无此字段 | 新增返回 |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 产品类型取值稳定性 | 依赖产品服务实时可用 | 订单主数据直读,稳定 |
| 列表/详情标签覆盖 | 仅抢单池/我的接单/详情 | 待办/日历/对账/需求历史全覆盖 |
| `productType` 筛选实现 | 查询后按当前页内存匹配(「我的接单」入参被忽略) | SQL 下推条件 eq,跨页精确(「我的接单」入参生效) |
## 六.7、影响评估(修改/删除类必写)
- **是否破坏向后兼容**: 否(纯出参字段新增 + 已有字段取值口径不变)
- **前端是否必须同步上线**: 否(不渲染则忽略新字段;旧前端不受影响)
- **前端 workaround 清理点**: 无
## 七、不影响范围(显式声明)
- **仅影响**: 管理后台房务上述 7 个接口的出参。
- **零影响**: C 端接口、订单创建/调整、车务、结算、`productType` 筛选入参语义、酒店维度视图(无订单行)。
---
## 八、测试环境已验证
真实网关接口输出(api.test.1814.love),带 ✓ 标记:
```
GET /admin/house/orders/... (3 个订单) → 200 + order.productType=CORE 与订单一致 ✓
GET /v3/admin/order/todos → 200 + 10 行均含 productType(订单行=CORE) ✓
GET /v3/admin/order/grab-pool/hotel-requirements → 200 + 10 行 productType=CORE ✓
```
验证账号: 房务管理员(测试环境)。
---
## 十、相关文档
- 关联 Issue: [wx/HL#7070](https://git.1814.love:8443/wx/HL/issues/7070)
- 关联 PR: [wx/HL#7077](https://git.1814.love:8443/wx/HL/pulls/7077) / [wx/HL#7084](https://git.1814.love:8443/wx/HL/pulls/7084)
## 关联 / 联系人
### 链接
- **Issue**: [#7070](https://git.1814.love:8443/wx/HL/issues/7070)
- **PR**: [#7077](https://git.1814.love:8443/wx/HL/pulls/7077) / [#7084](https://git.1814.love:8443/wx/HL/pulls/7084)
- **Merge commit**: [19ca3d702](https://git.1814.love:8443/wx/HL/commit/19ca3d702)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,370 @@
---
schema: "hl-changelog/v2"
ticket: "7078"
title: "供应商企微审核中冻结资料修改"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "f4d3e1c9"
target_release: ""
verified_at: "2026-09-04"
status_note: '后端已部署并通过 TEST;前端需在 statusName="审核中" 时制灰供应商、账户、合同及冲突状态写操作。'
updated_at: "2026-09-04"
base: "dev-v3"
---
# 供应商模块:企微审核中冻结资料修改
> **服务**: `hl-resource-service`
> **Issue**: [#7078](https://git.1814.love:8443/wx/HL/issues/7078)
> **PR**: [#7086](https://git.1814.love:8443/wx/HL/pulls/7086)
> **日期**: 2026-09-04
> **影响范围**: 管理后台供应商详情、账户、合同和状态操作
## ⚠️ 关键变化
`statusName="审核中"` 时,前端统一制灰供应商资料、账户、合同及冲突状态写入口;详情和审批记录仍可查看,注册审批撤销等合法审批退出路径保持可用。`statusName` 是 #7069 已交付字段,本次未新增请求字段、响应字段或错误码。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 更新供应商 | PUT | `/admin/supplier/items/{supplierId}/update` | 行为修改 | 企微主体审批中返回 `395005` |
| 2 | 新增合同 | POST | `/admin/supplier/items/{supplierId}/contracts/add` | 行为修改 | 企微主体审批中返回 `395005` |
| 3 | 更新合同 | PUT | `/admin/supplier/items/{supplierId}/contracts/{contractId}/update` | 行为修改 | 企微主体审批中返回 `395005` |
| 4 | 删除合同 | DELETE | `/admin/supplier/items/{supplierId}/contracts/{contractId}/del` | 行为修改 | 企微主体审批中返回 `395005` |
| 5 | 恢复合作 | POST | `/admin/supplier/items/{supplierId}/resume` | 行为修改 | 状态审批中不得绕过审批恢复合作 |
## 三、接口详情
### 1. 更新供应商 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO → SupplierWriteRespVO`
#### 使用场景
修改供应商主体、类型、联系人、资质、评价或其他档案资料。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 | 供应商 ID |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 当前供应商版本 |
| `changeReason` | Body | String | 条件必填 | 最长 500 | 非草稿修改必填 |
| 资料字段 | Body | 原类型 | 否 | 沿用原契约 | 至少提交一个可变更字段 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data` | `SupplierWriteRespVO` | 成功响应结构不变 |
#### 请求示例
```json
{"remark":"更新备注","changeReason":"资料修正","expectedUpdateTime":"2026-09-04 10:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"supplierId":"2095000000000000001","status":"ACTIVE","updateTime":"2026-09-04 10:01:00"},"success":true}
```
#### 空数据 / 降级响应
写接口无空成功结果,也不提供降级成功。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- 企微注册或主体状态审批为 `PENDING` 时拒绝,供应商主档及聚合子项均保持不变。
- 其他权限、字段校验、并发和审计规则不变。
### 2. 新增合同 `POST /admin/supplier/items/{supplierId}/contracts/add`
**VO**: `SupplierContractCreateReqVO → SupplierContractRespVO`
#### 使用场景
为供应商独立登记一份线下合同。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 | 供应商 ID |
| `changeReason` | Body | String | 是 | 最长 500 | 登记原因 |
| 合同资料字段 | Body | 原类型 | 否 | 沿用原契约 | 名称、编号、日期、金额等字段不变 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data` | `SupplierContractRespVO` | 成功响应结构不变 |
#### 请求示例
```json
{"contractName":"年度框架合同","changeReason":"线下签署后登记"}
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"contractId":"2095000000000000002","contractName":"年度框架合同","updateTime":"2026-09-04 10:02:00"},"success":true}
```
#### 空数据 / 降级响应
除 `changeReason` 外的合同业务字段可空;成功仍返回合同对象,不提供降级成功。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- 企微注册或主体状态审批为 `PENDING` 时不新增合同或审计记录。
- 合同字段、权限、锁和幂等规则不变。
### 3. 更新合同 `PUT /admin/supplier/items/{supplierId}/contracts/{contractId}/update`
**VO**: `SupplierContractUpdateReqVO → SupplierContractRespVO`
#### 使用场景
完整替换一份已有合同的业务资料。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` / `contractId` | Path | String | 是 | 正整数 | 供应商和合同 ID |
| `changeReason` | Body | String | 是 | 最长 500 | 修改原因 |
| `expectedUpdateTime` | Body | String | 否 | `yyyy-MM-dd HH:mm:ss` | 合同并发版本 |
| 合同资料字段 | Body | 原类型 | 否 | 沿用原契约 | 完整替换语义不变 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data` | `SupplierContractRespVO` | 成功响应结构不变 |
#### 请求示例
```json
{"contractName":"年度框架合同(修订)","changeReason":"合同修订","expectedUpdateTime":"2026-09-04 10:02:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"contractId":"2095000000000000002","contractName":"年度框架合同(修订)","updateTime":"2026-09-04 10:03:00"},"success":true}
```
#### 空数据 / 降级响应
合同业务字段保持既有可空和完整替换语义;写接口不提供降级成功。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- 企微注册或主体状态审批为 `PENDING` 时,先于合同查询拒绝,不修改合同或审计记录。
- 合同归属、可选版本及无变化校验不变。
### 4. 删除合同 `DELETE /admin/supplier/items/{supplierId}/contracts/{contractId}/del`
**VO**: `SupplierContractDeleteReqVO → Void`
#### 使用场景
软删除一份已有合同。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` / `contractId` | Path | String | 是 | 正整数 | 供应商和合同 ID |
| `changeReason` | Body | String | 是 | 最长 500 | 删除原因 |
| `expectedUpdateTime` | Body | String | 否 | `yyyy-MM-dd HH:mm:ss` | 合同并发版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data` | null | 成功时为空 |
#### 请求示例
```json
{"changeReason":"合同登记作废","expectedUpdateTime":"2026-09-04 10:03:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","data":null,"success":true}
```
#### 空数据 / 降级响应
成功时 `data=null`;请求体不能省略,也不提供降级成功。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- 企微注册或主体状态审批为 `PENDING` 时,先于合同查询拒绝,不删除合同或新增审计记录。
- 合同归属、可选版本、软删除及幂等规则不变。
### 5. 恢复合作 `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` | 当前供应商版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.status` / `data.statusName` | String | 成功后为 `ACTIVE` / “合作中” |
| `data.updateTime` | String | 新并发版本 |
#### 请求示例
```json
{"reason":"恢复合作","expectedUpdateTime":"2026-09-04 10:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","data":{"supplierId":"2095000000000000003","status":"ACTIVE","statusName":"合作中","updateTime":"2026-09-04 10:04:00"},"success":true}
```
#### 空数据 / 降级响应
写接口无空成功结果,也不提供降级成功。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","data":null,"success":false}
```
#### 业务边界
- 暂停合作供应商已有企微主体状态审批时不得恢复合作,来源状态保持不变。
- 原有权限、来源状态、并发、审计和幂等规则不变。
## 四、契约约束与正确调用方式
- 后端以企微注册或主体状态审批 `PENDING` 为权威门禁;页面即使状态滞后,写请求仍可能返回 `395005`。
- 前端展示和制灰统一读取列表或详情的 `statusName`;`status` 继续用于筛选和生命周期判断。
- 账户新增和设默认接口的后端契约未变:注册审批中的 `VETTING`、状态审批来源的 `SUSPENDED` / `BLACKLIST` 均不满足账户写入所需的 `ACTIVE` 状态,仍返回既有 `395010`。
- 详情、账户详情、审批记录和变更记录等读取入口保持可用;注册审批撤销 `POST /admin/supplier/items/{supplierId}/approval/revoke` 保持可用。
## 五、数据库行为
- 本次没有数据库结构、初始化数据或迁移变化。
- 审核中被拒绝的请求不修改供应商、账户、合同、状态或审计数据。
## 六、边界行为
- 五类展示场景:注册、列入黑名单、解除黑名单、“终止且账清”和“拉黑且账清”在企微审核中均返回 `statusName="审核中"`。
- 审批结束后刷新列表或详情,再按最终生命周期状态恢复原有可用操作。
- 请求参数、成功响应、认证、权限、配置、Redis 和 MQ 契约均未改变。
## 六.6、修改前后对比
| 行为 | 修改前 | 修改后 |
|---|---|---|
| 企微主体审批中修改供应商资料 | 部分入口可能继续写入 | 返回 `395005`,业务数据不变 |
| 企微主体审批中新增、更新或删除合同 | 部分入口可能继续写入 | 返回 `395005`,业务数据不变 |
| 状态审批中恢复合作 | 可能绕过审批候选来源状态 | 返回 `395005`,来源状态不变 |
| 账户写入 | 既有规则要求供应商为 `ACTIVE` | 规则不变;前端在“审核中”时提前制灰 |
| 详情与合法审批退出 | 可用 | 保持可用 |
## 六.7、影响评估
- **是否破坏向后兼容**: 是;审批中的资料和合同写请求由可能成功调整为业务拒绝。
- **前端是否必须同步上线**: 是。
- **前端 workaround 清理点**: 不再仅按 `status` 放开写按钮,增加 `statusName="审核中"` 的统一制灰条件。
## 七、不影响范围
- **仅影响**: 企微注册或主体状态审批中的供应商资料、账户、合同和冲突状态操作。
- **零影响**: 详情及审批记录读取、注册审批撤销、企微回调与轮询、审批结束后的原有业务规则。
- **无变化**: 请求/响应字段、错误码定义、数据库、配置、Redis、MQ;后端仓库未修改前端代码。
## 八、测试环境已验证
部署提交:`6462397526ad61acfbcf503486d8507c9c1c7293`。
```text
GET /admin/supplier/items/list?limit=200 → 200;注册审批返回 VETTING / 审核中 ✓
GET /admin/supplier/items/{supplierId}/basic-info/view → 200;详情同为 VETTING / 审核中 ✓
PUT /admin/supplier/items/{supplierId}/update → 395005 ✓
POST /admin/supplier/items/{supplierId}/contracts/add → 395005 ✓
PUT /admin/supplier/items/{supplierId}/contracts/{contractId}/update → 395005 ✓
DELETE /admin/supplier/items/{supplierId}/contracts/{contractId}/del → 395005 ✓
POST /admin/supplier/items/{supplierId}/bank-accounts/add → 395010 ✓
```
上述拒绝请求前后,供应商版本、账户、合同、审批和审计的只读数据库快照哈希一致。TEST 当前没有四类状态审批的在途样本,因此没有发起企微审批造数;对应子类型、来源状态、“审核中”投影及全部写入口由合并提交中的精确自动化测试覆盖。
## 十、相关文档
- [Issue #7078](https://git.1814.love:8443/wx/HL/issues/7078)
- [PR #7086](https://git.1814.love:8443/wx/HL/pulls/7086)
- [前置展示契约 #7069](https://git.1814.love:8443/wx/HL/issues/7069)
## 前端动作与当前状态
- `statusName="审核中"` 时,统一制灰供应商资料编辑/保存、账户新增/设默认、合同新增/编辑/删除,以及暂停、恢复、拉黑、解除拉黑和归档等冲突写入口。
- 保留详情、审批记录、审批进度刷新和合法审批退出。
- **当前状态:待前端处理。**
## 关联 / 联系人
### 链接
- **Issue**: [#7078](https://git.1814.love:8443/wx/HL/issues/7078)
- **PR**: [#7086](https://git.1814.love:8443/wx/HL/pulls/7086)
- **Merge commit**: [6462397526ad61acfbcf503486d8507c9c1c7293](https://git.1814.love:8443/wx/HL/commit/6462397526ad61acfbcf503486d8507c9c1c7293)
### 联系人
- **后端负责人**: @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`)
@@ -0,0 +1,267 @@
---
schema: "hl-changelog/v2"
ticket: "7087"
title: "供应商新建修改必填资料校验"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "72d63c8e"
target_release: ""
verified_at: "2026-09-04"
status_note: "后端已部署并通过 TEST;前端需为供应商新建和编辑表单补齐主体资料、联系人及结算账户必填校验。"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 供应商模块:新建与修改补齐必填资料校验
`POST /admin/supplier/items/add` 新建时必须一次提交完整资料;`PUT /admin/supplier/items/{supplierId}/update` 仍是增量接口,但保存后的供应商聚合必须完整。前端需同步补齐表单必填标识和提交前校验。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 新建供应商草稿 | POST | `/admin/supplier/items/add` | 必填校验收紧 | 主体资料完整,且至少一名联系人、一个初始账户 |
| 2 | 修改供应商资料 | PUT | `/admin/supplier/items/{supplierId}/update` | 聚合完整性校验 | 省略字段保留现值;显式空类型、联系人或账户拒绝 |
## 三、接口详情
### 1. 新建供应商草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO → SupplierWriteRespVO`
#### 使用场景
管理端完成供应商新建表单后保存草稿。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `fullName` | Body | String | 是 | 非空,最长 500 | 供应商全称 |
| `taxNo` | Body | String | 是 | 合法主体证件号 | 统一社会信用代码 |
| `types` | Body | Array | 是 | 至少 1 项,最多 15 项 | `typeCode` 取 `supplier_type` 生效字典值 |
| `legalRepresentative` | Body | String | 是 | 非空 | 法定代表人 |
| `legalRepresentativeIdNo` | Body | String | 是 | 合法 18 位居民身份证号 | 法人身份证号 |
| `legalRepresentativeIdCardFrontUrl` | Body | String | 是 | 公网 HTTPS 永久地址 | 身份证人像面 |
| `legalRepresentativeIdCardBackUrl` | Body | String | 是 | 公网 HTTPS 永久地址 | 身份证国徽面 |
| `contactPhone` | Body | String | 是 | 合法手机号、座机或 400/800 号码 | 法人联系电话 |
| `establishDate` | Body | String | 是 | `yyyy-MM-dd`,不得晚于当天 | 成立日期 |
| `balance` | Body | Number | 是 | 最多 16 位整数、2 位小数 | 余额,可为正数、0 或负数 |
| `paymentType` | Body | String | 是 | `supplier_payment_type` 生效字典值 | 支付类型 |
| `mainCooperation` | Body | String | 是 | 非空 | 主要合作内容 |
| `licenseImageUrl` | Body | String | 是 | 非空 | 营业执照影像地址 |
| `address` | Body | String | 是 | 非空,最长 500 | 注册地址 |
| `contacts` | Body | Array | 是 | 至少 1 项,最多 100 项 | 每项填写姓名、电话及 `sup_content_role` 角色 |
| `initialAccounts` | Body | Array | 是 | 当前必须且只能 1 项 | 每项填写账户类型、银行和账号 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 供应商 ID |
| `data.supplierNo` | String | 供应商编号 |
| `data.status` | String | 新建成功为 `DRAFT` |
| `data.initialAccounts` | Array | 初始账户摘要 |
| `data.updateTime` | String | 后续修改使用的并发版本 |
#### 请求示例
```json
{
"fullName": "示例供应商有限公司",
"taxNo": "91350211M000100Y46",
"types": [{"typeCode": "HOTEL"}],
"legalRepresentative": "张三",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://example.com/supplier/id-front.jpg",
"legalRepresentativeIdCardBackUrl": "https://example.com/supplier/id-back.jpg",
"contactPhone": "13800138000",
"establishDate": "2020-01-02",
"balance": 0,
"paymentType": "1",
"mainCooperation": "酒店资源合作",
"licenseImageUrl": "https://example.com/supplier/license.jpg",
"address": "厦门市思明区示例路 1 号",
"contacts": [{"contactName": "李四", "contactPhone": "13800138001", "contactRole": "contentBus", "isPrimary": true}],
"initialAccounts": [{"accountType": "CORPORATE", "bankName": "示例银行", "accountNo": "6222000012345678"}]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2095000000000000001",
"supplierNo": "SUP2095000000000000001",
"status": "DRAFT",
"initialAccounts": [{"accountId": "2095000000000000002", "status": "DRAFT"}],
"updateTime": "2026-09-04 16:48:49"
}
}
```
#### 空数据 / 降级响应
写接口没有空数据成功或降级成功;任一必填项缺失时返回失败响应。
#### 错误响应
```json
{"code":400,"message":"联系人不能为空","data":null,"success":false}
```
#### 业务边界
- 未登录返回业务码 `401`;写入仍要求 `FINANCE` 或 `SUPER_ADMIN` 及 `supplier:create` 权限。
- `types`、`contacts`、`initialAccounts` 传 `null`、省略或空数组均视为缺失。
- 校验失败不创建供应商或子项;响应可能使用 HTTP 200,必须同时检查 `code` 与 `success`。
### 2. 修改供应商资料 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO → SupplierWriteRespVO`
#### 使用场景
管理端编辑既有供应商资料并按最新版本保存。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID | 目标供应商 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 取详情最新 `updateTime` |
| 主体必填资料 | Body | 原类型 | 条件必填 | 与新建接口相同 | 省略表示保留现值;保存后的有效值必须完整 |
| `types` | Body | Array | 条件必填 | 显式提交时至少 1 项 | 省略保留存量,`[]` 拒绝 |
| `contacts` | Body | Array | 条件必填 | 显式提交时至少 1 项 | 省略保留存量,`[]` 拒绝 |
| `initialAccounts` | Body | Array | 条件必填 | 显式提交时当前必须且只能 1 项 | 省略保留存量,`[]` 拒绝 |
| `changeReason` | Body | String | 条件必填 | 最长 500 | `DRAFT` 可省略,其他可修改状态沿用既有规则 |
主体必填资料指:`fullName`、`taxNo`、`legalRepresentative`、`legalRepresentativeIdNo`、`legalRepresentativeIdCardFrontUrl`、`legalRepresentativeIdCardBackUrl`、`contactPhone`、`establishDate`、`balance`、`paymentType`、`mainCooperation`、`licenseImageUrl`、`address`。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 供应商 ID |
| `data.status` | String | 保存后的状态 |
| `data.initialAccounts` | Array | 当前初始账户摘要 |
| `data.updateTime` | String | 保存后的新并发版本 |
#### 请求示例
```json
{
"shortName": "示例供应商",
"expectedUpdateTime": "2026-09-04 16:48:49"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2095000000000000001",
"status": "DRAFT",
"initialAccounts": [{"accountId": "2095000000000000002", "status": "DRAFT"}],
"updateTime": "2026-09-04 16:49:16"
}
}
```
#### 空数据 / 降级响应
省略未修改字段会保留已存值;接口没有空数据成功或降级成功。
#### 错误响应
```json
{"code":400,"message":"供应商类型不能为空","data":null,"success":false}
```
#### 业务边界
- 写入仍要求 `FINANCE` 或 `SUPER_ADMIN` 及 `supplier:update` 权限。
- 保存前按“请求值 + 已存值”校验完整聚合;存量资料缺项时,必须补齐后才能保存其他修改。
- 非草稿账户继续走独立账户审批;本次不改变状态、并发、审批、幂等或审计规则。
- `expectedUpdateTime` 过期返回既有业务码 `395014`,失败不更新任何聚合数据。
## 四、契约约束与正确调用方式(接口类必写)
| 场景 | 正确调用 |
|---|---|
| 新建 | 一次提交全部主体必填资料、非空 `types`、非空 `contacts` 和一个 `initialAccounts` |
| 修改普通字段 | 先查询详情;已存聚合完整时,只提交变化字段和最新 `expectedUpdateTime` |
| 修改关系快照 | 提交完整非空快照;不修改关系时省略对应字段 |
| 字典字段 | 提交字典接口返回的 `dictValue`,不要提交中文标签或前端硬编码默认值 |
支付类型当前字典值为 `1`(现付)、`2`(签单)、`3`(月付);供应商类型和联系人角色分别从 `supplier_type`、`sup_content_role` 动态字典读取。
## 五、数据库行为
- 校验失败不新增或更新供应商聚合数据。
- 本次没有表结构、字段非空约束、数据迁移或历史数据回填。
## 六、边界行为
- 业务失败可能仍为 HTTP 200;前端必须检查 `success=false` 和业务 `code/message`。
- 必填文本为空白、必填值为 `null`、必填集合为空时均拒绝。
- 更新省略字段表示保留,不等于清空;完整性按保存后的有效聚合判断。
- 超过列表上限、字典值失效、身份证/电话/URL 格式不合法时继续使用既有校验错误。
## 六.6、修改前后对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 新建缺主体资料 | 部分字段可缺失并保存草稿 | 任一约定主体必填资料缺失即拒绝 |
| 新建缺关系资料 | 类型、联系人或账户可能为空 | 三类均必须非空 |
| 修改显式空关系快照 | 可清空部分关系 | `types: []`、`contacts: []`、`initialAccounts: []` 均拒绝 |
| 修改省略未变化字段 | 保留现值 | 仍保留现值,并校验最终聚合完整性 |
## 六.7、影响评估
- **是否破坏向后兼容**: 是。依赖不完整草稿或显式清空类型、联系人、初始账户的旧请求会被拒绝。
- **前端是否必须同步上线**: 是。新建和编辑表单均需补齐必填标识、校验提示和提交数据。
- **前端 workaround 清理点**: 不再允许以空数组清空供应商类型、联系人或初始账户。
## 七、不影响范围
- 响应结构和字段名不变。
- 不改变供应商注册提交接口、权限矩阵、状态机、审批流、并发版本、幂等与审计语义。
- 不改变非草稿账户独立审批入口,也不修改数据库约束。
## 八、测试环境已验证
- 新建缺注册地址、联系人或账户分别返回业务码 `400`,且无业务数据写入;完整资料创建成功。
- 修改显式空类型、联系人、账户或清空营业执照分别返回业务码 `400`,资料与版本不变;完整存量上的增量修改成功。
- 匿名新建返回业务码 `401`;验收草稿已通过应用删除接口清理,有效聚合数据为零。
## 十、相关文档
- [后端 Issue #7087](https://git.1814.love:8443/wx/HL/issues/7087)
- [后端 PR #7093](https://git.1814.love:8443/wx/HL/pulls/7093)
- [历史契约 #6343](https://git.1814.love:8443/wx/HL/issues/6343):其中“新建可空类型、修改可显式清空类型”已被本工单取代。
## 关联 / 联系人
### 链接
- **Issue**: [#7087](https://git.1814.love:8443/wx/HL/issues/7087)
- **PR**: [#7093](https://git.1814.love:8443/wx/HL/pulls/7093)
- **Merge commit**: [e8b96e9a5](https://git.1814.love:8443/wx/HL/commit/e8b96e9a5dd0c75e65157bf889336ef740533da4)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,506 @@
---
schema: "hl-changelog/v2"
ticket: "7090"
title: "供应商信用等级与公司资料字段"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "32a1a289"
target_release: ""
verified_at: "2026-09-04"
status_note: "后端已部署并通过 TEST;前端需在供应商新建和编辑表单接入信用等级、公开电子邮箱、公司类型和办公地址。"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 供应商模块:信用等级与公司资料字段
供应商新建、编辑和注册提交支持 `creditLevel`、`publicEmail`、`companyType`、`officeAddress`,详情新增后三个字段回显。公司类型使用系统字典 `supplier_company_type`。
## ⚠️ 关键变化
- 新建省略 `creditLevel` 时后端默认值由 B 改为 A;可选值仍与供应商主列表筛选一致,为 A/B/C/D。
- 只有 `SUPER_ADMIN` 可以显式提交 `creditLevel`。其他角色可以展示详情值,但请求体必须省略该字段。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 查询供应商基本信息 | GET | `/admin/supplier/items/{supplierId}/basic-info/view` | 响应新增字段 | 回显公开邮箱、公司类型和办公地址 |
| 2 | 查询供应商公司类型字典 | GET | `/admin/dict/data/supplier_company_type` | 新增字典数据 | 返回固定的两个生效选项 |
| 3 | 新建供应商草稿 | POST | `/admin/supplier/items/add` | 请求新增字段 | 支持信用等级和三个可选公司资料字段 |
| 4 | 修改供应商资料 | PUT | `/admin/supplier/items/{supplierId}/update` | 请求新增字段 | 支持增量维护并保留省略字段 |
| 5 | 提交供应商注册 | POST | `/admin/supplier/items/{supplierId}/submit` | 请求新增字段 | 完整表单可携带新增字段进入审批 |
## 三、接口详情
### 1. 查询供应商基本信息 `GET /admin/supplier/items/{supplierId}/basic-info/view`
**VO**: `SupplierBasicInfoRespVO`
#### 使用场景
打开供应商详情或编辑页时读取当前字段值和并发版本。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID | 供应商 ID |
#### 出参 `Result<SupplierBasicInfoRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.creditLevel` | String | 信用等级 A/B/C/D |
| `data.publicEmail` | String/null | 公开电子邮箱 |
| `data.companyType` | String/null | `supplier_company_type` 的 `dictValue` |
| `data.officeAddress` | String/null | 办公地址 |
| `data.updateTime` | String | 编辑请求使用的最新并发版本 |
#### 请求示例
```http
GET /admin/supplier/items/2095000000000000001/basic-info/view
Authorization: Bearer <token>
```
无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2095000000000000001",
"creditLevel": "A",
"publicEmail": "service@example.com",
"companyType": "1",
"officeAddress": "呼和浩特市示例办公地址",
"updateTime": "2026-09-04 20:04:19"
}
}
```
#### 空数据 / 降级响应
存量数据或新建时未填写三个可选资料字段,分别返回 `null`;接口不使用默认文案代替空值。
#### 错误响应
```json
{"code":395001,"message":"供应商不存在","data":null,"success":false}
```
#### 业务边界
- 沿用供应商详情查看权限;业务失败可能仍使用 HTTP 200,必须检查响应体。
- `companyType` 返回字典值,不返回中文标签;前端用字典接口翻译。
- `creditLevel` 可以展示给有详情权限的用户,是否可编辑按当前角色控制。
### 2. 查询供应商公司类型字典 `GET /admin/dict/data/supplier_company_type`
**VO**: `Result<List<SysDictDataRespVO>>`
#### 使用场景
供应商新建或编辑页加载公司类型下拉选项。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplier_company_type` | Path | String | 是 | 固定字典类型 | 不要改成中文名称 |
#### 出参 `Result<List<SysDictDataRespVO>>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data[].dictValue` | String | 提交给供应商接口的值 |
| `data[].dictLabel` | String | 下拉展示文案 |
| `data[].sortOrder` | Number | 升序展示顺序 |
| `data[].status` | String | 当前均为 `ACTIVE` |
#### 请求示例
```http
GET /admin/dict/data/supplier_company_type
Authorization: Bearer <token>
```
无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{"dictValue":"1","dictLabel":"有限责任公司(自然人投资或控股)","sortOrder":10,"status":"ACTIVE"},
{"dictValue":"2","dictLabel":"国企控股","sortOrder":20,"status":"ACTIVE"}
]
}
```
#### 空数据 / 降级响应
后端已初始化两个选项;若请求失败或返回空数组,表单不要用本地硬编码选项替代。
#### 错误响应
```json
{"code":401,"message":"未登录或登录已过期","data":null,"success":false}
```
#### 业务边界
- 下拉框展示 `dictLabel`,请求只提交对应的 `dictValue`。
- 当前合法值严格为 `1`、`2`;不要提交中文标签。
- 字典接口需要管理端真实登录态。
### 3. 新建供应商草稿 `POST /admin/supplier/items/add`
**VO**: `SupplierDraftSaveReqVO → SupplierWriteRespVO`
#### 使用场景
管理端供应商新建表单保存完整草稿。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `creditLevel` | Body | String | 否 | A/B/C/D;仅 `SUPER_ADMIN` 可显式提交 | 省略时后端默认 A |
| `publicEmail` | Body | String | 否 | 邮箱格式,最长 100 | 公开电子邮箱 |
| `companyType` | Body | String | 否 | 生效的 `supplier_company_type` 值 | 公司类型 |
| `officeAddress` | Body | String | 否 | 最长 500 | 录入方式与 `address` 一致 |
| `fullName`、`taxNo` | Body | String | 是 | 沿用现有主体校验 | 供应商名称与主体证件号 |
| `types` | Body | Array | 是 | 至少 1 项 | 供应商类型完整集合 |
| 法人资料与 `contactPhone` | Body | String | 是 | 沿用现有格式校验 | 法人姓名、身份证三字段和联系电话 |
| `establishDate`、`address` | Body | String | 是 | 日期不得晚于当天;地址非空 | 成立日期和注册地址 |
| `balance`、`paymentType`、`mainCooperation` | Body | 混合 | 是 | 沿用现有规则 | 结算与合作资料 |
| `licenseImageUrl` | Body | String | 是 | 非空 | 营业执照影像 |
| `contacts`、`initialAccounts` | Body | Array | 是 | 至少一名联系人;当前一个初始账户 | 聚合子项 |
#### 出参 `Result<SupplierWriteRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 新供应商 ID |
| `data.status` | String | 新建成功为 `DRAFT` |
| `data.updateTime` | String | 后续编辑使用的并发版本 |
#### 请求示例
```json
{
"fullName": "示例供应商有限公司",
"taxNo": "91350211M000100Y46",
"types": [{"typeCode":"HOTEL"}],
"legalRepresentative": "张三",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://example.com/id-front.jpg",
"legalRepresentativeIdCardBackUrl": "https://example.com/id-back.jpg",
"contactPhone": "13800138000",
"establishDate": "2020-01-02",
"address": "呼和浩特市示例注册地址",
"creditLevel": "A",
"publicEmail": "service@example.com",
"companyType": "1",
"officeAddress": "呼和浩特市示例办公地址",
"balance": 0,
"paymentType": "1",
"mainCooperation": "酒店资源合作",
"licenseImageUrl": "https://example.com/license.jpg",
"contacts": [{"contactName":"李四","contactPhone":"13800138001","contactRole":"contentBus","isPrimary":true}],
"initialAccounts": [{"accountType":"CORPORATE","bankName":"示例银行","accountNo":"6222000012345678"}]
}
```
#### 响应示例
```json
{
"code":200,"message":"成功","success":true,
"data":{"supplierId":"2095000000000000001","status":"DRAFT","updateTime":"2026-09-04 20:04:19"}
}
```
#### 空数据 / 降级响应
省略 `publicEmail`、`companyType`、`officeAddress` 时保存为未填写,详情返回 `null`;无降级成功响应。
#### 错误响应
```json
{"code":395002,"message":"无权执行该供应商写操作","data":null,"success":false}
```
#### 业务边界
- `creditLevel` 省略时后端固定默认 A;不要再按旧行为假设默认 B。
- 只有 `SUPER_ADMIN` 可显式提交 `creditLevel`;其他可创建供应商的角色必须省略该字段。
- 三个公司资料字段均非必填;显式 `companyType` 必须命中当前生效字典。
- 非法邮箱、信用等级、公司类型或超过 500 字的办公地址返回业务码 `400`,失败不创建草稿。
### 4. 修改供应商资料 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO → SupplierWriteRespVO`
#### 使用场景
编辑页按详情中的最新版本增量保存供应商资料。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID | 目标供应商 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 取详情最新 `updateTime` |
| `creditLevel` | Body | String | 否 | A/B/C/D;仅 `SUPER_ADMIN` 可显式提交 | 省略保留现值 |
| `publicEmail` | Body | String | 否 | 邮箱格式,最长 100 | 省略保留现值 |
| `companyType` | Body | String | 否 | 生效的字典值 1/2 | 省略保留现值 |
| `officeAddress` | Body | String | 否 | 最长 500 | 省略保留现值 |
#### 出参 `Result<SupplierWriteRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.supplierId` | String | 供应商 ID |
| `data.status` | String | 保存后的状态 |
| `data.updateTime` | String | 保存后的新并发版本 |
#### 请求示例
```json
{
"creditLevel": "B",
"publicEmail": "service@example.com",
"companyType": "2",
"officeAddress": "呼和浩特市示例办公地址",
"expectedUpdateTime": "2026-09-04 20:04:19"
}
```
#### 响应示例
```json
{
"code":200,"message":"成功","success":true,
"data":{"supplierId":"2095000000000000001","status":"DRAFT","updateTime":"2026-09-04 20:05:01"}
}
```
#### 空数据 / 降级响应
省略新增字段表示保留现值;接口没有空数据成功或降级成功。
#### 错误响应
```json
{"code":395014,"message":"数据已被他人修改,请刷新后重试","data":null,"success":false}
```
#### 业务边界
- 保存前先读取详情并使用最新 `updateTime`;过期版本不写入任何字段。
- 只有 `SUPER_ADMIN` 可显式提交 `creditLevel`。其他角色即使值未改变也必须省略,否则返回 `395002`。
- 省略 `creditLevel` 不会重置为 A;A 只用于新建时的缺省值。
- `companyType` 必须提交字典值;邮箱、公司类型和办公地址的失败校验均不产生部分更新。
### 5. 提交供应商注册 `POST /admin/supplier/items/{supplierId}/submit`
**VO**: `SupplierSubmitReqVO → SupplierApprovalCommandRespVO`
#### 使用场景
供应商完整注册表单保存并提交审批。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID | 草稿供应商 |
| `expectedUpdateTime` | Body | String | 是 | 最新并发版本 | 防止覆盖并发编辑 |
| `creditLevel` | Body | String | 否 | A/B/C/D;仅 `SUPER_ADMIN` 可显式提交 | 省略保留草稿值 |
| `publicEmail` | Body | String | 否 | 邮箱格式,最长 100 | 省略保留草稿值 |
| `companyType` | Body | String | 否 | 生效的字典值 1/2 | 省略保留草稿值 |
| `officeAddress` | Body | String | 否 | 最长 500 | 省略保留草稿值 |
| 完整注册资料 | Body | 混合 | 是 | 沿用现有提交契约 | 主体、类型、联系人、账户及营业执照等 |
#### 出参 `Result<SupplierApprovalCommandRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.approvalLogId` | String | 审批记录 ID |
| `data.provider` | String | 审批提供方 |
| `data.approvalStatus` | String | 审批状态 |
| `data.submittedAt` | String | 提交时间 |
#### 请求示例
```json
{
"fullName": "示例供应商有限公司",
"taxNo": "91350211M000100Y46",
"types": [{"typeCode":"HOTEL"}],
"legalRepresentative": "张三",
"legalRepresentativeIdNo": "11010519491231002X",
"legalRepresentativeIdCardFrontUrl": "https://example.com/id-front.jpg",
"legalRepresentativeIdCardBackUrl": "https://example.com/id-back.jpg",
"contactPhone": "13800138000",
"establishDate": "2020-01-02",
"address": "呼和浩特市示例注册地址",
"creditLevel": "A",
"publicEmail": "service@example.com",
"companyType": "1",
"officeAddress": "呼和浩特市示例办公地址",
"balance": 0,
"paymentType": "1",
"mainCooperation": "酒店资源合作",
"licenseImageUrl": "https://example.com/license.jpg",
"contacts": [{"contactName":"李四","contactPhone":"13800138001","contactRole":"contentBus","isPrimary":true}],
"initialAccounts": [{"accountType":"CORPORATE","bankName":"示例银行","accountNo":"6222000012345678"}],
"expectedUpdateTime": "2026-09-04 20:05:01"
}
```
#### 响应示例
```json
{
"code":200,"message":"成功","success":true,
"data":{"approvalLogId":"2095000000000000010","provider":"WECOM","approvalStatus":"PENDING","submittedAt":"2026-09-04 20:05:02"}
}
```
#### 空数据 / 降级响应
写接口成功时返回审批受理结果;失败时 `data` 为 `null`,不会以空对象表示成功。
#### 错误响应
```json
{"code":395002,"message":"无权执行该供应商写操作","data":null,"success":false}
```
#### 业务边界
- 新增可选字段省略时沿用既有“保留草稿值”语义,不会清空已保存资料。
- 显式 `creditLevel` 仍只允许 `SUPER_ADMIN`;其他提交角色必须省略该字段。
- 公司类型按提交时生效字典校验;校验失败不会进入审批。
- 审批、幂等、重复主体确认和状态规则保持不变。
## 四、契约约束与正确调用方式(接口类必写)
| 场景 | 正确调用 |
|---|---|
| 新建页面 | 从主列表相同选项展示 A/B/C/D;初始选中 A |
| 非超级管理员 | 可展示信用等级,但新建、编辑、提交请求均省略 `creditLevel` |
| 公司类型 | 调字典接口,用 `dictLabel` 展示、用 `dictValue` 提交 |
| 编辑保存 | 携带详情最新 `updateTime`;只提交实际允许修改的字段 |
| 注册提交 | 三个公司资料字段省略时保留草稿值,不需重复拼接空值 |
## 五、数据库行为
| 前端提交 | 外部可观察结果 |
|---|---|
| 新建省略 `creditLevel` | 详情返回 `creditLevel: "A"` |
| 编辑或提交省略 `creditLevel` | 保留已有等级 |
| 新建省略三个公司资料字段 | 详情对应字段返回 `null` |
| 填写三个公司资料字段 | 保存成功后详情原值回显 |
| 校验或权限失败 | 不产生部分字段写入 |
## 六、边界行为
- 业务失败可能仍为 HTTP 200,必须同时检查 `code`、`success` 和 `message`。
- `creditLevel` 只接受大写 A/B/C/D;非法值返回业务码 `400`。
- `publicEmail` 最长 100,`officeAddress` 最长 500;超限或邮箱格式非法返回业务码 `400`。
- `companyType` 只接受当前生效字典值;字典不可用或值失效时失败关闭。
- 存量供应商三个新增资料字段没有自动推断或回填,详情可返回 `null`。
## 六.5、枚举 / 数据字典
### creditLevel
**所属字段**: `SupplierDraftSaveReqVO.creditLevel / SupplierUpdateReqVO.creditLevel / SupplierBasicInfoRespVO.creditLevel` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `A` | A 级 | 新建省略时的默认值 |
| `B` | B 级 | 与主列表筛选共用 |
| `C` | C 级 | 与主列表筛选共用 |
| `D` | D 级 | 与主列表筛选共用 |
### companyType(supplier_company_type)
**所属字段**: `SupplierDraftSaveReqVO.companyType / SupplierUpdateReqVO.companyType / SupplierBasicInfoRespVO.companyType` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `1` | 有限责任公司(自然人投资或控股) | 生效字典值 |
| `2` | 国企控股 | 生效字典值 |
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|---|---|---|
| `creditLevel` 请求 | 新建、编辑不能维护 | 新建、编辑、提交可选;仅 `SUPER_ADMIN` 可显式提交 |
| `publicEmail` | 无请求或详情字段 | 可选保存并在详情回显 |
| `companyType` | 无请求或详情字段 | 可选保存并按系统字典回显 |
| `officeAddress` | 无请求或详情字段 | 可选保存并在详情回显 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 新建省略信用等级 | 默认 B | 默认 A |
| 编辑省略新增字段 | 无对应字段 | 保留已有值 |
| 公司类型选项 | 无集中契约 | 从 `supplier_company_type` 动态读取 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。旧客户端省略新增字段仍可调用;新建信用缺省业务值由 B 调整为 A。
- **前端是否必须同步上线**: 是。新建和编辑页需展示四个字段并接入公司类型字典。
- **前端 workaround 清理点**: 不要硬编码公司类型;不要再把新建缺省信用等级当作 B;非 `SUPER_ADMIN` 不要序列化 `creditLevel`。
## 七、不影响范围
- **仅影响**: 管理后台供应商新建、编辑、详情和注册提交表单。
- **零影响**: 供应商主列表信用等级筛选选项、生命周期状态机、审批流程、账户流程和其他前端入口。
## 八、测试环境已验证
- 公司类型字典真实返回且仅返回两个约定的生效选项。
- 真实新建、编辑和详情调用已逐项保存并回读 A/B/C/D,以及公开邮箱、公司类型 1/2、办公地址。
- 真实新建省略四个字段后,信用等级回读为 A,三个可选公司资料字段回读为 `null`。
- 编辑省略 `creditLevel` 后保持既有等级;两条验收草稿均已通过业务删除接口清理。
- 注册提交沿用同一请求字段、权限和保存逻辑,已通过合并提交的后端契约测试;TEST 未创建外部审批单。
## 十、相关文档
- 关联 Issue: [wx/HL#7090](https://git.1814.love:8443/wx/HL/issues/7090)
- 关联 PR: [wx/HL#7107](https://git.1814.love:8443/wx/HL/pulls/7107)
## 关联 / 联系人
### 链接
- **Issue**: [#7090](https://git.1814.love:8443/wx/HL/issues/7090)
- **PR**: [#7107](https://git.1814.love:8443/wx/HL/pulls/7107)
- **Merge commit**: [c1102d62ecb81c24596fba91bba6e704da6e85ff](https://git.1814.love:8443/wx/HL/commit/c1102d62ecb81c24596fba91bba6e704da6e85ff)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,284 @@
---
schema: "hl-changelog/v2"
ticket: "7094"
title: "供应商账户驳回终态"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "2e8ed57c"
target_release: ""
verified_at: "2026-09-04"
status_note: "后端已部署并通过 TEST;前端需将 REJECTED 映射为已驳回且不可用。"
updated_at: "2026-09-04"
base: "dev-v3"
---
# 供应商模块:账户驳回返回明确不可用终态
企微驳回后,后续新增账户由 `PENDING` 改为 `REJECTED`;只有 `ACTIVE` 账户可用于收款或设为默认。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 账户列表 | GET | `/admin/supplier/items/{supplierId}/account-info/list` | 响应枚举扩展 | `bankAccounts[].status` 新增 `REJECTED` |
| 2 | 账户详情 | GET | `/admin/supplier/bank-accounts/{accountId}/view` | 响应枚举扩展 | `status` 新增 `REJECTED` |
| 3 | 新增账户 | POST | `/admin/supplier/items/{supplierId}/bank-accounts/add` | 重放结果修正 | 已驳回请求重放返回 `accountStatus=REJECTED` |
| 4 | 审批变更记录 | GET | `/admin/supplier/items/{supplierId}/approval-records/page` | 查询枚举扩展 | 账户记录支持按 `REJECTED` 筛选并显示“已驳回” |
## 三、接口详情
### 1. 账户列表 `GET /admin/supplier/items/{supplierId}/account-info/list`
**VO**: `SupplierAccountInfoRespVO`
#### 使用场景
账户管理弹窗刷新企微审批后的账户状态。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 | 供应商 ID |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.bankAccounts[].status` | String | 新增 `REJECTED`,表示已驳回且不可用 |
| `data.bankAccounts[].isDefault` | String | 驳回账户为 `NO` |
#### 请求示例
```http
GET /admin/supplier/items/2095000000000000001/account-info/list
Authorization: Bearer <token>
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"supplierId":"2095000000000000001","bankAccounts":[{"accountId":"2095000000000000002","status":"REJECTED","isDefault":"NO"}]}}
```
#### 空数据 / 降级响应
供应商没有可见账户时返回 `bankAccounts=[]`;接口不提供降级成功。
#### 错误响应
```json
{"code":395001,"message":"供应商不存在","data":null,"success":false}
```
#### 业务边界
- 前端只有在 `status=ACTIVE` 时才允许选择账户或显示默认操作。
### 2. 账户详情 `GET /admin/supplier/bank-accounts/{accountId}/view`
**VO**: `SupplierBankAccountDetailRespVO`
#### 使用场景
打开单个账户详情时展示当前终态。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `accountId` | Path | String | 是 | 正整数 | 账户 ID |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.status` | String | 新增 `REJECTED`,表示已驳回且不可用 |
| `data.isDefault` | String | 驳回账户为 `NO` |
#### 请求示例
```http
GET /admin/supplier/bank-accounts/2095000000000000002/view
Authorization: Bearer <token>
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"accountId":"2095000000000000002","status":"REJECTED","isDefault":"NO"}}
```
#### 空数据 / 降级响应
账户不存在或不可见时返回业务失败,不返回空成功详情。
#### 错误响应
```json
{"code":395001,"message":"供应商不存在","data":null,"success":false}
```
#### 业务边界
- `REJECTED` 只读可见,但不能设为默认账户。
### 3. 新增账户 `POST /admin/supplier/items/{supplierId}/bank-accounts/add`
**VO**: `SupplierBankAccountBatchCreateReqVO → List<BankAccountSubmitResultRespVO>`
#### 使用场景
新增账户提交企微审批,或以同一请求重放已完成结果。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 | 已生效供应商 ID |
| `accounts` | Body | Array | 是 | 1~50 项 | 请求结构不变 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data[].approvalStatus` | String | 已驳回终态为 `REJECTED` |
| `data[].accountStatus` | String | 已驳回终态由 `PENDING` 修正为 `REJECTED` |
#### 请求示例
```json
{"accounts":[{"accountType":"CORPORATE","bankName":"示例银行","accountNo":"6222000012345678"}]}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":[{"accountId":"2095000000000000002","approvalStatus":"REJECTED","accountStatus":"REJECTED","isDefault":"NO"}]}
```
#### 空数据 / 降级响应
写接口不返回空成功列表;新提交在企微完成前仍返回 `PENDING`。
#### 错误响应
```json
{"code":395010,"message":"请先完成供应商注册","data":null,"success":false}
```
#### 业务边界
- 仅已完成驳回的请求返回 `REJECTED`;企微撤销继续沿用既有 `PENDING` 语义。
### 4. 审批变更记录 `GET /admin/supplier/items/{supplierId}/approval-records/page`
**VO**: `SupplierApprovalRecordPageReqVO → PageResult<SupplierApprovalRecordRespVO>`
#### 使用场景
查询账户审批关联的状态变更记录。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 | 供应商 ID |
| `targetType` | Query | String | 否 | 查询账户时传 `ACCOUNT` | 目标类型 |
| `status` | Query | String | 否 | 新增 `REJECTED` | 变更后状态 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.records[].status` | String | 驳回记录为 `REJECTED` |
| `data.records[].statusName` | String | 驳回记录为“已驳回” |
#### 请求示例
```http
GET /admin/supplier/items/2095000000000000001/approval-records/page?targetType=ACCOUNT&status=REJECTED&page=1&pageSize=20
Authorization: Bearer <token>
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"records":[{"targetType":"ACCOUNT","status":"REJECTED","statusName":"已驳回"}],"total":1}}
```
#### 空数据 / 降级响应
无匹配记录时返回 `records=[]`;接口不提供降级成功。
#### 错误响应
```json
{"code":400,"message":"变更目标状态不合法","data":null,"success":false}
```
#### 业务边界
- `status=REJECTED` 配合 `targetType=ACCOUNT` 使用,不作为供应商主体生命周期筛选值。
## 四、契约约束与正确调用方式
1. 企微处理完成后刷新账户列表,以 `status` 判断账户是否可用。
2. 将 `REJECTED` 映射为“已驳回”,按不可用样式展示;只有 `ACTIVE` 可设为默认。
3. 必须检查统一响应的 `code` 和 `success`,不能只判断 HTTP 状态。
## 五、数据库行为
企微驳回完成后,账户最终可观察状态为 `REJECTED`;历史同类异常会同步收敛。将 `REJECTED` 账户设为默认会失败且不改变账户或原默认账户。
## 六、边界行为
- `PENDING`、`REJECTED`、`DISABLED` 均不可用,`ACTIVE` 才可用于收款。
- 企微撤销仍保留 `PENDING`;本次未新增错误码,也未改变请求字段。
## 六.6、修改前后对比
| 场景 | 修改前 | 修改后 |
|---|---|---|
| 企微驳回 | 账户仍返回 `PENDING` | 账户返回 `REJECTED` |
| 已驳回请求重放 | `accountStatus=PENDING` | `accountStatus=REJECTED` |
## 六.7、影响评估
- **是否破坏向后兼容**: 响应枚举扩展;前端需增加未知值处理。
- **前端是否必须同步上线**: 是。
- **前端 workaround 清理点**: 删除把企微驳回账户继续显示为“待审批”的兜底逻辑。
## 七、不影响范围
- 不改变供应商注册随单账户的驳回退草稿语义。
- 不改变企微撤销、审批通过、默认账户或停用流程。
- 不影响小程序接口;没有新增错误码。
## 八、测试环境已验证
- 列表和详情均返回业务成功,目标账户为 `status=REJECTED`、`isDefault=NO`。
- 将该账户设为默认返回 `code=395005`、`success=false`,账户与原默认账户均未变化。
- 企微已驳回且仍为 `PENDING` 的精确异常计数为 0。
## 十、相关文档
- [Issue #7094](https://git.1814.love:8443/wx/HL/issues/7094)
- [补充 Issue #7119](https://git.1814.love:8443/wx/HL/issues/7119)
- [PR #7118](https://git.1814.love:8443/wx/HL/pulls/7118)
- [补充 PR #7121](https://git.1814.love:8443/wx/HL/pulls/7121)
## 前端动作与当前状态
- 将 `REJECTED` 显示为“已驳回”且不可用,只为 `ACTIVE` 显示默认操作。
- **当前状态:待前端处理。**
## 关联 / 联系人
- **部署提交**: [f861074a84b50eb83fd67729b77f8208747a2095](https://git.1814.love:8443/wx/HL/commit/f861074a84b50eb83fd67729b77f8208747a2095)
- **后端负责人**: @lc
@@ -0,0 +1,746 @@
---
schema: "hl-changelog/v2"
ticket: "7100"
title: "团期退单户:提交退单申请 + 管理员审批(070 破坏性变更 + 072~075 新增)"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "4bc8dd4d"
target_release: ""
verified_at: "2026-09-05"
status_note: "已过网关真测(2026-09-05,admin 切 ADMIN 角色,5 端点全链路含提交/驳回/再提交/通过);含 #7126 预估口径修复后的复测"
updated_at: "2026-09-05"
base: "dev-v3"
---
# 团期: 退单户改为「提交申请 + 管理员审批」
> **服务**: hl-order-service-v3
> **PR**: #7106
> **Issue**: #7100
> **日期**: 2026-09-04
> **影响范围**: 管理后台团期详情「退单户」弹窗 + 新增退单审批中心
---
## ⚠️ 关键变化
**`POST .../sub-order/{orderId}/withdraw` 是破坏性变更:调用它不再退款,只建一张待审申请单。**
三条前端必读:
1. **响应体由 `Result<Void>` 变成对象**。老前端只看 `code` 不读 `data` 的话不会崩,但**提示语必须改**——
现在的语义是「已提交审核」,不是「已退款」。
2. **金额要显示两个数**:`paidAmount`(已付)和 `estimatedRefundAmount`(预计退)。
**两者可以不相等,而且早期阶段常常不等**——招募中 / 资源准备中退的是**订金应付额**,
与已付多少无关(TEST 实测:已付 ¥0、订金 ¥2000 的单,预计退与实退都是 ¥2000)。
**不要自己调 `/v3/admin/order/{id}/cancel-preview` 算**——那个接口恒按退改政策算,
与早期阶段的订金口径对不上。
3. **审批中心是新界面**:列表 → 详情 → 通过 / 取消退单(4 个新端点),入口按角色显隐,
仅 `ADMIN` / `SUPER_ADMIN` 可见,其余角色调用返 589530。
---
## 一、背景
原来的退单户是「点一下就退款」:提交那一刻订单即 `CANCELLED`、团期名额当场释放、退款单自动建,
招募中/资源准备中甚至免审直退。但业务要的是**可以取消退单,且取消后该户继续留在团期里走原流程**——
订单都取消了、名额可能已被新客户占走,这事根本做不到。
故改为**先挂申请单、批了才执行**:`PENDING` 期间订单 / 名额 / 钱三项零变动,
该户照常提需求、排房排车;管理员通过才真正退团,驳回则全程无痕、可再次提交。
退款口径**没有变**:仍按团期状态选模式(招募中 / 资源准备中全退定金,物资准备中 / 待出发按退改政策阶梯扣)。
变的只是「什么时候执行」和「谁点头」。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 退单户·提交退单申请 | POST | `/v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/withdraw` | 修改 | **破坏性**:由「直接退款」改为「建待审申请单」,响应体 Void → 对象 |
| 2 | 退单审批分页列表 | GET | `/v3/admin/order/group-batch/withdraw/page` | 新增 | 审批中心列表,缺省只返待审 |
| 3 | 退单申请详情 | GET | `/v3/admin/order/group-batch/withdraw/:approvalId` | 新增 | 含「提交时预估」与「当前预估」两个金额 |
| 4 | 退单审核通过 | POST | `/v3/admin/order/group-batch/withdraw/:approvalId/approve` | 新增 | 此刻才执行退团:订单取消 + 名额回落 + 退款 |
| 5 | 取消退单(驳回) | POST | `/v3/admin/order/group-batch/withdraw/:approvalId/reject` | 新增 | 该户继续留在团期中走原流程 |
---
## 三、接口详情
### 1. 退单户·提交退单申请 `POST /v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/withdraw`
**VO**: `WithdrawSubOrderReqVO` / `WithdrawSubOrderRespVO`
#### 使用场景
团期详情底部操作条点「退单户」→ 弹窗选一户(下拉数据仍用既有的
`GET /v3/admin/order/group-batch/:groupBatchId/orders`,后端没有另开候选接口)→ 填退团原因 →
点「提交退团审核」时调用。**调完钱不动**,等管理员在审批中心处理。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 团期主订单 ID |
| orderId | Path | Long | ✅ | - | 要退的子订单 ID(= 一户) |
| reason | Body | String | ❌ | ≤512 字 | 退团原因;不传后端记「团期退团」。整个 body 可省略 |
#### 出参 `Result<WithdrawSubOrderRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| approvalId | String | 退单审批单 ID(雪花,序列化为字符串),审批中心用它取详情 |
| paidAmount | BigDecimal | 该户已付款额,弹窗「已付 ¥5,000」取此列 |
| estimatedRefundAmount | BigDecimal | 预计退款额,弹窗「预计退 ¥3,500」取此列。`FULL_DEPOSIT` 时 = **订金应付额**(与已付无关),`POLICY` 时 = 已付额按政策扣减后;**实退以审批通过时重算为准** |
| refundMode | String | `FULL_DEPOSIT`=全退定金 / `POLICY`=按退改政策阶梯扣 |
| refundPolicy | Object | 退改政策明细,仅 `POLICY` 模式给(用于展示「距出发 6 天,扣 30%」),否则为 null |
| consultantName | String | 该户定制师姓名。**信息项,不是审批人**,前端不得暗示他要来点头 |
| warnings | String[] | 非阻断提示(如当前已满团,退单通过后将空出 1 户);无则为空数组 |
#### 请求示例
```json
{ "reason": "客户临时有事无法参团" }
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"approvalId": "1955009900112233",
"paidAmount": 5000.00,
"estimatedRefundAmount": 3500.00,
"refundMode": "POLICY",
"refundPolicy": { "policyName": "标准退改政策", "policyId": 12 },
"consultantName": "李雯",
"warnings": []
},
"success": true
}
```
#### 空数据 / 降级响应
写操作,无空数据形态。`refundPolicy` 在 `FULL_DEPOSIT` 阶段恒为 null(退订金不看政策),
此时 `estimatedRefundAmount` 是**订金应付额**,与 `paidAmount` 无关,两者不等属正常:
```json
{
"code": 200,
"data": {
"approvalId": "1955009900112234",
"paidAmount": 5000.00,
"estimatedRefundAmount": 2000.00,
"refundMode": "FULL_DEPOSIT",
"refundPolicy": null,
"warnings": []
},
"success": true
}
```
#### 错误响应
```json
{
"code": 589529,
"message": "该户已有退单审核在途,请勿重复提交",
"success": false,
"data": null
}
```
四类拒绝:
| code | 含义 |
|------|------|
| 589500 | 团期不存在 |
| 589501 | 团期状态不允许退团(出行中及以后,走售后退款) |
| 589512 | 子订单不属于该团期 |
| 589529 | 该户已有在途退单申请 |
#### 业务边界
- **零副作用**:本接口只 INSERT 一行审批单。订单状态、团期已报名人数/户数、退款单**三项分文未动**。
- **未付分文也能提交**,但**不等于退 0**:
- 招募中 / 资源准备中(`FULL_DEPOSIT`)退的是**订金应付额**,未付款的单照样会退订金并生成退款单——
这是订单侧既有口径(D3 决策),不是本次引入;如果这不符合业务预期,需要在订单侧另开工单讨论。
- 物资准备中 / 待出发(`POLICY`)以已付额为基数,未付则退 0、**不生成退款单**。
- 订金为 0 时 `warnings` 会提示「该户订金为 0…」——这类单提交得进去但**审批会被订单侧拒绝**。
- **出行后拒**:出行中 / 核算中 / 已结算 / 已取消一律 589501,且**在建单之前就拒**,不会留下批不掉的单。
- **幂等**:同一 `groupBatchId + orderId` 5 秒窗口内重复提交只成功一次;顺序重复由 589529 兜底。
- **金额会漂移**:`POLICY` 模式下「距出发天数」每天在变,本接口返回的是**提交时**的预估,仅供展示。
- **兼容**:body 整体可省略;老前端不读 `data` 也不会报错,但提示文案必须改。
---
### 2. 退单审批分页列表 `GET /v3/admin/order/group-batch/withdraw/page`
**VO**: `WithdrawApprovalListReqVO` / `WithdrawApprovalItemRespVO`
#### 使用场景
退单审批中心的列表页。缺省只返待审单,管理员进来就是「待我处理」的视图。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| approvalStatus | Query | String | ❌ | PENDING/APPROVED/REJECTED/ALL | 缺省 `PENDING`;查全部传 `ALL` |
| groupBatchId | Query | Long | ❌ | - | 按团期筛选 |
| keyword | Query | String | ❌ | - | 客户姓名 / 订单号模糊匹配(两者取并集) |
| createdFrom | Query | String | ❌ | yyyy-MM-dd | 提交时间起;格式非法则忽略该条件 |
| createdTo | Query | String | ❌ | yyyy-MM-dd | 提交时间止,含当日 |
| pageNo | Query | Integer | ❌ | ≥1,默认 1 | 页码 |
| pageSize | Query | Integer | ❌ | ≤100,默认 20 | 每页条数,超 100 截断 |
#### 出参 `Result<PageResult<WithdrawApprovalItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| approvalId | String | 退单审批单 ID |
| groupBatchId | String | 团期 ID |
| batchNo | String | 团期号 |
| productName | String | 产品名称 |
| departDate | String | 出发日期 yyyy-MM-dd |
| orderId | String | 子订单 ID |
| orderNo | String | 订单号 |
| customerName | String | 客户姓名 |
| participantCount | Integer | 该户人数(成人+儿童+小童+婴儿) |
| paidAmount | BigDecimal | 已付款额(提交时快照) |
| estimatedRefundAmount | BigDecimal | 预计退款额(提交时快照) |
| refundMode | String | `FULL_DEPOSIT` / `POLICY` |
| reason | String | 退团原因 |
| applicantName | String | 提交人姓名 |
| approvalStatus | String | `PENDING` / `APPROVED` / `REJECTED` |
| createdAt | DateTime | 提交时间 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/withdraw/page?approvalStatus=PENDING&pageNo=1&pageSize=20
```
#### 响应示例
```json
{
"code": 200,
"data": {
"records": [
{
"approvalId": "1955009900112233",
"batchNo": "GT-26-0007",
"productName": "小蒙马亲子团",
"departDate": "2026-10-01",
"orderNo": "GT-26-0099",
"customerName": "林婉清",
"participantCount": 2,
"paidAmount": 5000.00,
"estimatedRefundAmount": 3500.00,
"refundMode": "POLICY",
"reason": "临时有事无法参团",
"applicantName": "王磊",
"approvalStatus": "PENDING",
"createdAt": "2026-09-04 17:20:11"
}
],
"total": 1,
"page": 1,
"pageSize": 20
},
"success": true
}
```
#### 空数据 / 降级响应
无待审单,或 keyword 未命中任何客户/订单号时返回空页(**不是 null**):
```json
{ "code": 200, "data": { "records": [], "total": 0, "page": 1, "pageSize": 20 }, "success": true }
```
#### 错误响应
```json
{
"code": 589530,
"message": "仅管理员可处理退单审核",
"success": false,
"data": null
}
```
| code | 含义 |
|------|------|
| 589530 | 当前登录人不是管理员角色 |
#### 业务边界
- **鉴权**:仅 `ADMIN` / `SUPER_ADMIN` 放行;定制师 / 财务 / 房务 / 车务一律 589530。前端入口应按角色显隐,不要靠调用失败来判断。
- **排序**:按提交时间倒序。
- **性能**:整页的团期与子订单各批量取一次,不随行数放大查询。
- **日期容错**:`createdFrom` / `createdTo` 格式非法时**忽略该条件**并继续查询,不报错。
---
### 3. 退单申请详情 `GET /v3/admin/order/group-batch/withdraw/:approvalId`
**VO**: `WithdrawApprovalDetailRespVO`
#### 使用场景
审批中心点进某一单。管理员在这里看清楚「退谁、退多少、现在退多少」再决定通过还是取消。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| approvalId | Path | Long | ✅ | - | 退单审批单 ID |
#### 出参 `Result<WithdrawApprovalDetailRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| (列表行全部字段) | - | 见接口 2 的出参表 |
| currentEstimatedRefundAmount | BigDecimal | **当前**预计退款额(实时重算)。审批通过时按它执行 |
| currentRefundMode | String | 当前退款模式;团期状态推进后可能与提交时不同 |
| refundPolicy | Object | 当前退改政策明细(`POLICY` 模式给) |
| actualRefundAmount | BigDecimal | 实际退款额,审批通过后回填;未通过为 null |
| consultantName | String | 该户定制师(信息项,非审批人) |
| approvedByName | String | 批复人姓名;未批复为 null |
| approvedAt | DateTime | 批复时间;未批复为 null |
| approveRemark | String | 批复备注 |
| refundApplicationId | String | 通过后生成的退款申请 ID |
#### 请求示例
```http
GET /v3/admin/order/group-batch/withdraw/1955009900112233
```
#### 响应示例
```json
{
"code": 200,
"data": {
"approvalId": "1955009900112233",
"orderNo": "GT-26-0099",
"customerName": "林婉清",
"participantCount": 2,
"paidAmount": 5000.00,
"estimatedRefundAmount": 3500.00,
"refundMode": "POLICY",
"currentEstimatedRefundAmount": 3200.00,
"currentRefundMode": "POLICY",
"consultantName": "李雯",
"approvalStatus": "PENDING",
"approvedByName": null,
"approvedAt": null,
"actualRefundAmount": null,
"refundApplicationId": null
},
"success": true
}
```
#### 空数据 / 降级响应
团期已推进到不可退阶段(出行中及以后)时,**当前预估取不到**,`currentEstimatedRefundAmount` /
`currentRefundMode` 降级为 null,单子仍可查看——但点通过会被 589501 拦住:
```json
{
"code": 200,
"data": {
"approvalId": "1955009900112233",
"estimatedRefundAmount": 3500.00,
"currentEstimatedRefundAmount": null,
"currentRefundMode": null
},
"success": true
}
```
`refundApplicationId` 目前**恒为 null**——退款单由订单取消事件异步建,取消响应里不带该 ID。
需要跳退款单请用 `orderId` 去退款工作台查。
#### 错误响应
```json
{
"code": 589531,
"message": "退单申请不存在",
"success": false,
"data": null
}
```
| code | 含义 |
|------|------|
| 589530 | 非管理员角色 |
| 589531 | 申请单不存在 / 已删除 |
#### 业务边界
- **两个预估都要展示**:`estimatedRefundAmount` 是提交时快照,`currentEstimatedRefundAmount` 是当前值。
`POLICY` 下距出发天数每天在变,**实退按当前值算**,只给前者会误导审批人。
- **鉴权**:同列表,仅管理员。
---
### 4. 退单审核通过 `POST /v3/admin/order/group-batch/withdraw/:approvalId/approve`
**VO**: `ApproveWithdrawReqVO` / `WithdrawApprovalDetailRespVO`
#### 使用场景
审批中心详情页点「通过」。**这一刻才真正退团**:订单取消、团期名额回落、退款进实退链路。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| approvalId | Path | Long | ✅ | - | 退单审批单 ID |
| remark | Body | String | ❌ | ≤512 字 | 批复备注;body 可整体省略 |
#### 出参 `Result<WithdrawApprovalDetailRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| (详情全部字段) | - | 见接口 3 |
| approvalStatus | String | 固定 `APPROVED` |
| actualRefundAmount | BigDecimal | **实际**退款额(按批复时团期状态重算,可能与提交时快照不同) |
| approvedByName | String | 批复人(当前登录人) |
| approvedAt | DateTime | 批复时间 |
#### 请求示例
```json
{ "remark": "已与客户确认,同意退单" }
```
#### 响应示例
```json
{
"code": 200,
"data": {
"approvalId": "1955009900112233",
"approvalStatus": "APPROVED",
"refundMode": "POLICY",
"estimatedRefundAmount": 3500.00,
"actualRefundAmount": 3200.00,
"approvedByName": "刘涛",
"approvedAt": "2026-09-04 18:02:35"
},
"success": true
}
```
#### 空数据 / 降级响应
写操作,无空数据形态。团期时间线写失败会降级(记 WARN)但**不影响退团成功**。
`paidAmount = 0` 的零元退单:订单照常取消,`actualRefundAmount` 为 0,**不生成退款单**:
```json
{ "code": 200, "data": { "approvalStatus": "APPROVED", "actualRefundAmount": 0.00 }, "success": true }
```
#### 错误响应
```json
{
"code": 589532,
"message": "该退单申请已处理,不可重复操作",
"success": false,
"data": null
}
```
| code | 含义 |
|------|------|
| 589501 | 团期已推进到不可退阶段(出行中及以后) |
| 589512 | 子订单不属于该团期 |
| 589530 | 非管理员角色 |
| 589531 | 申请单不存在 |
| 589532 | 申请单已是终态(已通过 / 已取消) |
#### 业务边界
- **钱以批复时为准**:审批通过时**重新**按当时团期状态选模式、重算金额,**不认提交时的快照**。
典型场景:提交时团期还在资源准备中(全退 5000),批复时已进物资准备(按政策退 3200)→ 实退 3200。
- **一次到位**:团期侧审批完即执行,退款**不再进退款审批中心二次审**。
- **名额回落**:1 单 = 1 户 = 1 房,通过后该团期已报名 −1 户 / −N 人。
- **并发**:按 `approvalId` 加分布式锁 + 状态 CAS 双保险,两名管理员同时点通过只有一个成功,另一个 589532。
- **不可撤销**:钱一旦进实退链路就撤不回来,要撤走售后退款。
---
### 5. 取消退单(驳回) `POST /v3/admin/order/group-batch/withdraw/:approvalId/reject`
**VO**: `RejectWithdrawReqVO` / `WithdrawApprovalDetailRespVO`
#### 使用场景
审批中心详情页点「取消退单」。**该户继续留在团期中走原流程**,可以继续提需求、住房用车、正常出行。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| approvalId | Path | Long | ✅ | - | 退单审批单 ID |
| remark | Body | String | ✅ | 非空,≤512 字 | 驳回原因(合规要求必填),body 不可省略 |
#### 出参 `Result<WithdrawApprovalDetailRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| (详情全部字段) | - | 见接口 3 |
| approvalStatus | String | 固定 `REJECTED` |
| approveRemark | String | 驳回原因 |
| approvedByName | String | 操作人 |
| approvedAt | DateTime | 操作时间 |
#### 请求示例
```json
{ "remark": "客户已改口,继续参团" }
```
#### 响应示例
```json
{
"code": 200,
"data": {
"approvalId": "1955009900112233",
"approvalStatus": "REJECTED",
"approveRemark": "客户已改口,继续参团",
"approvedByName": "刘涛",
"approvedAt": "2026-09-04 18:05:12"
},
"success": true
}
```
#### 空数据 / 降级响应
写操作,无空数据形态。时间线写失败降级不阻断驳回。
#### 错误响应
```json
{
"code": 589532,
"message": "该退单申请已处理,不可重复操作",
"success": false,
"data": null
}
```
| code | 含义 |
|------|------|
| 589530 | 非管理员角色 |
| 589531 | 申请单不存在 |
| 589532 | 申请单已是终态 |
remark 为空时走参数校验,返回校验失败提示(非业务码)。
#### 业务边界
- **零副作用**:只改申请单状态。订单状态、团期名额、退款单**三项分文未动**。
- **可再次提交**:驳回后该户可以再走一遍退单户流程(在途判重只拦 `PENDING` 单)。
- **并发**:同 approve,锁 + CAS 双保险。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
```jsonc
// ✅ 提交退单:body 可省略,也可只带 reason
{ "reason": "客户临时有事无法参团" }
// ❌ 不要再期待「调完就退款了」——现在只是建了一张待审单
// ❌ 不要传 refundMode:退款模式由后端按团期状态定,前端无权指定
// ✅ 取消退单:remark 必填
{ "remark": "客户已改口,继续参团" }
// ❌ 取消退单不传 remark → 参数校验失败
{}
```
### 金额取哪个数
- 弹窗展示 → 提交接口返回的 `paidAmount` + `estimatedRefundAmount`
- 审批详情展示 → `paidAmount` + `currentEstimatedRefundAmount`(当前值才是实退依据)
- **都不要**调 `/v3/admin/order/{id}/cancel-preview` 自己算,它恒按退改政策算,团期早期会少显示
---
## 五、数据库行为
- 提交时新增一条退单审批记录(待审),**不改订单、不改团期计数、不建退款单**。
- 审批通过时才发生:订单流转为已取消、团期已报名人数/户数回落、按金额生成退款申请并进实退链路
(金额为 0 时不生成退款申请)。
- 取消退单(驳回)只更新审批记录的状态与批复信息,其余数据零变动。
- 提交 / 通过 / 驳回各写一条团期时间线;写失败降级为 WARN,不影响主流程。
- 同一子订单同时只允许存在一条待审记录。
---
## 六、边界行为
- 出行中及以后不允许退单,提交与审批两处都拦(589501),走售后退款。
- 未付分文可以退单;早期阶段(全退订金)仍会按**订金应付额**退并生成退款单,后期阶段(按政策)才退 0 且不生成退款单。
- 已满团的团期提交退单会返回 `warnings` 提示,**不阻断**。
- 审批期间该户完全正常:可提需求、可排房排车、可被派资源。
- 审批期间若该户又付了尾款,通过时按**当前**已付款额重算退款。
## 六.5、枚举 / 数据字典
### 审批状态(`approvalStatus`)
| 值 | 含义 | 可做的操作 |
|----|------|------------|
| PENDING | 待审 | 通过 / 取消退单 |
| APPROVED | 已通过(已退团) | 无(终态) |
| REJECTED | 已取消退单 | 无(终态);该户可再次提交新申请 |
### 退款模式(`refundMode`)
| 值 | 含义 | 出现阶段 |
|----|------|----------|
| FULL_DEPOSIT | 全额退**订金应付额**(与已付款额无关;订金为 0 时订单侧拒绝退款) | 招募中 / 资源准备中 |
| POLICY | 以**已付款额**为基数按退改政策阶梯扣减 | 物资准备中 / 待出发 |
## 六.6、修改前后对比
针对 `POST /v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/withdraw`:
| 维度 | 修改前 | 修改后 |
|------|--------|--------|
| 调用后果 | **立即退款**:订单取消、名额释放、退款单自动建 | **只建待审申请单**,三项零变动 |
| 招募中 / 资源准备中 | 免审直退,钱当场退 | 同样要管理员审批 |
| 物资准备中 / 待出发 | 退款单挂起等定制师审 | 团期侧审批通过后一次到位,不再二次审 |
| 响应体 | `Result<Void>`,`data` 为 null | `Result<WithdrawSubOrderRespVO>`,含两个金额与审批单 ID |
| 能否反悔 | 不能,订单已取消 | 能,管理员可「取消退单」,该户继续留在团期 |
| 退款金额口径 | 按团期状态选模式 | **不变**,仍按团期状态选模式 |
| 谁审批 | 定制师(且早期免审) | **管理员角色**(ADMIN / SUPER_ADMIN) |
## 六.7、影响评估
- **前端必改**:提交成功后的提示语(「已退款」→「已提交审核,待管理员确认后退款」);
弹窗金额改为两行展示;新增审批中心三个页面(列表 / 详情 / 通过与取消)。
- **不改也不会崩**:响应体从 null 变对象是**向后兼容**的(老前端不读 `data` 即可),
但**业务语义会错**——用户以为钱退了,实际还在等审批。属必须跟进项。
- **运营流程变化**:招募期退款不再是秒退,需要管理员点一下;换来的是可撤销。
- **其他端**:C 端、小程序、财务侧退款工作台**均不受影响**——退款单的建立与实退链路完全没动。
- **回滚**:回滚本次发布即恢复旧行为;已建的待审申请单不会自动执行,需人工处理。
---
## 七、不影响范围
- 转订单 `POST .../transfer-in`(#7095)——两件事,各走各的,本次未动。
- 团期成团 / 取消成团 / 流团 / 调整容量 / 预支等其余团期动作端点。
- 子订单列表 `GET /v3/admin/order/group-batch/:groupBatchId/orders`——退单弹窗下拉仍用它,出参未变。
- 订单侧散客退改政策与 `/v3/admin/order/{id}/cancel-preview`,口径与实现均未动。
- 退款工作台的申请、审核、实退链路。
- C 端 / 小程序全部接口。
---
## 八、测试环境已验证
**过网关真测已完成**(2026-09-05,`https://api.test.1814.love:9443`,
admin 账号经 `POST /admin/auth/login` + `POST /admin/auth/switch-role` 切到 `ADMIN` 角色)。
测试数据:在团期 `Q202610312089667212070612994`(资源准备中)代下两单并全程验证后作废。
| # | 验证项 | 结果 |
|---|--------|------|
| 1 | 角色门:`ROOM_MANAGER` 调审批列表 | 589530 拒绝 ✅ |
| 2 | 072 列表(ADMIN,缺省 PENDING / `ALL`) | 200,分页结构 `records/total/page/pageSize` ✅ |
| 3 | 073 详情:不存在的单 | 589531 ✅ |
| 4 | 070 提交:不存在的团期 | 589500 ✅ |
| 5 | 070 提交:真团期 + 不存在的子订单 | 581007 订单不存在 ✅ |
| 6 | 070 提交:真实子订单 | 200,返 approvalId + 两个金额 + 模式 ✅ |
| 7 | 提交后订单/名额零变动 | 订单仍 `PENDING_PAY`,enrolled 仍 2/1 ✅ |
| 8 | 072 列表出现该待审单 | total=1,字段齐 ✅ |
| 9 | 073 详情双预估 | 提交时预估与当前预估均返回 ✅ |
| 10 | 070 重复提交 | 589529 ✅ |
| 11 | **075 取消退单** | 200 → REJECTED ✅ |
| 12 | **驳回后该户仍在团里** | 订单仍 `PENDING_PAY`,enrolled 仍 2/1,三项零变动 ✅ |
| 13 | 已处理的单再驳 | 589532 ✅ |
| 14 | 驳回后可再次提交 | 200,新 approvalId ✅ |
| 15 | **074 审核通过** | 200 → APPROVED,回填批复人/实退额 ✅ |
| 16 | 通过后订单取消 + 名额回落 | 订单 `CANCELLED`,enrolled 2/1 → **0/0** ✅ |
| 17 | 已通过的单再批 | 589532 ✅ |
| 18 | 075 remark 为空 | 400「驳回原因不能为空」✅ |
| 19 | **预估 == 实退** | 预估 ¥2000 = 实退 ¥2000 ✅(见下方缺陷) |
### 实测暴露并已修复的缺陷
首轮实测发现:已付 ¥0 的单,弹窗显示「预计退 ¥0」,审批通过后**实退 ¥2000**——预估与实退不同源。
根因是订单侧 `FULL_DEPOSIT` 退的是**订金应付额**而非已付额,本模块的预估函数错用了已付额。
已由 **PR #7126** 修复(预估改用订金、订金为 0 时出 warning),重新部署后复测预估与实退一致。
两轮验证产生的退款单(`2096027837650616321`、`2096029516789915650`)均已置 REJECTED,
无实退记录,**测试环境资金零变动**。
### 部署记录
- 2026-09-04 19:39–19:40 首次部署(PR #7106)
- 2026-09-05 08:14–08:15 修复后重新部署(PR #7126)
两次均从 `dev-v3` 重新构建 hl-order-service-v3 并滚动重启,8186 / 8086 双实例先后健康。
### 本地测试
`mvn -pl hl-order-service-v3 -am test` 共 8175 例,Failures: 0、Errors: 7——
7 例全部是 Testcontainers 迁移测试因本机无 Docker 报 `IllegalState`,与本次改动无关。
`GroupBatchWithdrawApprovalServiceTest` 18 例(含 3 例订金口径回归)、
`GroupBatchFinanceServiceTest` 24 例、团期全域 553 例全绿;ArchUnit 架构规则全绿。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| **PR #7126** | **#7100** | 实测暴露的预估口径修复(FULL_DEPOSIT 按订金算) | ✅ 最新 |
| PR #7106 | #7100 | 退单户改为申请 + 管理员审批,新增 072~075 | ✅ 有效 |
| #7103 | #7095 | 团期转订单(与本次并行开发,错误码与迁移号已错开) | ✅ 有效 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#7100](https://git.1814.love:8443/wx/HL/issues/7100)
- 关联 PR: [wx/HL#7106](https://git.1814.love:8443/wx/HL/pulls/7106)
- 后续计划: 流团审批复用同一套审批表(本次只做退单);已通过后的撤销走售后,不在本次范围
## 关联 / 联系人
### 链接
- **Issue**: [#7100](https://git.1814.love:8443/wx/HL/issues/7100)
- **PR**: [#7106](https://git.1814.love:8443/wx/HL/pulls/7106) + [#7126](https://git.1814.love:8443/wx/HL/pulls/7126)(预估口径修复)
- **Merge commit**: [46c261d84](https://git.1814.love:8443/wx/HL/commit/46c261d84)
### 联系人
- **后端负责人**: @jw
@@ -0,0 +1,299 @@
---
schema: "hl-changelog/v2"
ticket: "7101"
title: "供应商合作中资料变更接入企微审批"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "fbed9101"
target_release: ""
verified_at: "2026-09-05"
status_note: "后端已部署并通过 TEST;前端需将合作中资料保存结果按企微异步审批处理,并展示审核中。"
updated_at: "2026-09-05"
base: "dev-v3"
---
# 供应商模块:合作中资料变更接入企微审批
> **服务**: `hl-resource-service`、`hl-user-service`
> **Issue**: #7101
> **PR**: #7125、#7128
> **日期**: 2026-09-05
> **影响范围**: 管理后台供应商资料保存与新增收款账户结果展示
## ⚠️ 关键变化
合作中(`ACTIVE`)供应商保存资料不再直接更新正式资料:接口先返回企微审批受理结果,待审批时统一显示“审核中”;驳回不更新,通过后才应用本次申请。新增账户仍独立审批,本次补充中文审批状态字段。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 更新供应商 | PUT | `/admin/supplier/items/{supplierId}/update` | 行为与响应修改 | `ACTIVE` 改为企微审批后生效,响应新增 `approval` |
| 2 | 批量新增收款账户 | POST | `/admin/supplier/items/{supplierId}/bank-accounts/add` | 响应字段扩展 | 每项新增 `approvalStatusName` |
## 三、接口详情
### 1. 更新供应商 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO → SupplierWriteRespVO`
#### 使用场景
保存合作中供应商的主体资料、联系人或资质;草稿保存语义不变。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 | 供应商 ID |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 当前供应商版本 |
| `changeReason` | Body | String | `ACTIVE` 必填 | 最长 500 | 变更原因 |
| 资料字段 | Body | 原类型 | 否 | 沿用原契约 | 至少产生一项实际变化 |
| `contacts` | Body | Array | 否 | 完整快照 | 已有联系人带 `contactId`、`expectedUpdateTime`;新增联系人省略 `contactId` |
#### 出参 `Result<SupplierWriteRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.status` | String | 审批期间仍为 `ACTIVE` |
| `data.statusName` | String | 待审批时为“审核中” |
| `data.approval.approvalLogId` | String | 资料变更审批记录 ID |
| `data.approval.approvalStatus` | String | 初次受理为 `PENDING` |
| `data.approval.approvalStatusName` | String | 初次受理为“审核中” |
| `data.approval.spNo` | String | 企业微信审批单号 |
| `data.approval.syncStatus` | String | 审批同步状态 |
| `data.updateTime` | String | 审核中仍返回正式资料当前版本 |
#### 请求示例
```json
{
"remark": "更新合作备注",
"changeReason": "业务资料更新",
"expectedUpdateTime": "2026-09-05 09:00:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2095000000000000001",
"status": "ACTIVE",
"statusName": "审核中",
"approval": {
"approvalLogId": "2095000000000000002",
"provider": "WECOM",
"approvalStatus": "PENDING",
"approvalStatusName": "审核中",
"spNo": "202609050001",
"syncStatus": "REQUESTING"
},
"updateTime": "2026-09-05 09:00:00"
}
}
```
#### 空数据 / 降级响应
成功受理时 `data.approval` 不为空;企微结果不确定时保留审批事实,前端不得自动重复提交。草稿直接保存时 `data.approval=null`。
#### 错误响应
```json
{
"code": 395014,
"message": "数据已被他人修改,请刷新后重试",
"success": false,
"data": null
}
```
`400` 表示字段、联系人快照或变更原因不合法;`395005` 表示当前状态不允许;`395019`~`395022` 表示企微未配置、账号未绑定、提交失败或状态待对账。未新增错误码。
#### 业务边界
- 审核中正式资料保持原值;驳回不更新,通过后一次性应用申请中的冻结值。
- 新增联系人必须省略 `contactId`,不得发送 `contactId:null`;只修正内部审批快照兼容,不放宽外部显式空 ID 校验。
- 企微表单的变更明细只列实际差异,身份证号和个人电话完整展示;相关附件和当前正式资料为选填。
### 2. 批量新增收款账户 `POST /admin/supplier/items/{supplierId}/bank-accounts/add`
**VO**: `SupplierBankAccountBatchCreateReqVO → List<BankAccountSubmitResultRespVO>`
#### 使用场景
为合作中供应商新增一至五十个收款账户,每个账户独立提交企微审批。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 | 合作中供应商 ID |
| `accounts` | Body | Array | 是 | 1~50 项 | 账户列表,原请求结构不变 |
#### 出参 `Result<List<BankAccountSubmitResultRespVO>>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data[].approvalStatus` | String | `PENDING` / `APPROVED` / `REJECTED` |
| `data[].approvalStatusName` | String | “审核中” / “已通过” / “已驳回” |
| `data[].accountStatus` | String | 审核中为 `PENDING`,通过为 `ACTIVE`,驳回为 `REJECTED` |
| `data[].spNo` | String | 企业微信审批单号 |
#### 请求示例
```json
{
"accounts": [{
"accountType": "PERSONAL",
"bankName": "示例银行",
"bankBranch": "示例支行",
"accountNo": "6222000012345678",
"proofFileUrls": [],
"settleMode": "SINGLE",
"invoiceType": "NONE"
}]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [{
"accountId": "2095000000000000003",
"approvalLogId": "2095000000000000004",
"provider": "WECOM",
"approvalStatus": "PENDING",
"approvalStatusName": "审核中",
"spNo": "202609050002",
"syncStatus": "REQUESTING",
"accountStatus": "PENDING",
"isDefault": "NO"
}]
}
```
#### 空数据 / 降级响应
写接口不返回空成功列表;结果不确定时账户保持不可用,前端不得自动重提。
#### 错误响应
```json
{
"code": 395011,
"message": "该账户正在审批中,请勿重复提交",
"success": false,
"data": null
}
```
`400` 表示账户字段或组合规则不合法;`395010` 表示供应商尚未生效;其他企微错误沿用现有 `395019`~`395022`。
#### 业务边界
- 每个账户独立审批;审核中保持 `PENDING` 且不可收款、不可设为默认。
- 企微驳回后为 `REJECTED`,通过后为 `ACTIVE`;前端只把 `ACTIVE` 当作生效账户。
## 四、契约约束与正确调用方式
1. 更新接口返回成功后,若 `data.approval.approvalStatus=PENDING`,展示“审核中”并保留正式资料页面值,不做本地乐观覆盖。
2. 通过详情、审批记录或账户列表刷新终态;审批只能在企业微信处理,管理端不发送通过或驳回命令。
3. 联系人数组是完整快照;新增项省略 `contactId`,已有项同时传 `contactId` 与 `expectedUpdateTime`。
4. 所有接口同时检查 HTTP 状态和响应体 `code/success`。
## 五、数据库行为
- 本次无数据库结构或迁移变化。
- 资料审批中不写正式资料;通过后应用冻结申请,驳回后正式版本和值均不变。
- 新账户审批中为 `PENDING`,通过后为 `ACTIVE`,驳回后为 `REJECTED`。
## 六、边界行为
- 模板审批节点由企业微信配置决定,后端不校验“恰好一级”。
- 表单固定使用已配置的八个 `Text` 控件;相关附件与当前正式资料允许为空。
- 企微回调或轮询完成前,前端只展示受理状态,不推断最终结果。
## 六.5、枚举 / 数据字典
| 字段 | 值 | 中文 | 说明 |
|---|---|---|---|
| `approvalStatus` | `PENDING` | 审核中 | 尚未终结 |
| `approvalStatus` | `APPROVED` | 已通过 | 业务变更已应用 |
| `approvalStatus` | `REJECTED` | 已驳回 | 业务变更未应用 |
| `accountStatus` | `PENDING` | 待审批 | 账户不可用 |
| `accountStatus` | `ACTIVE` | 生效中 | 账户可用 |
| `accountStatus` | `REJECTED` | 已驳回 | 账户不可用 |
## 六.6、修改前后对比
| 行为 | 修改前 | 修改后 |
|---|---|---|
| `ACTIVE` 供应商保存资料 | 直接更新正式资料 | 返回企微审批;通过后更新,驳回不更新 |
| 资料保存响应 | 无独立资料审批结果 | 返回 `data.approval` 与“审核中”状态 |
| 新增账户响应 | 仅英文审批状态 | 增加 `approvalStatusName` 中文状态 |
## 六.7、影响评估
- **是否破坏向后兼容**: 是;合作中资料保存由同步生效改为异步审批。
- **前端是否必须同步上线**: 是。
- **前端 workaround 清理点**: 删除保存成功后立即以请求值覆盖正式资料的逻辑,改为展示“审核中”并刷新终态。
## 七、不影响范围
- 草稿供应商直接保存、注册审批、主体状态审批、合同、默认账户和停用流程不变。
- 新增账户的请求路径、请求字段和独立审批语义不变。
- 未新增数据库、配置、Redis、MQ 或错误码。
## 八、测试环境已验证
```text
PUT /admin/supplier/items/{supplierId}/update → PENDING / 审核中,正式资料保持原值 ✓
企微通过资料变更 → 正式资料更新,身份证号与电话完整值可见 ✓
企微驳回资料变更 → 正式资料和值版本均不变 ✓
POST /admin/supplier/items/{supplierId}/bank-accounts/add → PENDING / 审核中 ✓
企微通过新增账户 → ACTIVE ✓
企微驳回新增账户 → REJECTED,未激活 ✓
```
资料审批使用模板 `3WNhaxns4i6kfVs74gz4qZQhFxy4iJY4anxMuvYw`;八个 `Text` 控件、实际差异格式、完整身份证号/电话和两个选填项已逐项验证。部署提交为 `6bc1c1262e5e4902a893f30fac875d35a65f8a76`。
## 十、相关文档
- [Issue #7101](https://git.1814.love:8443/wx/HL/issues/7101)
- [主 PR #7125](https://git.1814.love:8443/wx/HL/pulls/7125)
- [补充 Issue #7127](https://git.1814.love:8443/wx/HL/issues/7127)
- [补充 PR #7128](https://git.1814.love:8443/wx/HL/pulls/7128)
## 前端动作与当前状态
- 合作中资料保存成功后读取 `data.approval`,展示“审核中”,刷新终态后再更新正式资料。
- 新增账户直接展示 `approvalStatusName`,并按 `accountStatus` 判断是否可用。
- 新增联系人省略 `contactId`,已有联系人继续携带 ID 与版本。
- **当前状态:待前端处理。**
## 关联 / 联系人
### 链接
- **Issue**: [#7101](https://git.1814.love:8443/wx/HL/issues/7101)
- **PR**: [#7125](https://git.1814.love:8443/wx/HL/pulls/7125)
- **Merge commit**: [91a965f0b0f403a0f8c9d3990587f09ea780c9dd](https://git.1814.love:8443/wx/HL/commit/91a965f0b0f403a0f8c9d3990587f09ea780c9dd)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,300 @@
---
schema: "hl-changelog/v2"
ticket: "6842"
title: "供应商合同修改与删除接口"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "not_required"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "汇总现有合同修改、删除契约;前端按完整替换、必填原因及可选合同版本接入。既有 TEST 业务实测及本次只读复核范围见正文。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 供应商合同:修改与删除接口
> **影响范围**:管理后台供应商合同编辑、删除。当前状态:后端已部署;前端待核对接入。
更新为整份替换,15 个业务字段均可空;修改、删除都必须提交 `changeReason`。`expectedUpdateTime` 可省略,传入时必须使用合同自身版本。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 修改供应商合同 | PUT | `/admin/supplier/items/{supplierId}/contracts/{contractId}/update` | 现有契约说明 | 完整替换一份合同 |
| 2 | 删除供应商合同 | DELETE | `/admin/supplier/items/{supplierId}/contracts/{contractId}/del` | 现有契约说明 | 携带 JSON 原因软删除一份合同 |
## 三、接口详情
### 1. 修改供应商合同 `PUT /admin/supplier/items/{supplierId}/contracts/{contractId}/update`
**VO**: `SupplierContractUpdateReqVO / SupplierContractRespVO`
#### 使用场景
编辑供应商下已登记的单份合同。提交应保留的全部业务字段;未提交字段按空值覆盖。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID | 所属供应商 |
| `contractId` | Path | String | 是 | 正整数 ID | 目标合同 |
| `changeReason` | Body | String | 是 | 去空白后非空,最长 500 字符 | 本次修改原因 |
| `expectedUpdateTime` | Body | String/null | 否 | `yyyy-MM-dd HH:mm:ss` | 合同 `updateTime`,传入即校验 |
| `contractName` | Body | String/null | 否 | 最长 500 字符 | 合同名称 |
| `contractNo` | Body | String/null | 否 | 最长 100 字符 | 合同编号 |
| `contractType` | Body | String/null | 否 | `FRAME` / `SINGLE_TRIP` / `PURCHASE` | 合同类型 |
| `signDate` | Body | String/null | 否 | `yyyy-MM-dd` | 签署日期 |
| `startDate` | Body | String/null | 否 | `yyyy-MM-dd` | 有效期开始 |
| `endDate` | Body | String/null | 否 | 两端有值时不得早于 `startDate` | 有效期结束 |
| `businessLine` | Body | String/null | 否 | 最长 100 字符 | 业务线自由文本 |
| `relatedMainContract` | Body | String/null | 否 | 最长 100 字符 | 关联主合同引用 |
| `autoRenew` | Body | Boolean/null | 否 | `true` / `false` / `null` | 自动续约 / 不续约 / 未登记 |
| `amount` | Body | String/null | 否 | 非负,最多 10 位整数、2 位小数 | 金额,如 `"1200.50"` |
| `pricingMode` | Body | String/null | 否 | 最长 100 字符 | 计价方式 |
| `settleCycle` | Body | String/null | 否 | 最长 32 字符 | 结算周期自由文本 |
| `status` | Body | String/null | 否 | `DRAFT` / `ACTIVE` / `SIGNED` / `EXPIRED` | 合同状态 |
| `scanFileUrl` | Body | String/null | 否 | 最长 1000 字符 | 一个合同附件的永久地址 |
| `remark` | Body | String/null | 否 | 最长 500 字符 | 合同备注 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` / `message` / `success` | Integer / String / Boolean | 成功为 `200`、`成功`、`true` |
| `data.contractId` | String | 合同 ID |
| `data.contractName` | String/null | 合同名称 |
| `data.contractNo` | String/null | 合同编号 |
| `data.contractType` | String/null | 合同类型 |
| `data.signDate` | String/null | 签署日期 |
| `data.startDate` / `data.endDate` | String/null | 有效期,`yyyy-MM-dd` |
| `data.businessLine` | String/null | 业务线 |
| `data.relatedMainContract` | String/null | 关联主合同 |
| `data.autoRenew` | Boolean/null | 自动续约标记,`false` 与 `null` 含义不同 |
| `data.amount` | String/null | 金额;无金额为 `null` |
| `data.pricingMode` | String/null | 计价方式 |
| `data.settleCycle` | String/null | 结算周期 |
| `data.status` | String/null | 合同状态;历史读取可能出现 `TERMINATED` |
| `data.scanFileUrl` | String/null | 合同附件地址 |
| `data.remark` | String/null | 合同备注 |
| `data.updateTime` | String | 合同新版本,`yyyy-MM-dd HH:mm:ss` |
#### 请求示例
以下 ID、附件地址和时间均为示例值。
```http
PUT /admin/supplier/items/2095000000000000001/contracts/2095000000000000010/update
Authorization: Bearer <当前有效凭证>
Content-Type: application/json
{
"changeReason": "调整合同有效期",
"expectedUpdateTime": "2026-09-06 10:00:00",
"contractName": "年度服务合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-09-01",
"startDate": "2026-09-01",
"endDate": "2027-09-30",
"businessLine": "旅行服务",
"relatedMainContract": null,
"autoRenew": false,
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "月结",
"status": "SIGNED",
"scanFileUrl": "https://files.example.com/contracts/demo.pdf",
"remark": null
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"contractId": "2095000000000000010",
"contractName": "年度服务合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-09-01",
"startDate": "2026-09-01",
"endDate": "2027-09-30",
"businessLine": "旅行服务",
"relatedMainContract": null,
"autoRenew": false,
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "月结",
"status": "SIGNED",
"scanFileUrl": "https://files.example.com/contracts/demo.pdf",
"remark": null,
"updateTime": "2026-09-06 10:00:01"
}
}
```
#### 空数据 / 降级响应
可空业务字段省略或传 `null` 均清空;可选文本空白也规范化为空。只传 `changeReason` 会尝试清空全部业务字段,不表示仅改原因。成功返回合同对象,无变化时返回业务错误。
#### 错误响应
合同版本过期:
```json
{"code":395014,"message":"数据已被他人修改,请刷新后重试","success":false,"data":null}
```
`400`:缺少原因或格式错误;`395002`:无写权限;`395051`:合同不存在、已删除或归属不符;`395054`:日期倒置;`395057`:无实际变化;`395031`:供应商已归档;`395005`:主体企微审批未结束或状态不允许。
#### 业务边界
- 要求 `FINANCE` 或 `SUPER_ADMIN` 且具有 `supplier:update`;`ADMIN` 被拒绝。
- 合同必须属于路径供应商,供应商不能已归档,主体企微审批必须已结束。
- 完整替换包含页面隐藏字段;需保留的字段应原样提交。版本取合同自身,不使用供应商主体版本。
### 2. 删除供应商合同 `DELETE /admin/supplier/items/{supplierId}/contracts/{contractId}/del`
**VO**: `SupplierContractDeleteReqVO / Result<Void>`
#### 使用场景
删除一份已登记合同。DELETE 请求须携带 JSON 请求体。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID | 所属供应商 |
| `contractId` | Path | String | 是 | 正整数 ID | 目标合同 |
| `changeReason` | Body | String | 是 | 去空白后非空,最长 500 字符 | 本次删除原因 |
| `expectedUpdateTime` | Body | String/null | 否 | `yyyy-MM-dd HH:mm:ss` | 合同当前版本,提供时必须匹配 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` | Integer | 成功为 `200` |
| `message` | String | 成功为 `成功` |
| `success` | Boolean | 成功为 `true` |
| `data` | null | 无合同对象 |
#### 请求示例
```http
DELETE /admin/supplier/items/2095000000000000001/contracts/2095000000000000010/del
Authorization: Bearer <当前有效凭证>
Content-Type: application/json
{
"changeReason": "合同重复登记",
"expectedUpdateTime": "2026-09-06 10:00:01"
}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":null}
```
#### 空数据 / 降级响应
成功的 `data: null` 为正常结果,删除后从详情合同列表中移除该项。
#### 错误响应
缺少删除原因:
```json
{"code":400,"message":"变更原因不能为空","success":false,"data":null}
```
`395002`:无写权限;`395051`:合同不存在、已删除或归属不符;`395014`:版本过期;`395031`:供应商已归档;`395005`:主体企微审批未结束或状态不允许。
#### 业务边界
- 要求 `FINANCE` 或 `SUPER_ADMIN` 且具有 `supplier:update`;供应商已归档或主体企微审批未结束时拒绝。
- 删除为软删除,不删除供应商或账户;没有合同恢复接口。
- 前端请求库需将原因放进 DELETE 的 JSON body;不能只拼 Query 参数或只传 ID。
## 四、契约约束与正确调用方式
从 `GET /admin/supplier/items/{supplierId}/basic-info/view` 的 `data.contracts[]` 读取合同、`contractId` 和 `updateTime`。修改时提交应保留的全部字段;修改成功用新版本继续操作,删除成功刷新列表。版本冲突重新读取后再编辑。日期按 GMT+8 日历日期传 `yyyy-MM-dd`。
## 五、数据库行为
更新只替换目标合同并刷新合同版本;删除后有效详情不再返回该合同,历史记录保留。供应商主体版本不因合同写入改变。参数、权限、归属或版本校验失败时不产生合同变更。
## 六、边界行为
未登录或登录失效按认证失败处理;供应商不存在返回 `395001`。业务错误可能随 HTTP 200 返回,应检查 `code` 和 `success`。金额 `1200.5` 与 `1200.50` 视为相同值,仅改变金额格式不会构成实际变更。
## 六.5、枚举 / 数据字典
### `contractType`
| 值 | 中文 | 说明 |
|---|---|---|
| `FRAME` | 框架合同 | 可写 |
| `SINGLE_TRIP` | 单团单合同 | 可写 |
| `PURCHASE` | 采购合同 | 可写 |
| `null` | 未登记 | 可写 |
### `status`
| 值 | 中文 | 说明 |
|---|---|---|
| `DRAFT` | 草稿 | 可写 |
| `ACTIVE` | 生效中 | 可写 |
| `SIGNED` | 已签约 | 可写 |
| `EXPIRED` | 已过期 | 可写 |
| `TERMINATED` | 已终止 | 仅历史回显,不可提交 |
| `null` | 未登记 | 可写 |
## 六.6、修改前后对比
本次为既有契约汇总,后端无新增变更。
| 字段 / 行为 | 早期 #6544 说明 | 当前契约 |
|---|---|---|
| 合同业务字段 | 部分必填 | 全部可空;含业务线、关联主合同、自动续约 |
| `expectedUpdateTime` | 必填 | 可选,提供时校验合同版本 |
| `status` | 未列出 `SIGNED` | 支持已签约 `SIGNED` |
| 修改 / 删除 | 独立接口 | 路径保持不变;原因仍必填 |
## 六.7、影响评估
本次没有新增兼容性变化,无需与后端同步上线。前端接入现有修改、删除按钮时使用上述请求体,避免只传变化字段导致清空、丢弃 DELETE body 或误用主体版本。
## 七、不影响范围
本次仅说明合同维护入口;供应商主体保存和账户维护继续使用各自接口。
## 八、测试环境已验证
- 既有业务实测:#6654 已覆盖合同补全、清空、删除、缺原因及版本失败零写入;#6842 已覆盖扩展字段保存回读、`SIGNED`、`null`、`false` 和日期错误零写入。
- 本次于 2026-09-06 通过 TEST Gateway 只读核对 Resource Swagger:合同 PUT、DELETE 路径存在,更新的 15 个业务字段、可选版本与必填原因均已发布;同步核对最新 `dev-v3`。此次未重复执行共享环境业务写操作。
## 十、相关文档
- [合同修改、删除实测 #6654](https://git.1814.love:8443/wx/HL/issues/6654#issuecomment-43820)
- [补充字段与实测 #6842](https://git.1814.love:8443/wx/HL/issues/6842#issuecomment-44703)
## 关联 / 联系人
- **Issue**: [#6842](https://git.1814.love:8443/wx/HL/issues/6842)
- **PR**: [#6858](https://git.1814.love:8443/wx/HL/pulls/6858)、[#6665](https://git.1814.love:8443/wx/HL/pulls/6665)
- **后端负责人**: @lc
@@ -0,0 +1,235 @@
---
schema: "hl-changelog/v2"
ticket: "7060"
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 #7104 已合入 dev-v3(合并提交 1289f63d4);2026-09-06 测试环境造数补验,成团扇出与支付闸门守卫均实测通过"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 团期成团: 成团后自动推进子订单流程并派发定制师待办
> **服务**: hl-order-service-v3 | **PR**: #7104 | **Issue**: #7060 | **合并提交**: `1289f63d4`
> **影响范围**: 管理后台「团期详情 → 成团」动作的服务端行为
---
## ⚠️ 关键变化
**`POST /v3/admin/order/group-batch/:groupBatchId/group`(成团)现在会额外推进子订单状态。**
- 变更前:成团只写团期自身的 `needs_guide` / `needs_photographer`,**完全不碰子订单**。定制师那边不会产生任何配房 / 配车待办。
- 变更后:成团时把该团**活跃子订单**从 `AWAITING_PROFILE` 推进到 `RESOURCE_PREPARING`,由既有 `syncFlowTodos` 自然产生 `ASSIGN_ROOM` / `ASSIGN_VEHICLE` 待办。
**接口路径、入参、出参均未变**,前端无需改动;变化在服务端副作用。
---
## 一、背景
定制师代办链路此前是断的:成团之后不会有任何待办派给定制师,配房配车需求无从发起。根因是 `GroupBatchService#group()` 不写子订单。
方案甲(jw 2026-09-03 定案):复用既有待办链路,只补「推进 flow_status」这一步,待办的开 / 关 / 打回全套复用。
方案乙(批量派发待办、不动 flow_status)已否决——待办自动完成依赖 `flow_status` rank 触发 `completeSystemTodo`,走乙会「发得出、关不掉」。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期成团 | POST | `/v3/admin/order/group-batch/:groupBatchId/group` | **行为变更** | 新增子订单流程推进与待办派发副作用;路径/入参/出参不变 |
---
## 三、接口详情
### 1. 团期成团 `POST /v3/admin/order/group-batch/:groupBatchId/group`
**VO**: `Result<Void>`(无请求体)
#### 使用场景
团期管理员在「团期详情」点「成团」。团期由 `RECRUITING` 进入 `RESOURCE_PREPARING`,同时把该团活跃子订单推进到资源准备态,触发定制师的配房 / 配车待办。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 运营团期 ID(`order_group_batch` 主键) |
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | Integer | 200 成功 |
| `data` | null | 无返回体 |
#### 请求示例
```http
POST /v3/admin/order/group-batch/2096511923947282434/group
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": null
}
```
#### 空数据 / 降级响应
团期无活跃子订单时成团照常成功,仅不产生任何推进与待办。
**待办派发失败不影响成团**:派发走独立事务,单单失败只记 WARN,成团已提交的结果不回滚。
#### 错误响应
团期不在 `RECRUITING` 状态:
```json
{
"code": 589501,
"message": "团期状态不允许当前操作",
"success": false,
"data": null
}
```
#### 业务边界
- **只从 `AWAITING_PROFILE` 推进**:`AWAITING_PAY`(未支付)不被推进,不越过支付闸门
- 已在 `RESOURCE_PREPARING` 及之后的子订单 CAS 天然 miss,**重复成团幂等**
- 已取消子订单不参与(活跃口径仅排除 `CANCELLED`)
- 待办派发失败不阻断成团,可由后续任一次 `syncForOrder` 补发
---
## 四、契约约束与正确调用方式
- 成团**不再是纯团期侧动作**,会连带改子订单 `flow_status`。调用方若在成团前后缓存过子订单状态,需要刷新。
- **待办派给订单的定制师**(`order.consultant_id`)。管理端直接建单若未指定定制师,待办的 assignee 为空,不会出现在任何人的「我的待办」中——这是既有行为,非本次引入。
- 成团返回 200 只代表团期状态推进成功;待办派发是尽力而为,失败不体现在返回码里,需查日志 WARN。
---
## 五、数据库行为
仅描述外部可观察行为:
- 团期 `batch_status`:`RECRUITING` → `RESOURCE_PREPARING`
- 该团活跃子订单 `flow_status`:`AWAITING_PROFILE` → `RESOURCE_PREPARING`(CAS,其余状态不动)
- 待办表按既有 `syncFlowTodos` 逻辑新增 `ASSIGN_ROOM` / `ASSIGN_VEHICLE` 行
- **不涉及表结构调整**,无迁移、无回填
---
## 六、边界行为
- 团期非 `RECRUITING` → `589501`
- 子订单为 `AWAITING_PAY` → 不推进
- 重复成团 → 幂等,不重复推进、不重复派发
- 待办派发异常 → 成团仍成功,日志 WARN
---
## 六.5、枚举 / 数据字典
### 子订单 `flow_status`(本次涉及的两个值)
| 取值 | 含义 |
|------|------|
| `AWAITING_PROFILE` | 待补全信息(已支付、成团推进的起点) |
| `RESOURCE_PREPARING` | 资源准备(成团推进的目标,待办在此态产生) |
---
## 六.6、修改前后对比
| 项 | 变更前 | 变更后 |
|---|---|---|
| 成团对子订单的影响 | **无**,完全不碰 | 活跃子订单 `AWAITING_PROFILE → RESOURCE_PREPARING` |
| 定制师待办 | 成团后**不产生** | 成团后自动产生配房 / 配车待办 |
| 接口路径 / 入参 / 出参 | — | **均未变** |
| 未支付子订单 | — | **不受影响**,不越过支付闸门 |
## 六.7、影响评估
| 维度 | 评估 |
|---|---|
| 兼容性 | 接口契约零变化,前端无需改动 |
| 行为 | 成团新增副作用;调用方若缓存子订单状态需刷新 |
| 数据 | 无 DDL、无迁移;仅运行时状态推进 |
| 幂等 | CAS 保证,重复成团安全 |
| 失败隔离 | 待办派发独立事务,失败不回滚成团 |
| 回滚 | 移除推进调用即可,已推进的子订单状态需人工评估 |
---
## 七、不影响范围
- **零影响**:成团接口的路径、入参、出参
- **零影响**:未支付(`AWAITING_PAY`)子订单
- **零影响**:已取消子订单
- **零影响**:待办的开 / 关 / 打回逻辑(全部复用既有实现)
---
## 八、测试环境已验证
✅ 2026-09-06 于测试环境网关实测,真实鉴权(管理端 admin)。
- 网关 `https://api.test.1814.love`,合并提交 `1289f63d4`(特性分支部署后验收,再合入 dev-v3)
- 部署:双实例滚动更新,各 10s 就绪,零停机
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| 1 | 部署启动 | 新 bean 装配成功 | ✅ 双实例 UP(装配失败应用起不来) |
| 2 | 非 `RECRUITING` 成团 | 拒绝 | ✅ `589501 团期状态不允许当前操作` |
| 3 | 既有团期端点无回归 | 均 200 | ✅ 列表 / 详情 / 子订单 / 需求摘要 / 物资清单 |
| 4 | 造数:首单懒建 | 产生 `RECRUITING` 团期 | ✅ groupBatchId `2096511923947282434` |
| 5 | **成团推进子订单** | `AWAITING_PROFILE → RESOURCE_PREPARING` | ✅ 实测 flowStatus 已变为 `RESOURCE_PREPARING` |
| 6 | **支付闸门守卫** | `AWAITING_PAY` 不被推进 | ✅ 另一单成团后仍为 `AWAITING_PROFILE`,未越闸 |
**造数路径**:`POST /v3/admin/order`(注意必填 `productBatchId`,非前端注释所写的 `groupBatchId`)在团期看板「未命中」排期上建单 → 首单懒建产生 `RECRUITING` 团期 → `manual-receipt` 线下收款过支付闸门 → 成团。
**待办行未直接观测**:待办派给 `order.consultant_id`,本次造的单未指定定制师,assignee 为空,`/order-todos/my/page` 查不到;且无按 orderId 查待办的管理端接口。M1 的动作是推进 `flow_status`,已确证到位;待办由既有 `syncFlowTodos` 依 flow_status 产生,本单未改该链路。
**本地单测**:`GroupBatchTodoDispatchServiceTest` 5 例 + `group()` 接线 2 例;全量 **8119 例 0 failures**。
---
## 九、相关历史 PR
- #7104 本次变更
- 口径定案:jw 2026-09-03「成团驱动待办走甲」
---
## 十、相关文档
- `docs/group/团期模块3天开发计划.md` §三 M1
- 团期需求文档:`docs/group/`(dev-v3 分支)
---
## 关联 / 联系人
- **Issue**: #7060 | **PR**: #7104(合并提交 `1289f63d4`)
- **服务**: hl-order-service-v3
- **后端**: jw | **前端**: 无需改动
@@ -0,0 +1,234 @@
---
schema: "hl-changelog/v2"
ticket: "7087"
title: "供应商账户修改入口与删除限制"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "not_required"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "现有接口说明与历史口径纠正;前端按草稿账户修改入口接入,取消空数组删除账户。既有 TEST 业务实测及本次只读复核范围见正文。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 供应商账户:修改入口与删除限制
> **影响范围**:管理后台供应商账户编辑、删除操作。当前状态:后端已部署;前端待核对接入。
## ⚠️ 关键变化
草稿初始账户通过供应商更新接口修改,必须保留一项账户。**旧 #6669 文档的 `initialAccounts: []` 删除方式已被 #7087 收紧,当前返回 `400 / 账户不能为空`。** 草稿的 `changeReason` 现可省略。
| 操作 | 当前支持情况 |
|---|---|
| 修改草稿初始账户 | 支持,使用下文接口,供应商及其已有账户必须均为 `DRAFT` |
| 修改已提交或已生效账户资料 | 未提供独立接口;不能通过供应商更新绕过状态限制 |
| 单独删除账户 | 未提供接口;不能提交空数组清空最后一项账户 |
| 设置默认账户 | 已有 `PUT /admin/supplier/bank-accounts/{accountId}/default/update`,仅改变默认标记 |
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 修改草稿初始账户 | PUT | `/admin/supplier/items/{supplierId}/update` | 现有契约说明 | 通过 `initialAccounts` 完整替换唯一草稿账户;禁止空数组删除 |
## 三、接口详情
### 1. 修改草稿初始账户 `PUT /admin/supplier/items/{supplierId}/update`
**VO**: `SupplierUpdateReqVO / SupplierBankAccountReqVO / SupplierWriteRespVO`
#### 使用场景
在资料完整的草稿供应商下修改初始账户。本节列出账户编辑所需载荷;其余主体资料省略时保留现值。保存后主体必填资料、供应商类型、联系人、账户仍须完整。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID | 供应商 ID |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 供应商主体版本,不能使用账户版本 |
| `initialAccounts` | Body | Array | 本场景是 | 恰好 1 项 | 省略或 `null` 保留现值;`[]` 拒绝 |
| `initialAccounts[].accountType` | Body | String | 是 | `CORPORATE` / `PERSONAL` | 对公 / 对私 |
| `initialAccounts[].bankName` | Body | String | 是 | 非空,最长 500 字符 | 开户银行 |
| `initialAccounts[].accountNo` | Body | String | 是 | 去空白和连字符后 8~32 位数字 | 收款账号 |
| `initialAccounts[].bankBranch` | Body | String/null | 否 | 最长 500 字符 | 开户支行,可清空 |
| `initialAccounts[].proofFileUrls` | Body | Array/null | 否 | 最多 20 个不重复的公网 HTTPS 地址,每项最长 1000 字符;不带查询参数或片段 | 证明附件,省略或 `[]` 清空 |
| `initialAccounts[].settleMode` | Body | String/null | 否 | `PREPAY` / `MONTHLY` / `SINGLE` | 结算方式 |
| `initialAccounts[].accountPeriod` | Body | String/null | 条件必填 | 最长 50 字符;仅 `MONTHLY` 必填,其他方式须为空 | 月结账期 |
| `initialAccounts[].invoiceType` | Body | String/null | 否 | `SPECIAL` / `NORMAL` / `NONE` | 发票类型 |
| `initialAccounts[].taxRate` | Body | String/null | 条件必填 | 0%~100%,最多两位小数 | 可开票时必填;`NONE` 或未选发票类型时须为空 |
| `changeReason` | Body | String | 否 | 最长 500 字符 | 此处为 `DRAFT`,允许省略 |
不提交 `accountId`、`accountName` 或账户状态;户名由供应商全称确定。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` / `message` / `success` | Integer / String / Boolean | 业务结果;成功为 `200`、`成功`、`true` |
| `data.supplierId` / `data.supplierNo` | String | 供应商 ID / 编号 |
| `data.status` / `data.statusName` | String | 本场景为 `DRAFT` / `草稿` |
| `data.onboardingStage` | String | 本场景为 `PROFILE_DRAFT` |
| `data.initialAccounts` | Array | 保存后的初始账户摘要 |
| `data.initialAccounts[].accountId` | String | 当前账户 ID,替换账号后应重新读取 |
| `data.initialAccounts[].accountNo` | String | 完整账号 |
| `data.initialAccounts[].accountNoMask` | String | 废弃兼容字段,实际同样为完整账号;使用 `accountNo` |
| `data.initialAccounts[].status` | String | 本场景为 `DRAFT` |
| `data.approval` | null | 草稿直接保存,不发起审批 |
| `data.updateTime` | String | 保存后的供应商版本,供下次编辑使用 |
#### 请求示例
以下 ID、账号和时间均为示例值。
```http
PUT /admin/supplier/items/2095000000000000001/update
Authorization: Bearer <当前有效凭证>
Content-Type: application/json
{
"expectedUpdateTime": "2026-09-06 10:00:00",
"initialAccounts": [{
"accountType": "CORPORATE",
"bankName": "示例银行",
"accountNo": "6222000012345678",
"bankBranch": "示例支行",
"proofFileUrls": [],
"settleMode": "MONTHLY",
"accountPeriod": "月结30天",
"invoiceType": "SPECIAL",
"taxRate": "6%"
}]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"supplierId": "2095000000000000001",
"supplierNo": "SUP2095000000000000001",
"status": "DRAFT",
"statusName": "草稿",
"onboardingStage": "PROFILE_DRAFT",
"initialAccounts": [{
"accountId": "2095000000000000002",
"accountNo": "6222000012345678",
"accountNoMask": "6222000012345678",
"status": "DRAFT"
}],
"approval": null,
"updateTime": "2026-09-06 10:00:01"
}
}
```
#### 空数据 / 降级响应
成功响应包含账户摘要。编辑表单完整回显使用 `GET /admin/supplier/items/{supplierId}/account-info/list` 的 `data.bankAccounts`,主体版本取该响应的 `data.updateTime`。摘要不包含银行、附件和结算字段,不能直接作为下次完整账户载荷。
#### 错误响应
提交 `initialAccounts: []`:
```json
{"code":400,"message":"账户不能为空","success":false,"data":null}
```
其他常见业务码:`395002` 无写权限;`395005` 非草稿或主体企微审批未结束;`395009` 已有账户不是草稿;`395014` 主体版本过期;`395027` 账号已占用;`400` 缺版本、字段或结算组合不合法。完全未改变数据也返回 `400 / 未检测到实际变化`。
#### 业务边界
- 要求 `FINANCE` 或 `SUPER_ADMIN` 且具有 `supplier:update`;`ADMIN` 被拒绝。
- 仅可维护草稿初始账户;已提交、已生效、已驳回账户没有资料修改或删除入口。
- 一项账户是完整快照:未提交的可选字段会被清空,需保留的字段必须一并带回。
- 相同账号保留账户 ID;换成新账号会替换旧草稿账户,成功后刷新列表和版本。
## 四、契约约束与正确调用方式
1. 按供应商读取账户列表和主体版本,草稿页面提交一项完整账户。
2. 从月结切换为其他结算方式时同步清空 `accountPeriod`;选不开票时同步清空 `taxRate`。
3. 保存成功刷新账户;版本冲突先重新读取。前端移除空数组删除逻辑,不生成不存在的账户删除路径。
## 五、数据库行为
修改成功保留一项草稿账户并刷新供应商版本;更换账号时旧草稿账户不再出现在有效列表。校验失败不改变账户;空数组请求不会删除数据。
## 六、边界行为
未登录或登录失效按认证失败处理;必须检查响应体 `code`、`success`,不能仅凭 HTTP 200 判断保存成功。既有主体资料不完整时,账户编辑同样会被必填校验拒绝。
## 六.5、枚举 / 数据字典
### `accountType`
| 值 | 中文 | 说明 |
|---|---|---|
| `CORPORATE` | 对公 | 必填账户类型之一 |
| `PERSONAL` | 对私 | 必填账户类型之一 |
### `settleMode`
| 值 | 中文 | 说明 |
|---|---|---|
| `PREPAY` | 预付 | 账期须为空 |
| `MONTHLY` | 月结 | 必填账期 |
| `SINGLE` | 单次结算 | 账期须为空 |
| `null` | 未登记 | 账期须为空 |
### `invoiceType`
| 值 | 中文 | 说明 |
|---|---|---|
| `SPECIAL` | 专票 | 必填税率 |
| `NORMAL` | 普票 | 必填税率 |
| `NONE` | 不开票 | 税率须为空 |
| `null` | 未登记 | 税率须为空 |
## 六.6、修改前后对比
本次补充文档,后端无新增变更。
| 字段 / 行为 | 旧 #6669 说明 | 当前契约 |
|---|---|---|
| `initialAccounts: []` | 可清空账户 | #7087 起拒绝,必须保留账户 |
| 草稿 `changeReason` | 必填 | #6684 起可省略 |
| `expectedUpdateTime` | 必填 | 仍必填,使用供应商主体版本 |
| 独立账户修改 / 删除 | 无独立接口 | 仍无独立接口;草稿修改走主体更新 |
## 六.7、影响评估
- 本次没有新增兼容性变化;前端须遵守已部署的账户非空约束。
- 无需与后端同步上线;清理旧的 `[]` 删除调用,按上述状态控制编辑入口。
## 七、不影响范围
本次说明覆盖草稿初始账户维护;新增账户审批、默认账户切换和合同契约保持现状。
## 八、测试环境已验证
- 既有业务实测:#6654 最终证据记录草稿账户修改、可选字段清空与版本失败零写入;#7087 记录显式空账户被拒绝、至少一项账户保存成功。旧证据中的整项清空已被 #7087 覆盖。
- 本次于 2026-09-06 通过 TEST Gateway 只读核对 Resource Swagger:主体更新路径及账户请求/摘要字段存在,未发布独立账户修改、删除路径;同时核对最新 `dev-v3` 源码。此次未重复执行共享环境业务写操作。
## 十、相关文档
- [账户修改既有实测 #6654](https://git.1814.love:8443/wx/HL/issues/6654#issuecomment-43820)
- [账户非空约束与实测 #7087](https://git.1814.love:8443/wx/HL/issues/7087#issuecomment-46744)
- [草稿免填变更原因 #6684](https://git.1814.love:8443/wx/HL/issues/6684)
## 关联 / 联系人
- **Issue**: [#7087](https://git.1814.love:8443/wx/HL/issues/7087)
- **PR**: [#7093](https://git.1814.love:8443/wx/HL/pulls/7093)
- **后端负责人**: @lc
@@ -0,0 +1,354 @@
---
schema: "hl-changelog/v2"
ticket: "7135"
title: "团期创单收紧 productBatchId 校验(班期归属产品 / 非 GROUP 拒收 / tierSeq 存在性 / 日期按班期)"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "03a985b6"
target_release: "hl-ui@03a985b6"
verified_at: "2026-09-06"
status_note: "服务端对 POST /v3/admin/order 新增三个校验错误码 581055/581056/581057,响应 departureDate/returnDate 改为落库值;前端新建订单向导须仅对 GROUP 产品传 productBatchId 且班期必须属于所选产品。"
updated_at: "2026-09-08"
base: "dev-v3"
---
# 订单模块:团期创单校验收紧(班期归属产品 / 非 GROUP 拒收 / tierSeq 存在性 / 日期按班期)
管理后台创单接口 `POST /v3/admin/order` 对 GROUP 产品的团期校验收紧,新增三个拒单错误码(581055/581056/581057),同时出发日期、返团日期改为班期的权威值。修复跨产品班期串号导致订单错误归团、CORE/CUSTOM 单被误命中团期逻辑等问题。
## ⚠️ 关键变化
- **仅 GROUP 产品可传 productBatchId**:CORE/CUSTOM 请求含 productBatchId → 拒单 581056「非团期产品不能指定团期」(改前静默落库并误命中团期分支)。
- **班期必须属于所选产品**:productBatchId 对应班期的 productId ≠ 请求 productId → 拒单 581055「所选团期不属于该产品」(改前会按别家班期计价并挂到别家的团)。
- **tierSeq 必须在产品配置内**(全产品类型):不在 tierPrices ∪ tiers 并集中 → 拒单 581057「所选档位不存在」;产品未配档位时不拦(改前 tier_name 落 NULL)。
- **响应 departureDate/returnDate 改为班期权威值**:GROUP 单出发日 = 班期出发日;返团日 = 班期 endDate,或 班期出发日 + 行程天数 − 1(班期无 endDate 时)。改前响应回显请求日期,落库日期与班期脱钩。
- **GROUP 单响应 tierName 现有值**:来自产品 tiers 配置,改前恒 null。
- **人数超班期剩余名额拒单(581034)**(#7159 并入本 PR):`成人+儿童+小童 > 班期剩余名额` → 581034;此前订单侧读的 `remainingSlots` 字段在产品侧「库存改造 Phase 2」后已无来源、恒 null,致该预查长期 no-op,现改读产品侧权威字段 `remainingParticipants`(null=人数不限,跳过校验)。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 创建订单(管理端) | POST | `/v3/admin/order` | 请求新增校验 / 响应日期改值 | productBatchId 仅 GROUP 可传;班期归属;tierSeq 存在性;日期按班期 |
---
## 三、接口详情
### 1. 创建订单(管理端) `POST /v3/admin/order`
**VO**: `OrderCreateReqVO → OrderCreateRespVO`
#### 使用场景
管理端新建订单向导或团期看板新增子订单调用。创建跟团游、定制游、线路游订单,GROUP 产品必须指定班期,CORE/CUSTOM 产品不允许指定班期。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `productId` | Body | Long(String)| ✅ | 正整数 | 产品 ID |
| `tierSeq` | Body | Integer | ✅ | ≥ 1;必须存在于产品配置档位 | 档位序号,不在产品 tierPrices ∪ tiers 配置内 → 581057 |
| `departureDate` | Body | yyyy-MM-dd | ✅ | 不早于当天;日期格式 | 出发日期;出发日早于今天 → 581011 |
| `adultCount` | Body | Integer | ✅ | ≥ 1 | 成人数 |
| `childCount` | Body | Integer | 否 | ≥ 0,默认 0 | 儿童数(5-12 岁) |
| `youngChildCount` | Body | Integer | 否 | ≥ 0,默认 0 | 小童数(3-4 岁) |
| `babyCount` | Body | Integer | 否 | ≥ 0,默认 0 | 婴儿数(0-2 岁) |
| `customerName` | Body | String | ✅ | @NotBlank | 客户姓名 |
| `customerPhone` | Body | String | ✅ | ^1[3-9]\d{9}$ | 客户手机号(明文传,DB 层 AES 加密) |
| `customerRemark` | Body | String | 否 | ≤ 500 | 客户备注 |
| `createSource` | Body | String | 否 | ≤ 20 字;枚举:CUSTOMER / CONSULTANT / OTA / WALK_IN / B2B / VIP_REPURCHASE / REFERRAL / PROMOTION / INTERNAL;默认 CONSULTANT | 创建来源 |
| `productBatchId` | Body | Long(String) | 条件必填 | 仅 GROUP 产品可传;CORE/CUSTOM 传了 → 581056;班期 productId 必须等于请求 productId,否则 → 581055 | 团期 ID(product 侧班期 batchId);**GROUP 产品必传,CORE/CUSTOM/ROUTE 禁传**;值须属于请求 productId 对应产品 |
| `roomCount` | Body | Integer | 否 | ≥ 1 | 房间数 |
| `tags` | Body | List<String> | 否 | 无约束 | 订单标签名列表 |
| `sharerOpenid` | Body | String | 否 | 无约束 | 分享人 openid(C 端裂变追踪) |
| `customizerId` | Body | Long | 否 | 无约束 | 分享归因定制师 ID |
#### 出参 `Result<OrderCreateRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.id` | Long | 订单主键(雪花 ID 序列化为 String) |
| `data.orderNo` | String | 订单号(创单瞬间生成,永不变) |
| `data.orderStatus` | String | 粗状态枚举(创单后 = PENDING_PAY) |
| `data.orderStatusName` | String | 粗状态中文名(创单后 = 待支付) |
| `data.flowStatus` | String | 细状态枚举值(创单后 = AWAITING_PAY) |
| `data.flowStatusName` | String | 细状态中文名 |
| `data.flowStep` | Integer | 线性 6 步当前步序号(创单初态 = 0) |
| `data.flowStepTotal` | Integer | 线性 6 步总步数(固定 6) |
| `data.flowStepName` | String | 线性 6 步当前步中文名 |
| `data.consultantId` | Long | 实际绑定定制师 ID(序列化为 String) |
| `data.consultantSource` | String | 定制师来源(DEFAULT_ASSIGNED / LINK_BOUND / MANUAL / SHARED) |
| `data.tags` | List<String> | 系统自动打的标签 |
| `data.createdAt` | yyyy-MM-ddTHH:mm:ss | 创单时间 |
| `data.productName` | String | 产品名称 |
| `data.tierName` | String | 档位名(v5.18 新增,**GROUP 单现有值来自产品 tiers 配置,CORE/CUSTOM/ROUTE 仍为 null**) |
| `data.groupBatchName` | String | 拼团批次名(仅供兼容,恒 null,勿读) |
| `data.departureDate` | yyyy-MM-dd | **出发日期(v5.18;改后 = 班期权威出发日,与请求值可能不同)** |
| `data.returnDate` | yyyy-MM-dd | **返团日期(v5.18;改后 = 班期 endDate 或班期出发日 + 行程天数 − 1,与请求值可能不同)** |
| `data.totalAmount` | BigDecimal(String) | 订单总价(元) |
| `data.depositAmount` | BigDecimal(String) | 建议定金金额(元) |
| `data.depositRatio` | Integer | 定金比例百分比;RATIO 模式有值,FIXED 模式为 null,FULL 模式 = 100 |
| `data.depositMode` | String | 定金计算模式(FIXED / RATIO / FULL) |
| `data.paymentMode` | String | 支付模式(DEPOSIT / FULL) |
| `data.expiryMinutes` | Integer | 支付时限分钟数(默认 1440 = 24h) |
| `data.payUrl` | String | 支付页绝对 URL |
| `data.customerName` | String | 客户姓名(回显) |
| `data.groupBatchId` | Long(String) | **运营团期 ID(order 侧),非空 = 团订单,为空 = 普通订单,这是唯一判别** |
| `data.productBatchId` | Long(String) | 团期产品排期 ID(product 侧 batchId,仅供溯源) |
| `data.groupOrder` | Boolean | 是否团订单(= groupBatchId 非空的派生值) |
#### 请求示例
```json
{
"productId": "2044306857534636034",
"tierSeq": 1,
"departureDate": "2026-10-01",
"adultCount": 1,
"childCount": 1,
"youngChildCount": 0,
"babyCount": 0,
"customerName": "张三",
"customerPhone": "13800009601",
"createSource": "CONSULTANT",
"productBatchId": "2052935476557328386"
}
```
#### 响应示例(成功)
```json
{
"code": 200,
"message": "成功",
"data": {
"id": "2096414445365338113",
"orderNo": "HL20260906094527867",
"orderStatus": "PENDING_PAY",
"orderStatusName": "待支付",
"flowStatus": "AWAITING_PAY",
"flowStatusName": "待支付",
"flowStep": 0,
"flowStepTotal": 6,
"flowStepName": "待支付",
"consultantId": "50001234567890",
"consultantSource": "DEFAULT_ASSIGNED",
"tags": [],
"createdAt": "2026-09-06T09:45:27",
"productName": "冻干粉发短信给",
"tierName": "标准档",
"groupBatchName": null,
"departureDate": "2026-10-01",
"returnDate": "2026-10-03",
"totalAmount": "5850.00",
"depositAmount": "1000.00",
"depositRatio": null,
"depositMode": "FIXED",
"paymentMode": "DEPOSIT",
"expiryMinutes": 1440,
"payUrl": "https://pay.hulalv.com/pay/HL20260906094527867",
"customerName": "张三",
"groupBatchId": "2096412454643802114",
"productBatchId": "2052935476557328386",
"groupOrder": true
},
"success": true
}
```
#### 空数据 / 降级响应
创建接口无「空数据」场景(成功即返回订单对象)。降级路径:所依赖的产品服务 Feign 不可用时按错误码降级而非返回空——拉班期失败返 581027、拉报价失败返 581032,前端据 `code` 提示重试,不会返回 `data=null` 的成功包。
#### 错误响应
**出发日期早于今天**
```json
{
"code": 581011,
"message": "出发日期不能早于今天",
"data": null,
"success": false
}
```
**档位不存在(tierSeq 未在产品配置内)**
```json
{
"code": 581057,
"message": "所选档位不存在",
"data": null,
"success": false
}
```
**非团期产品不能指定团期**
```json
{
"code": 581056,
"message": "非团期产品不能指定团期",
"data": null,
"success": false
}
```
**所选团期不属于该产品**
```json
{
"code": 581055,
"message": "所选团期不属于该产品",
"data": null,
"success": false
}
```
**既有 GROUP 校验错误(团期缺失)**
```json
{
"code": 581026,
"message": "团期产品必须选择团期",
"data": null,
"success": false
}
```
**参数校验错误(如手机号格式错误)**
```json
{
"code": 400,
"message": "手机号格式错误",
"data": null,
"success": false
}
```
#### 业务边界
- **productBatchId 传值规则**:仅 GROUP 产品可传且必传(除非为空表示不下单);CORE/CUSTOM/ROUTE 产品绝不能带。
- **班期归属校验**:productBatchId 对应班期的 productId 必须等于请求 productId;不同则拒单 581055,不会创建订单或部分落库。
- **tierSeq 校验**:必须存在于产品的 tierPrices JSON(CORE/CUSTOM/ROUTE)或 tiers JSON(GROUP);产品未配任何档位时保持放行(存量产品兼容)。
- **出发日期与返团日期**:响应值为班期或产品的权威日期,可能与请求值不同。前端展示及后续行程渲染必须以响应值为准,不要缓存请求值。
- **错误码优先级顺序**:出发日期 581011 → 非 GROUP 拒收 581056 → tierSeq 581057 → GROUP 缺 productBatchId 581026 → 拉班期 581027 → 班期归属 581055 → 后续班期状态 / 截止 / 库存检查。
- **鉴权**:网关注入 `X-Admin-Id` 头,consultantId 无法解析返 581013。
- **幂等性**:客户端不得重试;同一 orderNo 存量幂等托管由 order-v3 内核保证。
---
## 四、契约约束与正确调用方式
| 场景 | 正确调用 | 错误调用 | 结果 |
|------|---------|---------|------|
| GROUP 产品创单 | 传 productBatchId,值取价格日历 items[].batchId | 不传 productBatchId | 581026 团期产品必须选择团期 |
| CORE 产品创单 | 不传 productBatchId / productBatchId = null | 传 productBatchId(任何值) | 581056 非团期产品不能指定团期 |
| CUSTOM 产品创单 | 不传 productBatchId / productBatchId = null | 传 productBatchId(任何值) | 581056 非团期产品不能指定团期 |
| 档位选择 | tierSeq 必须在产品已配档位内 | tierSeq 超过产品最大档位序号 | 581057 所选档位不存在 |
| 班期串号防护 | 班期 batchId 必须属于所选 productId | 前端用不同产品的 batchId | 581055 所选团期不属于该产品 |
| 日期展示 | 使用响应 departureDate / returnDate | 使用请求的出发日期 | 行程日期与班期脱钩,退款档位错位 |
---
## 五、数据库行为
| 操作 | 落库字段 | 说明 |
|------|---------|------|
| GROUP 创单(班期出发 2026-10-01,endDate 2026-10-03,tripDays=3) | order_main.depart_date = 2026-10-01(班期值) | 请求可能为 2026-10-02,但落库为班期出发日 |
| GROUP 创单(班期无 endDate) | order_main.return_date = 班期出发日 + tripDays - 1 | 如班期 2026-10-01,tripDays=3,则 return_date=2026-10-03 |
| GROUP 创单(班期有 endDate) | order_main.return_date = 班期 endDate | endDate 为准,与 tripDays 无关 |
| GROUP 创单 | order_main.tier_name = 产品 tiers JSON 对应 tierSeq 的值 | 改前恒 null;CORE/CUSTOM 仍为 null(无 tiers JSON) |
| CORE 创单带 productBatchId | 不创建,拒单 581056 | 改前会落库 productBatchId,误命中团期逻辑 |
| 班期串号 productBatchId 错 | 不创建,拒单 581055 | 改前会按班期产品计价,订单挂错团 |
---
## 六、边界行为
- 业务失败仍 HTTP 200,**必须检查 `code` 字段判成败**。
- 存量订单不回溯修正;仅新建订单应用新规则。
- 产品未配档位时保持放行(向后兼容存量产品);即使 tierSeq 传 99 也不拦。
- 班期 productId 缺失时仅告警日志,不拦单(product 侧老数据未回填,宁缺毋滥)。
- 网关注入 `X-Admin-Id` 为 null 时返 581013,改前静默取 JWT;两者互斥。
---
## 六.6、修改前后对比
针对 `POST /v3/admin/order`(GROUP 产品):
| 维度 | 修改前 | 修改后 |
|------|--------|--------|
| CORE/CUSTOM 带 productBatchId | 静默落库 product_batch_id,误命中团期 staff 扇出分支 | 拒单 581056「非团期产品不能指定团期」,不落库 |
| 跨产品班期(batchId 属别的产品) | 按别家班期计价,订单挂错团 | 拒单 581055「所选团期不属于该产品」(任一侧 productId 为空时仅告警放行) |
| tierSeq 不在产品档位内 | 不校验,tier_name 落 NULL | 拒单 581057「所选档位不存在」(产品未配档位时不拦) |
| GROUP 单响应/落库出发日 | 回显请求出发日,可能与班期脱钩 | = 班期出发日 |
| GROUP 单响应/落库返团日 | 按请求出发日推算 | = 班期 endDate(缺则 班期出发日 + 行程天数 − 1) |
| GROUP 单响应 tierName | 恒 null | 现有值(来自产品 tiers 配置) |
| 人数超班期剩余名额(#7159) | 预查读 remainingSlots 恒 null,整段 no-op(不拦) | 读 remainingParticipants,超额拒单 581034(人数不限的班期跳过) |
## 六.7、影响评估
- **前端必改**:新建订单向导与团期看板「新增子订单」仅对 GROUP 产品传 `productBatchId`,且取该产品价格日历的 `items[].batchId`;CORE/CUSTOM 绝不能带,否则 581056。
- **前端展示**:出发日期 / 返团日期以创单响应值为准(GROUP 单被班期覆盖),不要回显请求值。
- **向后兼容**:正常 CORE/CUSTOM 创单(不带 productBatchId)行为完全不变;错误码新增不影响既有成功路径。
- **其他端**:小程序 `POST /v3/mp/order` 共用内核,四个校验同样生效,请求契约不变。
- **数据安全**:跨产品串号单此前会把订单挂到别家团、扣错名额,本次从源头拒绝。
- **回滚**:回滚本次发布即恢复旧行为;已按新规则创建的订单不受影响。
## 七、不影响范围
- **仅影响**:管理后台「新建订单向导」和「团期看板新增子订单」流程。
- **零影响**:
- 小程序端 `POST /v3/mp/order` 共用内核,新校验同样生效但请求契约不变。
- 表结构:无 Flyway 变更、无新列新表。
- 订单列表、详情、订单编辑等读操作。
- CORE/CUSTOM 正常创单(不带 productBatchId)完全不变。
- 团期相关接口(看板、价格日历、班期详情)。
---
## 八、测试环境已验证
**测试服创单(产品「冻干粉发短信给」productId=2044306857534636034,班期 2026-10-01 productBatchId=2052935476557328386,1 成人 1 儿童,2026-09-06 测试)**
- ✓ GROUP 单正例:响应 departureDate/returnDate 与班期一致(班期 2026-10-01 出发、2026-10-03 返);groupBatchId / productBatchId / groupOrder 三字段已填值。
- ✓ CORE 产品 2056944461216100353 带 productBatchId → HTTP 200,code 581056「非团期产品不能指定团期」。
- ✓ productId=2044306857534636034 + productBatchId=2089667212070612995(属另一产品) → HTTP 200,code 581055「所选团期不属于该产品」。
- ✓ tierSeq=9(产品只配 1-3 档) → HTTP 200,code 581057「所选档位不存在」。
- ✓ GROUP 单不传 productBatchId → HTTP 200,code 581026「团期产品必须选择团期」。
**单测覆盖**(定向复测全绿):OrderServiceTest 193/193 ✓ | GroupOrderStrategyTest 30/30 ✓(含 581034 三例)| OrderCreateTransactionExecutorTest 21/21 ✓ | ProductTierResolverTest 11/11 ✓ | OrderMpCreateServiceTest 6/6 ✓ | BatchInfoVODeserializationTest 2/2 ✓ | OrderMpReadServiceTest 15/15 ✓ | InternalOrderSnapshotControllerTest 4/4 ✓ | E2eScopedOrderCreateServiceTest 19/19 ✓;ArchTest 全绿(LayerEnforcement 5 / RedLine 9 / MapperBoundary 26 / HouseModuleBoundary 4 / DashboardLayer 2 / LocalCacheVetting 1)。
**网关验证(已部署 dev-v3 复测,2026-09-06 15:00)**:合并提交 977be08e 部署测试服,双实例滚动重启完成(8086/8186 新 PID、jar 已更新)。网关实测 15/15 通过:S2 正例 200(日期=班期 10-01/10-03)、S10 跨产品班期 581055、S11 日期不一致回显班期日期、S13 tierSeq=9 581057、S14 CORE 带班期 581056、S16 GROUP tierName=轻奢;#7142 判团字段在创单响应/详情/列表三处透出、#7143 productId 在看板/分页/详情三接口透出,均已核。581034(#7159)判定逻辑经三条单测证实生效;端到端实跑当日受测试账号对产品 schedule 无编辑权限(403)与限额班期 getBatchInfo 存量异常(581027)所限未完成,已于次日补齐(见下条)。
**581034 网关端到端补测(#7159,2026-09-07 09:2x,dev-v3 HEAD 9f10f44d)**:改用同产品 2044306857534636034 下干净的 12-01 期(班期 2096631555760807938 / QA-7189-1201,原 maxParticipants=6、0 人已报名)——经 SUPER_ADMIN 调 `PUT /admin/product/item/2044306857534636034/schedule` 把 maxParticipants 改为 3,随后 `POST /v3/admin/order` 传 adultCount=4 → **581034 团期剩余名额不足**;传 adultCount=2 → 200 建单 2096769958221402114,随即 `cancel/pre-trip` 取消并把 maxParticipants 改回 6,测试库已复原。工单原写的 10-01 期在 2026-09-07 凌晨已被他人下 4 单(8 人已报名),改容量后正例也会被拦,故换期取证,步骤与断言按 AC-4 原文执行。至此 #7159 的行为变化在测试环境已完成网关实测,本文档第一节与「六.6 修改前后对比」所述口径全部成立。
---
## 十、相关文档
- 关联 Issue: [wx/HL#7135](https://git.1814.love:8443/wx/HL/issues/7135)
- 关联 PR: [wx/HL#7169](https://git.1814.love:8443/wx/HL/pulls/7169)(Closes #7135、#7159;合并提交 977be08e)
- 关联 Issue: [wx/HL#7159](https://git.1814.love:8443/wx/HL/issues/7159)(人数预查改读 remainingParticipants 使 581034 生效;与 #7135 同 PR 交付,行为变化由本文档覆盖,网关实测证据见「八、测试环境已验证」末条)
- 关联工单 #7142(判团字段 groupBatchId / productBatchId / groupOrder)、#7143(看板 productId 显示)。
- 团期接口文档:GB-ADM-00B(OpenWiki)。
---
## 关联 / 联系人
### 链接
- **Issue**: [#7135](https://git.1814.love:8443/wx/HL/issues/7135)
- **PR**: [#7169](https://git.1814.love:8443/wx/HL/pulls/7169)(含 #7159)
- **Merge commit**: 977be08eccd0a32e27c01dce41de210a53f13e5d
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,61 @@
---
schema: "hl-changelog/v2"
ticket: "7137"
title: "供应商账户信息列表状态显示中文"
consumer: "admin"
author: "lc(GIT)"
change_type: "前端缺陷"
backend_status: "not_required"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "9d8115ed"
target_release: ""
verified_at: "2026-09-06"
status_note: "本单仅做后端验收;前端中文映射独立处理,不作为关单条件。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 供应商:账户信息列表状态显示中文
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 账户信息列表 | GET | `/admin/supplier/items/{supplierId}/account-info/list` | 无接口变更 | 补充状态中文展示映射 |
## 三、接口详情
### 1. 账户信息列表 `GET /admin/supplier/items/{supplierId}/account-info/list`
- 打开供应商详情的“账户信息”时调用;`supplierId` 为路径参数,无请求体。
- 冻结字段:`data.bankAccounts[].status`,类型 `String`,值为账户状态码。
- 成功须同时满足 HTTP 200、`code=200`、`success=true`。请求、响应和错误码均无变更。
## 六.5、枚举 / 数据字典
| 状态码 | 中文展示 | 值语义 |
|---|---|---|
| `DRAFT` | 草稿 | 初始账户尚未提交审批 |
| `PENDING` | 待审批 | 账户审批处理中 |
| `REJECTED` | 已驳回 | 审批驳回,账户不可用 |
| `ACTIVE` | 有效 | 账户已审批生效 |
| `DISABLED` | 已停用 | 账户停用 |
## 前端动作与边界
状态列按上述状态码显示中文,补齐 `DRAFT → 草稿`。业务判断继续使用原状态码;不把中文写回 `status`,不依赖账户响应中不存在的 `statusName`。
## 八、测试环境已验证
账户列表经 TEST Gateway 返回 HTTP 200、`code=200`、`success=true`。目标供应商当前账户为 `ACTIVE`;草稿账户按既有后端契约返回 `DRAFT`,中文语义为“草稿”。
## 当前状态
前端中文映射独立待处理。#7137 按后端接口和状态契约验收,不要求前端页面验收。
## 关联 / 联系人
- 工单:[HL #7137](https://git.1814.love:8443/wx/HL/issues/7137)
- 后端负责人:@lc
@@ -0,0 +1,509 @@
---
schema: "hl-changelog/v2"
ticket: "7142"
title: "订单详情/列表/创单响应透出 groupBatchId·productBatchId·groupOrder 判团字段"
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: ""
updated_at: "2026-09-06"
base: "dev-v3"
---
# 订单模块:判团字段透出(详情·列表·创单)
三个订单读接口响应透出判团字段 `groupBatchId` / `productBatchId` / `groupOrder`,供前端判断订单是否为团订单。关键口径:**判团只读 `groupBatchId`(非空=团订单)或 `groupOrder`**;`productBatchId` 仅供溯源,不参与判团。
## ⚠️ 关键变化
- **判团唯一口径** `groupBatchId` 非空或 `groupOrder = true` → 团订单;为空/false → 普通订单。
- **不要拿 `productBatchId` 反推团单**,该字段仅供产品侧班期溯源展示,产品侧可能有多个班期映射同一团期。
- 前一版 #7083 仅涉及订单主表冻结,本次全量透出给前端消费,与主表字段同源。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 订单详情 - 主单数据 | GET | `/v3/admin/order/{id}` | 响应新增字段 | data.main 新增 4 字段 |
| 2 | 订单分页列表 | GET | `/v3/admin/order` | 响应新增字段 | data.list[] 新增 2 字段 |
| 3 | 创建订单 | POST | `/v3/admin/order` | 响应新增字段 | data 新增 3 字段 |
---
## 三、接口详情
### 1. 订单详情 - 主单数据 `GET /v3/admin/order/{id}`
**VO**: `OrderMainVO`(响应位置:`data.main`)
#### 使用场景
打开订单详情页面时,读取订单主单数据及其判团标记,供前端决定显示团期相关内容(如「团期编号」、「团期名称」等)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `id` | Path | String | 是 | 正整数 ID | 订单 ID |
#### 出参 `Result<OrderDetailRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.main.groupBatchId` | String/null | 运营团期主键(order_group_batch.group_batch_id);非空=团订单,为空=普通订单 |
| `data.main.productBatchId` | String/null | 产品侧班期 ID(product_v2.group_tour_batch.batch_id);仅供溯源,不参与判团 |
| `data.main.groupOrder` | Boolean | 派生值,= groupBatchId != null;直接用于前端判团 |
| `data.main.batchNo` | String/null | 团期编号(order_group_batch.batch_no);普通单为 null,团期软删也为 null |
| `data.main.batchName` | String/null | 团期名称(order_group_batch.batch_name);普通单为 null,团期软删也为 null |
#### 请求示例
```http
GET /v3/admin/order/2096414445365338113
Authorization: Bearer <token>
```
无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"main": {
"id": "2096414445365338113",
"orderNo": "HL20260906094527867",
"orderStatus": "PENDING_PAY",
"orderStatusName": "待支付",
"flowStatus": "AWAITING_PAY",
"flowStatusName": "待补全信息",
"flowStep": 0,
"flowStepTotal": 6,
"totalAmount": "5850.00",
"depositAmount": "1000.00",
"departureDate": "2026-10-01",
"returnDate": "2026-10-03",
"groupBatchId": "2096412454643802114",
"productBatchId": "2052935476557328386",
"groupOrder": true,
"batchNo": "Q202610012052935476548939777",
"batchName": "10月1日长白山亲子团",
"progressStepper": []
},
"profile": {},
"resource": {},
"contract": {},
"insurance": {},
"refund": {},
"aftersale": {},
"financial": {}
}
}
```
#### 空数据 / 降级响应
普通订单时,`groupBatchId`、`productBatchId`、`batchNo`、`batchName` 均为 null;`groupOrder` 为 false。
#### 错误响应
```json
{
"code": 581007,
"message": "订单不存在",
"success": false,
"data": null
}
```
示例错误码:
- 581007:订单不存在
- 581045:房务角色无权查看订单详情(HTTP 200 code)
#### 业务边界
- 沿用订单详情权限校验;业务失败仍为 HTTP 200,需检查 code。
- 普通订单与团单在出参结构上无区别,仅字段值不同(null vs 有值)。
- 团期已软删时,`groupBatchId` 存在但对应记录不可查,`batchNo` / `batchName` 回退为 null。
- `productBatchId` 与 `groupBatchId` 无必然对应,前端不做交叉校验。
---
### 2. 订单分页列表 `GET /v3/admin/order`
别名接口:`GET /v3/admin/order/list`
**VO**: `OrderListItemRespVO`(响应位置:`data.list[]`)
#### 使用场景
订单列表页加载数据时,带上新增的判团标记,供前端快速判断每行是否为团订单,可用于条件展示「团期信息」列或其他团期特化功能。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `orderStatus` | Query | String | 否 | 枚举多值(逗号分隔) | 粗状态过滤(PENDING_PAY、CUSTOMIZING 等) |
| `flowStatus` | Query | String | 否 | 枚举多值(逗号分隔) | 细状态过滤(AWAITING_PAY、RESOURCE_PREPARING 等) |
| `tagNames` | Query | Array | 否 | - | 按标签过滤(多标签 OR 关系) |
| `keyword` | Query | String | 否 | - | 搜索关键字(团号/客户姓名/产品名/订单号 LIKE) |
| `departureDateFrom` | Query | String | 否 | yyyy-MM-dd | 出发日期范围起始 |
| `departureDateTo` | Query | String | 否 | yyyy-MM-dd | 出发日期范围结束 |
| `createSource` | Query | String | 否 | - | 来源过滤(CONSULTANT/MP/...) |
| `cancelled` | Query | Boolean | 否 | - | 是否含已取消订单(默认 false) |
| `consultantName` | Query | String | 否 | - | 定制师姓名 LIKE 模糊匹配 |
| `statusGroup` | Query | String | 否 | 枚举单值 | 按 Tab 分组(ALL/BEFORE_TRIP/ON_TRIP/SETTLEMENT/ABNORMAL/AFTERSALE) |
| `page` | Query | Integer | 是 | ≥1 | 页码 |
| `pageSize` | Query | Integer | 是 | ≤100 | 每页条数 |
#### 出参 `Result<Page<OrderListItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.list[].groupBatchId` | String/null | 运营团期主键(order_group_batch.group_batch_id);非空=团订单 |
| `data.list[].groupOrder` | Boolean | 派生值,= groupBatchId != null;直接用于前端判团 |
其他字段详见现有订单列表接口文档。
#### 请求示例
```http
GET /v3/admin/order?page=1&pageSize=20
Authorization: Bearer <token>
```
无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"total": 150,
"list": [
{
"id": "2096414445365338113",
"orderNo": "HL20260906094527867",
"productName": "冻干粉发短信给",
"customerName": "张三",
"departureDate": "2026-10-01",
"orderStatus": "PENDING_PAY",
"flowStatus": "AWAITING_PAY",
"totalAmount": "5850.00",
"groupBatchId": "2096412454643802114",
"groupOrder": true
},
{
"id": "2096414445365338114",
"orderNo": "HL20260906094527868",
"productName": "其他产品",
"customerName": "李四",
"departureDate": "2026-10-02",
"orderStatus": "PENDING_DEPARTURE",
"flowStatus": "PENDING_DEPARTURE",
"totalAmount": "8000.00",
"groupBatchId": null,
"groupOrder": false
}
]
}
}
```
#### 空数据 / 降级响应
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"total": 0,
"list": []
}
}
```
#### 错误响应
```json
{
"code": 401,
"message": "未登录或登录已过期",
"success": false,
"data": null
}
```
#### 业务边界
- 分页字段 `page` / `pageSize` 沿用现有约束。
- 列表返回最新 50 条或 100 条时,两个新增字段保证同时返回,不存在部分返回的情况。
- `groupOrder` 是 `groupBatchId != null` 的派生布尔值,前端可二选一使用。
- 普通订单与团单混合返回,字段值直接对标订单属性。
---
### 3. 创建订单 `POST /v3/admin/order`
**VO**: `OrderCreateRespVO`(响应位置:`data`)
#### 使用场景
创建订单后,管理端「订单已创建」弹窗或后续流程需判断该单是否为团单,及时显示团期相关信息(如「团期编号」、「出发日期」等)。
#### 入参
沿用现有 `OrderCreateReqVO`,**请求体不变**(本单只改响应)。字段表:
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `productId` | Body | String(Long) | ✅ | 正整数 | 产品 ID |
| `tierSeq` | Body | Integer | ✅ | ≥1 | 档位序号 |
| `departureDate` | Body | String(yyyy-MM-dd) | ✅ | 不早于今天 | 出发日期 |
| `adultCount` | Body | Integer | ✅ | ≥1 | 成人数 |
| `childCount` | Body | Integer | 否 | ≥0,默认 0 | 儿童数 |
| `youngChildCount` | Body | Integer | 否 | ≥0,默认 0 | 小童数 |
| `babyCount` | Body | Integer | 否 | ≥0,默认 0 | 婴儿数 |
| `customerName` | Body | String | ✅ | @NotBlank | 客户姓名 |
| `customerPhone` | Body | String | ✅ | ^1[3-9]\d{9}$ | 客户手机号 |
| `customerRemark` | Body | String | 否 | ≤500 | 备注 |
| `createSource` | Body | String | 否 | 默认 CONSULTANT | 创建来源 |
| `productBatchId` | Body | String(Long) | 否 | GROUP 必传、非 GROUP 禁传 | 团期 ID(见 #7135) |
| `roomCount` | Body | Integer | 否 | ≥1 | 房间数 |
| `tags` | Body | Array&lt;String&gt; | 否 | — | 订单标签 |
#### 出参 `Result<OrderCreateRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.id` | String | 订单主键 |
| `data.orderNo` | String | 订单号 |
| `data.orderStatus` | String | 粗状态(创单后为 PENDING_PAY) |
| `data.flowStatus` | String | 细状态(创单后为 AWAITING_PAY) |
| `data.flowStep` | Integer | 线性步序(创单初态为 0) |
| `data.flowStepTotal` | Integer | 总步数(固定 6) |
| `data.productName` | String | 产品名 |
| `data.tierName` | String | 档位名 |
| `data.departureDate` | String | 出发日期 |
| `data.returnDate` | String | 返团日期 |
| `data.totalAmount` | String | 订单总价 |
| `data.depositAmount` | String | 建议定金金额 |
| `data.depositRatio` | Integer/null | 定金比例百分比 |
| `data.depositMode` | String | 定金计算模式(FIXED/RATIO/FULL) |
| `data.paymentMode` | String | 支付模式(DEPOSIT/FULL) |
| `data.groupBatchId` | String/null | 运营团期 ID(创单同事务回写);非空=团订单 |
| `data.productBatchId` | String/null | 产品侧班期 ID(创单入参原样固化);仅供溯源 |
| `data.groupOrder` | Boolean | 派生值,= groupBatchId != null;直接用于判团 |
#### 请求示例
```json
{
"productId": 2044306857534636034,
"tierSeq": 1,
"departureDate": "2026-10-01",
"adultCount": 2,
"childCount": 1,
"customerName": "张三",
"customerPhone": "13800138000",
"productBatchId": 2052935476557328386,
"createSource": "CONSULTANT"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"id": "2096414445365338113",
"orderNo": "HL20260906094527867",
"orderStatus": "PENDING_PAY",
"orderStatusName": "待支付",
"flowStatus": "AWAITING_PAY",
"flowStatusName": "待补全信息",
"flowStep": 0,
"flowStepTotal": 6,
"flowStepName": "待支付",
"productName": "冻干粉发短信给",
"tierName": "经典档",
"departureDate": "2026-10-01",
"returnDate": "2026-10-03",
"totalAmount": "5850.00",
"depositAmount": "1000.00",
"depositRatio": null,
"depositMode": "FIXED",
"paymentMode": "DEPOSIT",
"expiryMinutes": 1440,
"payUrl": "https://pay.hulalv.com/pay/HL20260906094527867",
"customerName": "张三",
"groupBatchId": "2096412454643802114",
"productBatchId": "2052935476557328386",
"groupOrder": true
}
}
```
实测数据示例(2026-09-06 测试服):
- 产品「冻干粉发短信给」productId=2044306857534636034
- 班期 2026-10-01 productBatchId=2052935476557328386
- 团期主键 groupBatchId=2096412454643802114
- 团期编号 batchNo=Q202610012052935476548939777
#### 空数据 / 降级响应
创建订单成功后无空数据响应。
#### 错误响应
```json
{
"code": 400,
"message": "产品 ID 非法或产品已下架",
"success": false,
"data": null
}
```
#### 业务边界
- 创建普通订单时,`groupBatchId` / `productBatchId` 为 null,`groupOrder` 为 false。
- 创建 GROUP 产品订单时(必须提交 `productBatchId`),后端在同事务内懒创建或命中已有团期,回写 `groupBatchId`。
- `groupBatchName` 字段当前恒为 null(仅为兼容既有前端契约),**前端不要读它**。
- 响应中 `groupBatchId` / `productBatchId` 为字符串(JSON 序列化后,避免 JS 精度丢失);前端若需数值运算应转换为字符串存储。
---
## 四、契约约束与正确调用方式
| 场景 | 正确做法 |
|------|---------|
| 判断订单是否团单 | 读 `groupBatchId` 非空 或 `groupOrder == true`,两者等价 |
| 不要用 productBatchId 判团 | `productBatchId` 仅供溯源,可能 null(普通单)或有值(团单/非团单均可能) |
| 团单需显示团期名 | `groupBatchName` 恒为 null,读 `batchName`;团期软删时也为 null |
| 普通单与团单混合渲染 | 按 `groupOrder` 条件渲染,普通单该字段为 false;两类订单出参结构一致,仅值不同 |
---
## 五、数据库行为
| 订单类型 | groupBatchId | productBatchId | groupOrder | batchNo | batchName |
|---------|-------------|----------------|-----------|---------|-----------|
| 普通订单 | null | null | false | null | null |
| 团单(命中或懒建) | 非空 | 非空 | true | 有值 | 有值 |
| 团期已软删 | 非空 | 非空 | true | null | null |
---
## 六、边界行为
- 业务失败可能仍为 HTTP 200,必须同时检查 `code`、`success` 和 `message`。
- 详情接口 404/权限 403 时直接返回对应 HTTP 状态码;业务类失败(如订单状态不符)返回 HTTP 200 + code。
- 列表空结果返回 `total=0, list=[]`,分页参数超界时返回空列表(无 5XX)。
- 创单失败不落库,响应 HTTP 200 + code,data 为 null。
- 新增判团字段与现有字段同源、同时刷新,无时间差。
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `groupBatchId` | 无此字段 | 新增;运营团期主键 |
| `productBatchId` | 无此字段 | 新增;产品班期 ID(仅溯源) |
| `groupOrder` | 无此字段 | 新增;派生布尔,= groupBatchId != null |
| `batchNo` | 无此字段 | 新增;团期编号(详情/列表) |
| `batchName` | 无此字段 | 新增;团期名称(详情/列表) |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 前端判团依据 | 无判团字段,无法直接判别 | 读 groupBatchId 非空 或 groupOrder = true |
| 团单信息展示 | 依赖联查或额外接口 | 直接在订单响应中获得 |
| 产品班期溯源 | 不支持 | 新增 productBatchId(仅溯源展示) |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否。新增字段对旧客户端透明,非必需字段缺失时前端框架可靠 null 处理。
- **前端是否必须同步上线**: 是。前端需接入新增四个字段至详情/列表/创建成功弹窗模板,判团逻辑改用 groupBatchId 或 groupOrder。
- **前端 workaround 清理点**:
- 删除旧的"通过产品 ID 推断团单"逻辑,改用 groupBatchId 判别
- 不要硬编码团期编号/名称,改用响应中的 batchNo / batchName
- groupBatchName 恒为 null,勿读之;用 batchName 替代
---
## 七、不影响范围
- **仅影响**: 管理后台订单详情、列表和创建流程的前端渲染
- **零影响**:
- 订单创建/编辑/取消接口
- 小程序端(MpOrderDetailVO 不变)
- 数据库结构(新字段冻结在 order_main 表,无表改动)
- 订单写操作和业务流程
---
## 八、测试环境已验证
- **单测**: 559 条测试用例绿✓(新增判团字段相关的 UT 已覆盖普通单/团单双路径)
- **ArchTest**: 45 条架构测试绿✓
- **测试服网关实测**: 单测 + CR 通过;测试服网关实测见管理者补充
实测产品: `productId=2044306857534636034`(冻干粉发短信给),班期 `2026-10-01`(productBatchId=2052935476557328386),团期主键 `groupBatchId=2096412454643802114`,批号 `batchNo=Q202610012052935476548939777`。
---
## 九、相关历史 PR(功能演进)
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #7083 | - | order_group_batch 一跳直连,订单主表冻结 groupBatchId/productBatchId | ✅ 有效 |
| #7135 | - | GROUP 产品创单校验与重整 | ✅ 有效 |
| #7143 | - | 团期看板 VO 补 productId(配合本 PR) | ✅ 有效 |
| **本 PR #7155** | **#7142** | **订单详情/列表/创单响应透出判团字段** | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#7142](https://git.1814.love:8443/wx/HL/issues/7142)
- 关联 PR: [wx/HL#7155](https://git.1814.love:8443/wx/HL/pulls/7155)
- 团期接口文档: `docs/ARCHITECTURE.md` §0A.2.2(团单冻结口径)
- 团单业务规范: 见 #7135 changelog 中的团期创建规则
## 关联 / 联系人
### 链接
- **Issue**: [#7142](https://git.1814.love:8443/wx/HL/issues/7142)
- **PR**: [#7155](https://git.1814.love:8443/wx/HL/pulls/7155)
- **Merge commit**: [21d3d00de](https://git.1814.love:8443/wx/HL/commit/21d3d00de)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,573 @@
---
schema: "hl-changelog/v2"
ticket: "7143"
title: "团期看板分页项/详情/看板 VO 补 productId(供新增子订单深链预填产品)"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "5d0344ad"
target_release: ""
verified_at: "2026-09-06"
status_note: ""
updated_at: "2026-09-06"
base: "dev-v3"
---
# 团期模块:看板 VO 补 productId
三个团期看板读接口响应新增 `productId` 字段,供前端「新增子订单」深链到订单创建向导时预填产品和班期 ID,锁定出发日期。前端逻辑:**仅 GROUP 产品创单时才在 payload 中带 productBatchId**;非 GROUP 产品忽略 productBatchId。
## ⚠️ 关键变化
- 新增 `productId` 是产品主键,用于深链向导 `/order-v2/new?productId={productId}&productBatchId={productBatchId}&departureDate={date}` 的预填参数。
- 向导跳转后,**仅 GROUP 产品**应将 productBatchId 放入订单创建 POST payload;其他产品类型忽略该参数。
- 前端缺陷单已发,说明旧建单向导漏传 productBatchId,新版本补全(见关联链接)。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期分页列表 | GET | `/v3/admin/order/group-batch` | 响应新增字段 | data.list[] 各项新增 productId |
| 2 | 团期详情 | GET | `/v3/admin/order/group-batch/{groupBatchId}` | 响应新增字段 | data 新增 productId |
| 3 | 团期看板列表 | GET | `/v3/admin/order/group-batch/board?productId=` | 响应新增字段 | data[] 各项新增 productId |
---
## 三、接口详情
### 1. 团期分页列表 `GET /v3/admin/order/group-batch`
**VO**: `GroupBatchPageItemRespVO`(响应位置:`data.list[]`)
#### 使用场景
加载团期分页列表时,每一行包含产品 ID,前端点击「新增子订单」时取该行的 productId / productBatchId / departureDate 拼接深链,跳转到订单创建向导。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `productId` | Query | String | 否 | 正整数 ID | 按产品筛选 |
| `batchStatus` | Query | String | 否 | 枚举值 | 按团期状态码筛选(RECRUITING/RESOURCE_PREPARING/...) |
| `deadlineFrom` | Query | String | 否 | yyyy-MM-dd | 报名截止日起 |
| `deadlineTo` | Query | String | 否 | yyyy-MM-dd | 报名截止日止 |
| `opsStage` | Query | String | 否 | 枚举值 | 按运营阶段筛选(RECRUIT/FORMED/PENDING_TRIP/TRAVELLING/AUDITING/CHECKED/DISBANDED) |
| `month` | Query | String | 否 | yyyy-MM | 按出发月份筛选 |
| `keyword` | Query | String | 否 | - | 班期编号/班期名称模糊关键词 |
| `pageNo` | Query | Integer | 是 | ≥1 | 页码,从 1 开始 |
| `pageSize` | Query | Integer | 是 | ≤100 | 每页条数,默认 20 |
#### 出参 `Result<Page<GroupBatchPageItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.list[].groupBatchId` | String | 团期聚合主键 |
| `data.list[].productBatchId` | String | product 侧班期 ID |
| `data.list[].productId` | String | **【新增】** 产品 ID,供深链预填 |
| `data.list[].productName` | String | 产品名称 |
| `data.list[].batchNo` | String | 班期编号 |
| `data.list[].batchName` | String | 班期名称 |
| `data.list[].batchStatus` | String | 团期状态码 |
| `data.list[].batchStatusName` | String | 状态中文名 |
| `data.list[].minGroupPeople` | Integer | 最低成团人数 |
| `data.list[].maxRooms` | Integer | 房间容量 |
| `data.list[].maxParticipants` | Integer | 人数容量 |
| `data.list[].enrolledPeople` | Integer | 已报名人数 |
| `data.list[].enrolledRooms` | Integer | 已用房间数 |
| `data.list[].remainRooms` | Integer | 剩余房间数 |
| `data.list[].remainParticipants` | Integer | 剩余人数 |
| `data.list[].orderCount` | Integer | 子订单数 |
| `data.list[].enrollDeadline` | String | 报名截止日 |
| `data.list[].departDate` | String | 出发日期 |
| `data.list[].endDate` | String | 结束日期 |
| `data.list[].receivableAmount` | String | 整团应收合计 |
| `data.list[].receivedAmount` | String | 整团已收合计 |
| `data.list[].chips` | Object | 六芯片整团聚合态(hotel/vehicle/guide/photo/contract/insurance,各为字符串状态:全部完成为 `DONE`,存在未完成项为待办态;完整取值见六芯片文档 GB-ADM-090~095) |
#### 请求示例
```http
GET /v3/admin/order/group-batch?pageNo=1&pageSize=20
Authorization: Bearer <token>
```
无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"total": 5,
"list": [
{
"groupBatchId": "2096412454643802114",
"productBatchId": "2052935476557328386",
"productId": "2044306857534636034",
"productName": "冻干粉发短信给",
"batchNo": "Q202610012052935476548939777",
"batchName": "10月1日长白山亲子团",
"batchStatus": "RECRUITING",
"batchStatusName": "招募中",
"minGroupPeople": 6,
"maxRooms": 4,
"maxParticipants": 10,
"enrolledPeople": 4,
"enrolledRooms": 2,
"remainRooms": 2,
"remainParticipants": 6,
"orderCount": 2,
"enrollDeadline": "2026-09-25",
"departDate": "2026-10-01",
"endDate": "2026-10-03",
"receivableAmount": "48000.00",
"receivedAmount": "36000.00",
"chips": {
"hotel": "DONE",
"vehicle": "DONE",
"guide": "DONE",
"photo": "DONE",
"contract": "DONE",
"insurance": "DONE"
}
}
]
}
}
```
#### 空数据 / 降级响应
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"total": 0,
"list": []
}
}
```
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限",
"success": false,
"data": null
}
```
#### 业务边界
- 沿用团期列表权限校验(团期管理员/运营/定制师等);无权返回 589507。
- `productId` 与其他字段同时生效,始终非空(命中行/未命中行/孤儿行均返回)。
- 分页参数超界时返回空列表。
- 业务失败仍为 HTTP 200,需检查 code。
---
### 2. 团期详情 `GET /v3/admin/order/group-batch/{groupBatchId}`
**VO**: `GroupBatchDetailRespVO`(响应位置:`data`)
#### 使用场景
打开团期详情页时,读取产品 ID,供「新增子订单」按钮拼接深链,跳转订单创建向导并预填产品及班期。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | String | 是 | 正整数 ID | 团期主键 |
#### 出参 `Result<GroupBatchDetailRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data.groupBatchId` | String | 团期聚合主键 |
| `data.productBatchId` | String | product 侧班期 ID |
| `data.productId` | String | **【新增】** 产品 ID,供深链预填 |
| `data.productName` | String | 产品名称 |
| `data.batchNo` | String | 班期编号 |
| `data.batchName` | String | 班期名称 |
| `data.batchLabel` | String/null | 班期标签快照 |
| `data.batchStatus` | String | 团期状态码 |
| `data.batchStatusName` | String | 状态中文名 |
| `data.minGroupPeople` | Integer | 最低成团人数 |
| `data.maxRooms` | Integer | 房间容量 |
| `data.maxParticipants` | Integer | 人数容量 |
| `data.enrolledPeople` | Integer | 已报名人数 |
| `data.enrolledRooms` | Integer | 已用房间数 |
| `data.remainRooms` | Integer | 剩余房间数 |
| `data.remainParticipants` | Integer | 剩余人数 |
| `data.hotelReady` | Boolean | 配房完成标志 |
| `data.vehicleReady` | Boolean | 配车完成标志 |
| `data.guideReady` | Boolean | 导游完成标志 |
| `data.photographerReady` | Boolean | 摄影完成标志 |
| `data.materialConfirmed` | Boolean | 物资确认标志 |
| `data.requirementConfirmed` | Boolean | 需求整体确认标志 |
| `data.departDate` | String | 出发日期 |
| `data.endDate` | String | 结束日期 |
| `data.enrollDeadline` | String | 报名截止日 |
| `data.totalReceivable` | String | 整团应收合计 |
| `data.totalReceived` | String | 整团已收合计 |
| `data.remark` | String/null | 备注 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/2096412454643802114
Authorization: Bearer <token>
```
无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"groupBatchId": "2096412454643802114",
"productBatchId": "2052935476557328386",
"productId": "2044306857534636034",
"productName": "冻干粉发短信给",
"batchNo": "Q202610012052935476548939777",
"batchName": "10月1日长白山亲子团",
"batchLabel": null,
"batchStatus": "RESOURCE_PREPARING",
"batchStatusName": "资源准备中",
"minGroupPeople": 6,
"maxRooms": 4,
"maxParticipants": 10,
"enrolledPeople": 10,
"enrolledRooms": 4,
"remainRooms": 0,
"remainParticipants": 0,
"hotelReady": false,
"vehicleReady": false,
"guideReady": false,
"photographerReady": false,
"materialConfirmed": false,
"requirementConfirmed": false,
"departDate": "2026-10-01",
"endDate": "2026-10-03",
"enrollDeadline": "2026-09-25",
"totalReceivable": "48000.00",
"totalReceived": "36000.00",
"remark": null
}
}
```
#### 空数据 / 降级响应
详情接口不存在空数据响应。
#### 错误响应
```json
{
"code": 589501,
"message": "团期不存在",
"success": false,
"data": null
}
```
业务失败错误码沿用 GroupBatchErrorCode(权限/不存在等)。
#### 业务边界
- 沿用团期详情权限校验;无权返回 589507。
- `productId` 与其他字段同时返回,始终非空。
- 团期不存在返回业务码 589501(HTTP 200)。
---
### 3. 团期看板列表 `GET /v3/admin/order/group-batch/board?productId=`
**VO**: `GroupBatchBoardItemRespVO`(响应位置:`data[]`)
#### 使用场景
打开团期看板时,按产品维度加载该产品下的所有班期(产品侧班期为基底,左连运营侧团期数据),每一行携带 productId,供「新增子订单」深链按行数据拼接预填参数。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `productId` | Query | String | 是 | 正整数 ID | 按产品筛选(必填;缺参返回 400) |
#### 出参 `Result<List<GroupBatchBoardItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data[].productBatchId` | String | product 侧班期 ID |
| `data[].productId` | String | **【新增】** 产品 ID(= 请求入参;命中/未命中/孤儿行均非空) |
| `data[].batchNo` | String | 班期编号 |
| `data[].batchName` | String | 班期名称 |
| `data[].departureDate` | String | 出发日期 |
| `data[].endDate` | String | 结束日期 |
| `data[].enrollmentDeadline` | String | 报名截止日 |
| `data[].maxRooms` | Integer | 房间容量 |
| `data[].maxParticipants` | Integer | 人数容量 |
| `data[].enrolledRooms` | Integer | 已报名房数 |
| `data[].enrolledPeople` | Integer | 已报名人数 |
| `data[].remainRooms` | Integer | 剩余房间数 |
| `data[].remainParticipants` | Integer | 剩余人数 |
| `data[].batchStatus` | String | 团期状态码 |
| `data[].batchStatusLabel` | String | 状态中文名 |
| `data[].hotelReady` | Boolean | 配房完成标志 |
| `data[].vehicleReady` | Boolean | 配车完成标志 |
| `data[].guideReady` | Boolean | 导游完成标志 |
| `data[].photographerReady` | Boolean | 摄影完成标志 |
| `data[].needsGuide` | Boolean | 是否需领队 |
| `data[].needsPhotographer` | Boolean | 是否需摄影 |
| `data[].orderCount` | Integer | 活跃子订单数 |
| `data[].groupBatchId` | String/null | 团期聚合主键(未成团时为 null) |
| `data[].productBatchRemoved` | Boolean | 是否孤儿行(product 侧已删除该班期) |
| `data[].contractSignedCount` | Integer | 合同已签子订单数 |
| `data[].insuranceInsuredCount` | Integer | 保险已出子订单数 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/board?productId=2044306857534636034
Authorization: Bearer <token>
```
无请求体。
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{
"productBatchId": "2052935476557328386",
"productId": "2044306857534636034",
"batchNo": "Q202610012052935476548939777",
"batchName": "10月1日长白山亲子团",
"departureDate": "2026-10-01",
"endDate": "2026-10-03",
"enrollmentDeadline": "2026-09-25",
"maxRooms": 4,
"maxParticipants": 10,
"enrolledRooms": 2,
"enrolledPeople": 4,
"remainRooms": 2,
"remainParticipants": 6,
"batchStatus": "RECRUITING",
"batchStatusLabel": "招募中",
"hotelReady": false,
"vehicleReady": false,
"guideReady": false,
"photographerReady": false,
"needsGuide": true,
"needsPhotographer": false,
"orderCount": 2,
"groupBatchId": "2096412454643802114",
"productBatchRemoved": false,
"contractSignedCount": 1,
"insuranceInsuredCount": 1
},
{
"productBatchId": "2052935476557328387",
"productId": "2044306857534636034",
"batchNo": "Q202610022052935476548939778",
"batchName": "10月2日长白山亲子团",
"departureDate": "2026-10-02",
"endDate": "2026-10-04",
"enrollmentDeadline": "2026-09-26",
"maxRooms": 4,
"maxParticipants": 10,
"enrolledRooms": 0,
"enrolledPeople": 0,
"remainRooms": 4,
"remainParticipants": 10,
"batchStatus": "RECRUITING",
"batchStatusLabel": "招募中",
"hotelReady": false,
"vehicleReady": false,
"guideReady": false,
"photographerReady": false,
"needsGuide": true,
"needsPhotographer": false,
"orderCount": 0,
"groupBatchId": null,
"productBatchRemoved": false,
"contractSignedCount": 0,
"insuranceInsuredCount": 0
}
]
}
```
#### 空数据 / 降级响应
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": []
}
```
#### 错误响应
缺参 productId:
```json
{
"code": 400,
"message": "productId 参数缺失",
"success": false,
"data": null
}
```
---
#### 业务边界
- `productId` 必填,缺参返回 HTTP 200 + code 400;不要用 GET 参数默认值。
- 返回结构按产品侧班期为基底(左连团期数据):
- **命中行**(班期有对应团期):团期状态/容量/ready 取订单侧值;batchStatus ≠ RECRUITING
- **未命中行**(班期无团期):batchStatus 固定 RECRUITING;groupBatchId = null;容量取产品侧 maxRooms/maxParticipants
- **孤儿行**(团期但班期已删):productBatchRemoved = true;仅 orderCount > 0 时出现
- `productId` 在命中行、未命中行、孤儿行中均等于请求入参,始终非空。
- 表合并按 productBatchId 左连 order_group_batch;无团期记录时新增一行(未命中)。
---
## 四、契约约束与正确调用方式
| 场景 | 正确做法 |
|------|---------|
| 新增子订单深链 | 按行数据拼接 `/order-v2/new?productId={productId}&productBatchId={productBatchId}&departureDate={departureDate}` |
| 向导内 GROUP 产品创单 | 检查产品类型,仅 GROUP 类型在 POST payload 中带 productBatchId;其他类型忽略 |
| 非 GROUP 产品创单 | 忽略 productBatchId 参数,后端根据 productId 与 departureDate 自行逻辑 |
| 孤儿行处理 | 若 productBatchRemoved = true,需向用户提示"班期已下架,不可创建子订单"或禁用按钮 |
| 未成团行处理 | groupBatchId = null 时无团期记录;前端可选择隐藏"团期信息"列或显示"待成团" |
---
## 五、数据库行为
| 班期状态 | productBatchId | groupBatchId | batchStatus | 数据来源 |
|---------|----------------|-------------|------------|---------|
| 命中(有团期) | 产品侧 | 订单侧 | 订单侧 | order_group_batch 存在 |
| 未命中(无团期) | 产品侧 | null | RECRUITING | 无 order_group_batch 行 |
| 孤儿(班期删)| 产品侧 | 订单侧 | 订单侧 | order_group_batch 存在但班期无 |
新增 productId 字段在 productBatchId 后(响应顺序一致)。
---
## 六、边界行为
- 业务失败可能仍为 HTTP 200,必须同时检查 `code`、`success` 和 `message`。
- 看板接口 `productId` 缺参返回 400(HTTP 200);不要依赖前端参数校验。
- 分页/列表接口分页参数超界时返回空列表(无 5XX)。
- `productId` 在所有三个接口的所有行中均非空,无特殊情况返回 null。
- Long 型 ID 在 JSON 字符串化后,前端若需数值运算应保持字符串存储。
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `productId`(分页项) | 无此字段 | 新增;产品 ID |
| `productId`(详情) | 无此字段 | 新增;产品 ID |
| `productId`(看板项) | 无此字段 | 新增;产品 ID |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 新增子订单跳转 | 无法从响应直接拼深链参数 | 新增 productId,与 productBatchId/departureDate 配合直接拼链 |
| 向导内产品预填 | 依赖约定俗成或外部 context | 直接从深链 query string 传入 |
| GROUP 产品识别 | 向导内自行判别 | 向导根据产品类型自动识别是否需 productBatchId |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否。新增字段对旧客户端透明。
- **前端是否必须同步上线**: 否(从旧口径讲);但为完整支持团期看板「新增子订单」功能,前端应同步接入新增 productId 于深链拼接(仅一处改动)。
- **前端 workaround 清理点**:
- 新增子订单按钮处,改用响应中的 productId 拼深链,而非硬编码产品 ID
- 向导内创建 GROUP 订单时,检查产品类型再决定是否传 productBatchId(无需前端重构,只需补一个类型判断)
---
## 七、不影响范围
- **仅影响**: 管理后台团期看板的「新增子订单」功能入口
- **零影响**:
- 团期创建、编辑、审批、资源配置等写接口
- 团期与订单间的关联关系和业务流程
- 产品侧班期相关接口和定义
---
## 八、测试环境已验证
- **单测**: 79 条测试用例绿✓(新增 productId 相关的 UT 已覆盖命中/未命中/孤儿行三路径)
- **ArchTest**: 45 条架构测试绿✓
- **测试服网关实测**: 单测 + CR 通过;测试服网关实测见管理者补充
实测产品: `productId=2044306857534636034`(冻干粉发短信给),班期 `2026-10-01`(productBatchId=2052935476557328386),团期主键 `groupBatchId=2096412454643802114`,团期名 `batchName=10月1日长白山亲子团`。
---
## 九、相关历史 PR(功能演进)
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #7135 | - | GROUP 产品创单校验与重整(定义 productBatchId 入参) | ✅ 有效 |
| #7142 | - | 订单详情/列表/创单响应透出判团字段(groupBatchId/productBatchId/groupOrder)| ✅ 有效 |
| **本 PR #7157** | **#7143** | **团期看板 VO 补 productId(配合深链预填)** | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#7143](https://git.1814.love:8443/wx/HL/issues/7143)
- 关联 PR: [wx/HL#7157](https://git.1814.love:8443/wx/HL/pulls/7157)
- 相关工单: #7142(订单判团字段)、#7135(GROUP 产品规范)
- 前端缺陷: 新建订单向导漏传 productBatchId(另发前端 changelog)
## 关联 / 联系人
### 链接
- **Issue**: [#7143](https://git.1814.love:8443/wx/HL/issues/7143)
- **PR**: [#7157](https://git.1814.love:8443/wx/HL/pulls/7157)
- **Merge commit**: [ce2809c15](https://git.1814.love:8443/wx/HL/commit/ce2809c15)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,133 @@
---
schema: "hl-changelog/v2"
ticket: "7148"
title: "供应商账户停用审批及审批结果状态调整"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "7245780a"
target_release: ""
verified_at: "2026-09-06"
status_note: "后端已部署并通过 TEST;前端待接入停用审批入口与状态刷新"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 供应商:账户停用审批及审批结果状态调整
## 关键变化
停用审批通过后账户停用、驳回后启用;原账户新增审批通过后启用、驳回后改为停用。审批状态与账户状态分别展示。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 申请账户停用 | POST | `/admin/supplier/bank-accounts/{accountId}/disable` | 新增接口 | 复用现有账户审批模板与表单 |
## 三、接口详情
### 1. 申请账户停用 `POST /admin/supplier/bank-accounts/{accountId}/disable`
**VO**: `SupplierAccountDisableReqVO / BankAccountSubmitResultRespVO`
#### 使用场景
供应商账户页面对非默认启用账户提交停用申请。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| accountId | Path | String | 是 | 正整数 | 目标账户 ID |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 账户详情返回的 updateTime |
#### 出参 `Result<BankAccountSubmitResultVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| accountId / approvalLogId | String | 账户 / 本次审批记录 ID |
| requestNo | String | 同次申请重试时不变 |
| provider | String | 新停用申请为 WECOM |
| approvalStatus / approvalStatusName | String | 审批状态 / 中文名称 |
| spNo / spStatus | String 或 null | 企微单号 / 企微原始状态,尚未取得时为 null |
| syncStatus | String | REQUESTING 待同步、APPLIED 已应用、APPLY_FAILED 应用失败、RESULT_UNCERTAIN 待对账 |
| accountStatus | String | 当前账户状态 |
| isDefault | String | NO 非默认;历史申请重试返回当前默认标记 |
| submittedAt / finishedAt | String 或 null | 提交 / 审批完成时间,尚未发生时为 null |
#### 请求示例
`POST /admin/supplier/bank-accounts/7148001/disable`,携带正常管理端登录认证。
```json
{"expectedUpdateTime":"2026-09-06 12:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"accountId":"7148001","approvalLogId":"7148002","requestNo":"SUP-ACC-DISABLE-example","provider":"WECOM","approvalStatus":"PENDING","approvalStatusName":"审核中","spNo":"202609060001","spStatus":null,"syncStatus":"REQUESTING","accountStatus":"ACTIVE","isDefault":"NO","submittedAt":"2026-09-06 12:01:00","finishedAt":null}}
```
#### 错误响应
```json
{"code":395014,"message":"数据已被他人修改,请刷新后重试","success":false,"data":null}
```
#### 空数据 / 降级响应
不存在返回 395001;外部提交失败时可能返回已保留的审批与 `APPLY_FAILED` 或 `RESULT_UNCERTAIN`,不会把账户直接停用。
#### 业务边界
- 仅 SUPER_ADMIN 且具备 `supplier:account:manage`、`supplier:approval:submit` 可提交。主体须启用、账户须启用且非默认,主体与账户均不能有在途审批。门禁失败不新建申请;企微失败可能保留申请,须同时检查 `syncStatus`,不能把 `code=200` 当作已停用。
## 四、契约约束与正确调用方式
从最新账户详情取版本;同次失败重试复用原版本。已结束申请后重新申请须刷新版本。`RESULT_UNCERTAIN` 停止自动重提并等待对账;`APPLY_FAILED` 沿用原申请重试。默认账户须先切换默认。前端新增停用动作并在提交后刷新账户和审批状态。
## 五、数据库行为
审批中账户保持启用;只有停用审批通过才转为停用。驳回或撤销保留启用,重复请求不重复应用结果。
## 六、边界行为
未登录由网关拒绝;400 参数无效;395001 账户不存在;395004 无权限;395005 账户状态或默认标记不允许;395010 主体未启用;395011 账户已有在途审批;395014 版本过期。主体审批冻结返回 395005。
## 六.5、枚举 / 数据字典
### accountStatus(账户状态)
| 值 | 中文 | 说明 |
|---|---|---|
| ACTIVE | 启用 | 停用待审或驳回后保持可用 |
| DISABLED | 停用 | 停用通过或新账户审批驳回 |
| PENDING | 待审批 | 原新账户审批未结束 |
| REJECTED | 驳回 | 仅兼容历史账户记录 |
### approvalStatus(审批状态)
PENDING 审核中;APPROVED 已通过;REJECTED 已驳回;CANCELED 已撤销。终态重试返回账户当前状态,不据旧审批推导账户状态。
## 七、不影响范围
本单涉及供应商账户停用入口和审批结果状态;已有账户请求、查询返回结构保持兼容。
## 八、测试环境已验证
真实企微审批已验证:新增账户通过后为 `ACTIVE`、驳回后为 `DISABLED`;停用审批待审期间保持 `ACTIVE`,驳回后仍为 `ACTIVE`,通过后为 `DISABLED`。同版本重试复用原审批单;未登录、缺参、非法账户 ID、旧版本、在途审批和状态不允许均按契约拒绝,失败请求未新增审批或改写账户。测试账户最终均为非默认 `DISABLED`,原默认账户未变化。
## 十、相关文档
- **Issue**:[#7148](https://git.1814.love:8443/wx/HL/issues/7148)
- **PR**:[#7161](https://git.1814.love:8443/wx/HL/pulls/7161)
## 关联 / 联系人
- **后端负责人**:@lc
@@ -0,0 +1,442 @@
---
schema: "hl-changelog/v2"
ticket: "7149"
title: "团期子订单支付后即可提房车需求、物资准备起冻结、团单房型与间数必填"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "22eb9b03"
target_release: ""
verified_at: "2026-09-08"
status_note: "后端已合 dev-v3 并部署测试服、网关实测通过(PR #7177/#7184/#7200)。第二轮返工(2026-09-07):打回原因长度 512→500,房务置灰口径改按订单整体判。前端第一批已交付:团单房型大类+间数必填校验(FunItemAdjustModal,房数 0/空前端即拦截)、打回户房型需求入口恢复(RoomArrangeCard),ref 85fb6d52。0451e4c 错误码优先级口径更正(对外唯一变化:团单 582099 先于 582016;结构类先于状态类)前端仅 message 透传展示后端文案、无按码分支,零改动。第二批冻结置灰(物料准备中起置灰)待后端 #7197 订单详情透传 groupBatchStatus,本条保持 pending;期间 589536 兜底。
updated_at: "2026-09-06"
base: "dev-v3"
---
# 团期模块:子订单支付后即可提房车需求、物资准备起冻结、团单房型与间数必填
> **服务**: `hl-order-service-v3`
> **Issue**: #7149
> **PR**: #7177(squash 合入 dev-v3 `a52365278`)、#7184(复审补漏:汇总跳自订晚、团单房数缺失统一 582099)、#7200(直提入口 `PUT …/hotel-requirement` 团单房数 0 同样 582099,与调整入口同序)
> **日期**: 2026-09-06
> **影响范围**: 管理后台团期子订单详情「住宿安排 / 用车安排」提需求弹窗与「订单调整」提交
## ⚠️ 关键变化
三条行为改造,请求 / 响应结构一律不变:
1. **支付后即可提需求**:团期子订单(详情里 `groupOrder=true` / `productBatchId` 非空)在客户已支付(`orderStatus=CUSTOMIZING`)后即可提 / 改用房、用车需求,团期处于「招募中 `RECRUITING`」或「资源准备中 `RESOURCE_PREPARING`」都放行,不再等团期成团。改前团期非 `RESOURCE_PREPARING` 一律 589501「团期状态不允许当前操作」。
2. **物资准备起冻结**:团期进入「物料准备中 `MATERIAL_PREPARING`」及之后(`PENDING_DEPARTURE / TRAVELLING / REVIEWING / SETTLED`)提 / 改需求被拒,新错误码 **589536「团期已进入物资准备,需求已冻结,请联系团期管理员」**(文案里的「物资准备」就是状态芯片的「物料准备中」,同义)。唯一例外:该户该资源最新一版需求被团期管理员打回(`REJECTED_TO_CONSULTANT`)时可以重提一次,重提后再改仍 589536。团期 `CANCELLED` / 查不到团期仍 589501。
3. **团单房型大类与房间数必填**:团期子订单每个非自订晚(`customerSelfBooked` 非 `true`)的每段,按段首候选 `candidates[0].rooms[]` 逐行要求 `roomCategory` 非空且 `roomCount ≥ 1`(旧结构无 `rooms[]` 时按段级 `roomCategory` + `roomCount` 判),缺失返回新错误码 **582099「团期订单第{N}晚第{M}段需填写房型大类与房间数」**(M 从 1 起),拒绝时零副作用。核心订单不受影响,房型仍选填。**团单不再接受「加晚次空白占位」段**——每晚要么填齐房型行,要么标 `customerSelfBooked=true`。
前端要做:① 团期子订单提需求弹窗把「房型大类」改必填并提示;② 589536 / 582099 直接展示后端 `message`;③ 团期子订单在招募中即显示「提交房型需求 / 用车需求」入口;进入物料准备中后入口置灰或提示已冻结(被打回的户除外);④ 团单去掉「先提交空白占位」交互。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 提交 / 修改用房需求(兼容期 @Deprecated) | PUT | `/v3/admin/order/{id}/hotel-requirement` | 行为修改 | 团期闸门放宽 + 冻结 + 团单房型间数必填 |
| 2 | 订单调整统一提交 | POST | `/v3/admin/order/{id}/adjustment/submit` | 行为修改 | `updates.hotelRequirement` / `updates.vehicleRequirement` 走同一闸门与必填校验 |
| 3 | 提交 / 修改用车需求 | PUT | `/v3/admin/order/{id}/vehicle-requirement` | 行为修改 | 团期闸门放宽 + 冻结,不加必填 |
## 三、接口详情
### 1. 提交 / 修改用房需求 `PUT /v3/admin/order/{id}/hotel-requirement`
**VO**: `HotelRequirementReqVO → HotelRequirementRespVO`(`hl-order-service-v3/src/main/java/com/hulalv/order/requirement/controller/admin/vo/`)
#### 使用场景
定制师在子订单详情「住宿安排」提交或修改用房需求。后端按当前生效版本自动分支:无生效版本 → `INIT_SUBMIT`(新版本);生效版本为 `PENDING / PENDING_REVIEW` → `PENDING_EDIT`(同版本覆盖);`DONE` 等 → `DONE_ADJUST`(版本 +1)。团期子订单新版本状态恒为 `PENDING_REVIEW`(等团期管理员确认),不进房务抢单池。本端点为兼容期入口(#4515 标 @Deprecated),新客户端走接口 2。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | Path | String(Long) | 是 | 订单 ID | 团期子订单 `productBatchId` 非空 |
| `days` | Body | Array | 是 | 长度 = `tripNights`,`dayNumber` 1..tripNights 不重复 | 逐晚安排 |
| `days[].dayNumber` | Body | Integer | 是 | 1..tripNights | 第几晚 |
| `days[].customerSelfBooked` | Body | Boolean | 否 | `true` = 客人自订 | 自订晚不校验房型,`segments` 可空 |
| `days[].segments` | Body | Array | 非自订晚 ≥1 | 缺段 582098 | 当晚分住段 |
| `days[].segments[].candidates` | Body | Array | 是(≥1) | 候选酒店,房控择一 | `hotelId / hotelName` 可空(无酒店候选) |
| `days[].segments[].candidates[].rooms` | Body | Array | 新结构 | 房型行 | 团单按 `candidates[0].rooms` 逐行校验 |
| `…rooms[].roomCategory` | Body | String | **团单是** | 字典 `room_category`(STANDARD/SINGLE/TWIN/QUEEN/KING/SUITE/FAMILY/YURT/SPECIAL) | 核心订单选填不变 |
| `…rooms[].roomCount` | Body | Integer | **团单是,≥1** | 有行即 >0(582016) | 房数 |
| `…rooms[].roomTypeId / roomTypeName / protocolPrice / remark` | Body | Long / String / Decimal / String | 否 | — | 真实房型与快照,可空 |
| `days[].segments[].roomCategory / roomCount` | Body | String / Integer | 兼容 | 旧结构无 `rooms[]` 时团单必填 | 段级兼容字段 |
| `days[].segments[].budget / remark` | Body | Decimal / String(≤200) | 否 | 预算由后端按协议价覆盖 | — |
| `specialTags` | Body | String[] | 否 | 字典 `house_special_demand` | 特殊诉求 |
| `remark` | Body | String(≤500) | 否 | — | 备注 |
#### 出参 `Result<HotelRequirementRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.requirementId` | String | 需求行 ID |
| `data.version` | Integer | 版本号(INSERT-only 单调递增) |
| `data.isActive` | Boolean | 是否当前生效版本 |
| `data.status` | String | 团单恒 `PENDING_REVIEW`;核心为 `PENDING` |
| `data.submittedAt` | String | 首提时间 |
| `data.claimerId / claimerName / claimedAt` | String / String / String | 团单恒 null(不进抢单池) |
| `data.branchTaken` | String | `INIT_SUBMIT / PENDING_EDIT / DONE_ADJUST` |
| `data.previousVersion` | Integer | `DONE_ADJUST` 时上一版本号,否则 null |
| `data.assignmentDeletedCount` | Integer | `DONE_ADJUST` 时软删配房行数,否则 null |
#### 请求示例
```json
{
"days": [
{ "dayNumber": 1, "segments": [ { "remark": "第1晚", "candidates": [ { "hotelName": "网关实测酒店", "rooms": [ { "roomTypeName": "测试房型", "roomCategory": "KING", "roomCount": 1 } ] } ] } ] },
{ "dayNumber": 2, "segments": [ { "remark": "第2晚", "candidates": [ { "hotelName": "网关实测酒店", "rooms": [ { "roomTypeName": "测试房型", "roomCategory": "TWIN", "roomCount": 2 } ] } ] } ] }
],
"remark": "#7149 网关实测"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"requirementId": "2096504560393551873",
"version": 1,
"isActive": true,
"status": "PENDING_REVIEW",
"submittedAt": "2026-09-06 15:44:21",
"claimerId": null,
"claimerName": null,
"claimedAt": null,
"branchTaken": "INIT_SUBMIT",
"previousVersion": null,
"assignmentDeletedCount": null
}
}
```
#### 空数据 / 降级响应
本接口无空列表语义;`days` 为空数组或长度与 `tripNights` 不符返回 582011「天数长度与订单住宿晚数不一致」。第 2 晚 `customerSelfBooked=true` 且无 `segments` 时正常返回 200(自订晚不进房务分母,团单房型校验跳过)。
#### 错误响应
```json
{ "code": 582099, "message": "团期订单第2晚第1段需填写房型大类与房间数", "success": false, "data": null }
```
```json
{ "code": 589536, "message": "团期已进入物资准备,需求已冻结,请联系团期管理员", "success": false, "data": null }
```
其它沿用:589501「团期状态不允许当前操作」(团期 `CANCELLED` / 查不到)、582098「第{N}晚缺少用房需求,请填写房间需求或标记为客户自订」、582016「房间数必须大于 0」、582019「候选方案必须至少含 1 个房型行」、582017「订单状态不允许提交需求」(未支付)。HTTP 始终 200,错误在 `code` / `message`。
#### 业务边界
- 团期闸门(房车共用):`RECRUITING / RESOURCE_PREPARING` 放行;`MATERIAL_PREPARING / PENDING_DEPARTURE / TRAVELLING / REVIEWING / SETTLED` 及未知状态 589536;`CANCELLED` / 查不到团期 589501;核心订单不查团期。
- 冻结期例外:该户用房需求最高版本组(同 version 的一组行,含已失活的打回行;有生效行看生效行,否则组内任一行)为 `REJECTED_TO_CONSULTANT`(团期管理员打回)时放行重提,新版本回到 `PENDING_REVIEW`;之后再改仍 589536,要再改只能再次被打回。
- 团单房型间数必填按「段首候选 rooms 行」判,与全团需求汇总 `requirement-summary` 同口径;旧结构(无 `rooms[]`)按段级 `roomCategory + roomCount` 合成一行判。
- 校验顺序(接口对外可观察口径,PR #7184 / #7200 起):本接口**进事务前**先做入口校验——团单房型间数(582099,仅团单,含房数为空 / 0)→ 通用结构校验(582098 / 582016 / 582019 / 582020);都通过才进事务依次查订单状态(582017)→ 团期闸门(589536 / 589501)→ 晚数匹配(582011)→ 已配房晚改自订(582097)。事务内再跑的 582099 / 结构校验只是兜底,不会先于入口触发。因此**结构类错误仍先于订单状态与团期闸门返回**(冻结期团单缺房型得到 582099 而不是 589536);本单对外只改变一点:团单 582099 先于 582016。任一拒绝均在写库之前,无新版本、`room_control_status` 不变。核心订单仍以通用结构校验给码(房数 0 → 582016)。
- 成功后 `order_main.room_control_status=PENDING_REVIEW`(既有行为),团期成团时定制师不再产生「房型需求 · 待提交」待办。
### 2. 订单调整统一提交 `POST /v3/admin/order/{id}/adjustment/submit`
**VO**: `AdjustmentSubmitReqVO → AdjustmentSubmitRespVO`(`hl-order-service-v3/src/main/java/com/hulalv/order/adjustment/controller/admin/vo/`)
#### 使用场景
管理端子订单详情「订单调整」一次性提交各子域改动;本次只涉及 `updates.hotelRequirement`(用房需求完整新版本)与 `updates.vehicleRequirement`(用车需求完整新版本),两者与接口 1 / 接口 3 走同一团期闸门与团单房型间数校验。其它子域(人数 / 日期 / 出行人等)本次不变、省略。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | Path | String(Long) | 是 | 订单 ID | — |
| `updates` | Body | Object | 是 | 至少一个子域非空 | 各子域修改内容 |
| `updates.hotelRequirement` | Body | `HotelRequirementBodyVO` | 否 | 结构同接口 1 的 `days / specialTags / remark` | 用房需求完整新版本,团单房型间数必填规则同接口 1 |
| `updates.vehicleRequirement` | Body | `VehicleRequirementBodyVO` | 否 | 结构同接口 3 的 `fleet / specialTags / pickupRequired / dropoffRequired / remark` | 用车需求完整新版本 |
#### 出参 `Result<AdjustmentSubmitRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.success` | Boolean | 提交成功恒 `true` |
#### 请求示例
```json
{
"updates": {
"hotelRequirement": {
"days": [
{ "dayNumber": 1, "segments": [ { "candidates": [ { "hotelName": "网关实测酒店", "rooms": [ { "roomCategory": "KING", "roomCount": 1 } ] } ] } ] },
{ "dayNumber": 2, "segments": [ { "candidates": [ { "hotelName": "网关实测酒店", "rooms": [ { "roomCategory": "TWIN", "roomCount": 1 } ] } ] } ] }
],
"remark": "#7149 网关实测 调整入口"
}
}
}
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "success": true, "data": { "success": true } }
```
#### 空数据 / 降级响应
`updates` 各子域全空时沿用既有「无有效变更」拒绝;`hotelRequirement` 提交成功但版本号不变(`PENDING_EDIT` 覆盖同版本)属正常,前端以订单详情重新拉取为准。
#### 错误响应
```json
{ "code": 589536, "message": "团期已进入物资准备,需求已冻结,请联系团期管理员", "success": false, "data": null }
```
```json
{ "code": 582099, "message": "团期订单第2晚第1段需填写房型大类与房间数", "success": false, "data": null }
```
#### 业务边界
- 调整入口绕过「订单须为定制中」的普通提交闸,但**不绕**团期闸门与团单房型间数校验,行为与接口 1 / 3 一致。
- 团期冻结期内该入口同样 589536,被打回户例外同接口 1。
- 拒绝发生在事务内任何写操作之前,其它子域改动一并回滚。
### 3. 提交 / 修改用车需求 `PUT /v3/admin/order/{id}/vehicle-requirement`
**VO**: `VehicleRequirementReqVO → VehicleRequirementRespVO`(`hl-order-service-v3/src/main/java/com/hulalv/order/requirement/controller/admin/vo/`)
#### 使用场景
定制师在子订单详情「用车安排」提交或修改用车需求。本次只改团期闸门(放宽 + 冻结 + 打回例外),**不加任何必填**;车型 / 座位联动校验、容量校验、派单展开等既有逻辑不变。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | Path | String(Long) | 是 | 订单 ID | — |
| `fleet` | Body | Array | 是(≥1) | 缺 582021 | 用车明细 |
| `fleet[].vehicleType` | Body | String | 是 | 车务车型大类 key(582022) | 车型大类 |
| `fleet[].seats` | Body | Integer | 是 | 该车型可选座位数(582024) | 座位数 |
| `fleet[].count` | Body | Integer | 是 | >0(582023) | 车辆数 |
| `specialTags` | Body | String[] | 否 | 字典 `vehicle_special_demand`(582025) | 特殊诉求 |
| `pickupRequired / dropoffRequired` | Body | Boolean | 否 | — | 接 / 送机 |
| `remark` | Body | String | 否 | — | 备注 |
#### 出参 `Result<VehicleRequirementRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.requirementId` | String | 需求行 ID |
| `data.version` | Integer | 版本号 |
| `data.isActive` | Boolean | 是否当前生效版本 |
| `data.status` | String | 团单恒 `PENDING_REVIEW` |
| `data.submittedAt` | String | 首提时间 |
| `data.branchTaken` | String | `INIT_SUBMIT / PENDING_EDIT / DONE_ADJUST` |
#### 请求示例
```json
{ "fleet": [ { "vehicleType": "SUV", "seats": 7, "count": 1 } ] }
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": { "requirementId": "2096504700000000001", "version": 1, "isActive": true, "status": "PENDING_REVIEW", "submittedAt": "2026-09-06 15:46:02", "branchTaken": "INIT_SUBMIT" }
}
```
#### 空数据 / 降级响应
`fleet` 为空返回 582021「用车需求数组不能为空」;车队车型库不可用返回 582091「车队车型库不可用,无法校验座位数选项」(既有降级,本次不变)。
#### 错误响应
```json
{ "code": 589536, "message": "团期已进入物资准备,需求已冻结,请联系团期管理员", "success": false, "data": null }
```
其它沿用:589501(团期 `CANCELLED` / 查不到)、582022 / 582024 / 582023 / 582025(车型 / 座位 / 数量 / 诉求校验)。
#### 业务边界
- 团期闸门与接口 1 完全一致(同一方法),放行 / 冻结 / 打回例外按**用车需求**自身的最新一版判。
- 车侧不加房型类必填;`fleet` 结构与既有校验不变。
- 成功后 `order_main.vehicle_control_status=PENDING_REVIEW`(既有行为)。
## 四、契约约束与正确调用方式(接口类必写)
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload 要点 | 结果 |
|---|---|---|
| ✅ 团单每晚房型行齐全 | `candidates[0].rooms[]` 每行 `roomCategory` + `roomCount ≥ 1` | 200,`status=PENDING_REVIEW` |
| ✅ 团单某晚客人自订 | `{ "dayNumber": 2, "customerSelfBooked": true }`,无 `segments` | 200,该晚跳过校验 |
| ✅ 旧结构团单 | 无 `rooms[]`,段级 `roomCategory` + `roomCount` 齐全 | 200 |
| ❌ 团单房型行缺 `roomCategory` | `rooms: [ { "roomCount": 1 } ]` | 582099 |
| ❌ 团单房型行房数为空 / 0 | `rooms: [ { "roomCategory": "KING", "roomCount": 0 } ]` | 582099(PR #7184 起;核心订单仍 582016) |
| ❌ 团单旧结构缺段级 `roomCategory` | 段级只有 `roomCount`,候选带 `roomTypeId` | 582099 |
| ❌ 团单「加晚次空白占位」段 | 候选与段全空 | 582099(核心订单该形态仍放行) |
| ❌ 团期物料准备中提交 / 修改 | 任何合法 payload | 589536(该户最新需求被打回除外) |
| ❌ 子订单未支付 | 任何 payload | 582017「订单状态不允许提交需求」 |
### 切换状态时的必要动作
- 团期从招募中推进到物料准备中后,前端应把提需求入口置灰并以 589536 文案提示;管理员打回某户后该户入口恢复一次。
- 团单弹窗保存前在前端做房型大类必填校验,减少 582099 往返;后端仍以 582099 兜底。
## 五、数据库行为(涉及写操作时必写)
- 无表结构变更、无 Flyway。
- 提交成功:`order_hotel_requirement` / `order_vehicle_requirement` 写入新版本(`PENDING_EDIT` 为同版本换行,其它版本 +1),`status=PENDING_REVIEW`;`order_main.room_control_status` / `vehicle_control_status` 回写 `PENDING_REVIEW`(既有行为)。
- 被拒(589536 / 582099 / 589501 / 结构校验):无任何写入。
## 六、边界行为
- **团单不再接受「加晚次空白占位」段**:核心订单允许先提交全空的候选 / 段占位,团期子订单的非自订晚一律按缺房型拒绝(582099「第 N 晚第 M 段」);团单每晚要么填齐 `rooms[]`,要么标 `customerSelfBooked=true`。
- **文案「物资准备」= 团期状态芯片「物料准备中」(`MATERIAL_PREPARING`)**:589536 文案沿用代码里的「物资准备」叫法,与状态枚举文案同义,前端直接展示 `message` 即可。
- **冻结期打回只给一次重提机会**:重提后最新版变 `PENDING_REVIEW`,再改回到 589536;这是有意设计(改需求须经管理员再次打回)。
- **自订晚**:`customerSelfBooked=true` 的晚不做房型校验;全团汇总 `requirement-summary` 也整晚跳过(PR #7184 复审补漏),残留 `segments/rooms` 不计入采购分母、不再出「未知」桶——前端标记自订后仍建议清空该晚 `segments`,但后端不依赖它。
- **未知团期状态**按冻结处理(fail closed),不会误放行。
- **错误码优先级(2026-09-06 复审更正,以本条为准)**:两条对外入口的可观察顺序都是「结构类校验先于状态类校验」——`PUT …/hotel-requirement` 在进事务前先做 团单 582099 → 通用结构 582098 / 582016 / 582019 / 582020,之后才是 582017 → 589536 / 589501 → 582011 → 582097;`adjustment/submit` 在自身状态边界守卫之后、任何写库与 Feign 之前先做 团单 582099 → 通用结构,团期闸门 589536 / 589501 与晚数 582011 在落库阶段。本单对外**唯一**的优先级变化是团单 582099 先于 582016(两入口一致)。PR #7184 把事务内 `doUpsertHotelRequirement` 的结构校验挪到闸门之后只是内部兜底顺序(供改期减天等内部路径),对外不可观察,核心订单的错误码顺序没有变化。本文早先版本写「结构校验挪到订单状态 / 闸门之后、核心订单也受影响」是把事务内顺序误当成接口整体优先级。
- **调整入口同口径(PR #7184)**:`adjustment/submit` 对团期子订单同样先做团单房型间数校验,房数为空 / 0 也返回 582099「第 N 晚第 M 段」,不再先出 582016。
- **全团汇总某晚全团自订则无该晚条目**:`dailyRoomBreakdown` 按 dayNumber 升序但不保证连续,前端逐日表按 dayNumber 对齐、不要按下标。
- **子订单详情统计同步(PR #7184)**:订单详情 `hotelRequirementBrief.totalRoomCount / roomTypeSummary` 也不再计入自订晚残留段,与全团汇总一致;该晚仍出现在 `days[]` 里,并**新增响应字段** `hotelRequirementBrief.days[].customerSelfBooked`(Boolean,纯新增不破坏契约)供前端解释「段里有房型行但不计入总数」。
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
### roomCategory(字典 `room_category`)
| 值 | 含义 |
|---|---|
| STANDARD | 标准间 |
| SINGLE | 单人间 |
| TWIN | 双床房 |
| QUEEN | 大床房 |
| KING | 特大床房 |
| SUITE | 套房 |
| FAMILY | 家庭房 |
| YURT | 帐篷 / 毡房 |
| SPECIAL | 特色房 |
### 团期状态(`com.hulalv.order.groupbatch.enums.GroupBatchStatus`)
| 值 | 芯片文案 | 提 / 改需求 |
|---|---|---|
| RECRUITING | 招募中 | 放行 |
| RESOURCE_PREPARING | 资源准备中 | 放行 |
| MATERIAL_PREPARING | 物料准备中 | 589536(打回户例外) |
| PENDING_DEPARTURE | 待出发 | 589536(打回户例外) |
| TRAVELLING | 出行中 | 589536(打回户例外) |
| REVIEWING | 核单中 | 589536(打回户例外) |
| SETTLED | 已结算 | 589536(打回户例外) |
| CANCELLED | 已取消 | 589501 |
### 需求状态(`com.hulalv.order.requirement.enums.RequirementStatus`,`data.status` / `room_control_status`)
| 值 | 含义 |
|---|---|
| PENDING_REVIEW | 待团期管理员审核(团单提交后) |
| REJECTED_TO_CONSULTANT | 已打回定制师(冻结期可重提一次) |
| PENDING / PROCESSING / DONE | 核心订单 / 管理员提交房务后的房务侧状态,本次不变 |
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
请求无字段增删;`rooms[].roomCategory`、`rooms[].roomCount`(及旧结构段级同名字段)由「选填」改为「团期子订单必填」,核心订单不变。响应新增 `GET /v3/admin/order/{id}`(订单详情)`hotelRequirementBrief.days[].customerSelfBooked`(Boolean,PR #7184)。
### 行为级对比
| 行为 | 修改前 | 修改后 |
|---|---|---|
| 团期子订单提 / 改需求时机 | 团期须为 `RESOURCE_PREPARING`(成团后),否则 589501 | 支付后即可:`RECRUITING / RESOURCE_PREPARING` 放行 |
| 团期物料准备及之后 | 589501 | 589536(新码,文案明确「已冻结」),最新需求被打回的户可重提一次 |
| 团单缺房型大类 / 房数 | 放行,全团汇总出现「未知」房型 | 582099 拒绝,零副作用(房数为空 / 0 也是 582099,不再先出 582016;PR #7184) |
| 自订晚残留段进入全团汇总 | 计入「未知」桶 | 整晚跳过,不计入(PR #7184) |
| 团单空白占位段 | 放行 | 582099 拒绝 |
| 用车需求 | 同 589501 闸 | 同新闸门,不加必填 |
## 六.7、影响评估(修改/删除类必写)
- 破坏向后兼容:**部分**——请求 / 响应结构不变,但团单缺房型或空白占位段的旧调用会从 200 变 582099,招募中的调用从 589501 变 200,物料准备中从 589501 变 589536。
- 前端是否必须同步上线:**建议同步**——不同步时功能可用但用户会收到 582099 / 589536 提示且入口显示时机不准。
- 前端需清理的分支:「未成团不显示提需求入口」「物料准备中仍允许提交」「团单空白占位先提交」。
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- 核心订单(`productBatchId` 为空)的提需求、房型选填、抢单池、房务配房全部不变。
- 团期管理员确认 / 打回 / 提交房务(`…/hotel-requirement/reject`、`…/dispatch`、`group-batch/{groupBatchId}/requirement/*`)本次不改;打回后重提的版本号规则不变。
- `GET /v3/admin/order/group-batch/{groupBatchId}/requirement-summary` 结构不变,只是按本次提交的团单不再出现 `roomCategory="未知"` 项(历史数据仍可能出现)。
- 网关路由、权限码、表结构均无改动。
## 八、测试环境已验证
**环境**:TEST 网关 `https://api.test.1814.love:9443`,order-v3 dev-v3 `a52365278` 两实例 2026-09-06 15:43 滚动部署,登录 `admin`(adminId 1001,SUPER_ADMIN,且为两张测试单的定制师),实测 15:44–15:47。
| 场景 | 请求 | 结果 |
|---|---|---|
| 招募中(`RECRUITING`)子订单 2096412454488612866 提完整房型 | `PUT …/hotel-requirement` | 200,`status=PENDING_REVIEW` |
| 招募中,第 2 晚房型行缺 `roomCategory` | `PUT …/hotel-requirement` | 582099「团期订单第2晚第1段需填写房型大类与房间数」,DB 无新版本 |
| 招募中,旧结构段级缺 `roomCategory`(候选带 `roomTypeId`) | `PUT …/hotel-requirement` | 582099,同上 |
| 资源准备中完整提交 | `PUT …/hotel-requirement` | 200,`version=1 INIT_SUBMIT PENDING_REVIEW`;`order_main.room_control_status=PENDING_REVIEW` |
| 资源准备中经调整入口改需求 | `POST …/adjustment/submit` | 200,`data.success=true`(同版本 `PENDING_EDIT` 换行) |
| 第 2 晚 `customerSelfBooked=true` 无段 | `PUT …/hotel-requirement` | 200 |
| 团期 2089713777065832450 子订单 2096029450184347649 资源准备中提交 | `PUT …/hotel-requirement` | 200 |
| 团期管理员打回该户 | `POST …/hotel-requirement/reject` | 200,需求行 `REJECTED_TO_CONSULTANT`、`is_active=0` |
| 团期改为 `MATERIAL_PREPARING` 后被打回户重提 | `PUT …/hotel-requirement` | 200,`version=2 PENDING_REVIEW`(冻结期例外) |
| 重提后再改 | `PUT …/hotel-requirement` | 589536「团期已进入物资准备,需求已冻结,请联系团期管理员」 |
| 物料准备中经调整入口改需求 | `POST …/adjustment/submit` | 589536 |
| 物料准备中提用车需求 | `PUT …/vehicle-requirement` | 589536 |
**单测**:`RequirementServiceTest` 244 / `OrderTodoServiceTest` 15 / `RequirementGroupBatchErrorCodeRangeTest` 3 + 5 个 ArchTest(RedLine / MapperBoundary / HouseModuleBoundary / DashboardLayer / LocalCacheVetting)全绿,`BUILD SUCCESS` 396 用例。
---
---
## 十、相关文档
### 第二轮复审返工要点(2026-09-07)
本次对外前端变更汇总(同步自 #7210,两文件保持一致):
| 项 | 变更 | 前端影响 |
|---|---|---|
| ① 打回原因长度 | 512 → 500(`/hotel-requirement/reject`、`/vehicle-requirement/reject` 的 `returnRemark`,`/hotel-requirement/dispatch`、`/vehicle-requirement/dispatch` 的 `dispatchRemark`) | 打回 / 分房备注输入框 `maxlength="500"`;501+ 字返 400 |
| ② 房务置灰口径 | 改按订单整体判已分房;新增 `disabledReason` 字段「本单尚有挂在历史需求版本上的配房未清理」 | 按 disabledReason 文案直接显示,无需按码分支 |
(完整变更详见 #7210 文件的第二轮返工表)
- 团期模块接口文档 `docs/group/团期模块接口文档-v2.0.html` §0C.11.1(提需求时机)/ §0C.11.2(房型间数必填)/ GB-ADM-011:<https://web.test.1814.love:9443/hl-docs/group/>
- 团期房务实现方案 `docs/group/团期房务实现方案-v1.0.html` §3.12.1 / §3.12.2
- 后续工单(本次不做):管理员确认 / 打回改造(M1 / M2)、房务团期看板与按日订房、定制师 ↔ 团期管理员站内会话
## 关联 / 联系人
### 链接
- Issue: <https://git.1814.love:8443/wx/HL/issues/7149>
- PR: <https://git.1814.love:8443/wx/HL/pulls/7177>
- 合并提交: `a52365278`(dev-v3)
### 联系人
- 后端:wx
- 前端(hl-ui):mmg
@@ -0,0 +1,644 @@
---
schema: "hl-changelog/v2"
ticket: "7154"
title: "团期财务总览与预支复用订单预支:财务 Tab 三个只读端点 + 预支创建语义改造"
consumer: "admin"
author: "jw(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "af7cc62b"
target_release: ""
verified_at: "2026-09-08"
status_note: "后端 PR #7170 已合 dev-v3 并部署测试服(网关 finance/advances/payee-candidates 实测 200),原 backend_status/gateway_status=pending 系 09-06 陈旧快照已修正为 deployed/verified。前端团期财务 Tab 与预支接真已交付:groupBatchFinance.js 五端点+FinanceTab(四金额卡/逐户付款表/预支记录/整团核算口径行)+GroupAdvanceModal+detail 挂财务 Tab+store 键缓存+AdvanceApprovalList scope 筛选;金额全后端权威值零反算。ref af7cc62b,checkpoint 全绿含 Vitest 全量+生产构建。"
updated_at: "2026-09-08"
base: "dev-v3"
---
# 团期: 财务总览与预支复用订单预支
> **服务**: hl-order-service-v3
> **PR**: #7170
> **Issue**: #7154
> **日期**: 2026-09-06
> **影响范围**: 管理后台团期详情「财务」Tab、底部操作条「预支」弹窗、财务管理→预支审批列表
---
## ⚠️ 关键变化
团期财务 Tab 与团期预支弹窗此前**在 hl-ui 里完全不存在**(全仓零调用点),本次补齐后端。
四条前端必读:
1. **`POST .../group-batch/{id}/advance` 入参整体更换**。旧契约 `{amount, remark}` 是后端造了、前端从没接过的端点(已核 hl-ui v2.1 全仓无调用点),故**不是破坏性变更**;新契约与订单级预支**逐字一致**,预支弹窗组件可整体复用。
2. **「已预支」拆成三个数,旧 `advanceTotal` 已废**。旧语义是「提交即计入」(无审批),与新口径不等价:展示用 `advanceApproved`(只计已通过),额度用 `advanceAvailable`。**不要再用一个数**。
3. **预支上限一律读后端 `advanceAvailable`,不要前端自算**。订单级弹窗现在是本地 `balanceAmount − Σ记录` 算的,依赖记录列表已拉全;团期场景该算法不可靠。
4. **「设置报账人」传的是产品侧 `productBatchId`**,从团期详情接口取,**不是**财务 Tab 路径上的 `groupBatchId`,两者不同值。该端点后端零改动。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 团期财务总览 | GET | `/v3/admin/order/group-batch/:id/finance` | 新增 | 四张金额卡 + 逐户付款 + 整团合计 |
| 2 | 团期预支记录 | GET | `/v3/admin/order/group-batch/:id/advances` | 新增 | 团期级 + 各子订单级逐笔,不分页 |
| 3 | 领款人候选 | GET | `/v3/admin/order/group-batch/:id/advance/payee-candidates` | 新增 | 预支弹窗「借款对象」下拉 |
| 4 | 发起团期预支 | POST | `/v3/admin/order/group-batch/:id/advance` | 修改 | 路径不变,入参与返回体更换;创建即进待审批 |
| 5 | 预支审批列表 | GET | `/v3/admin/order/advance-approvals/page` | 修改 | 容忍团期级行 + `scope` 筛选 + keyword 六路 |
| 6 | 整团核算汇总 | GET | `/v3/admin/order/group-batch/:id/settlement/summary` | 修改 | 出参新增两个整团预支只读字段 |
**零改动复用**(同一套审批流程,前端无需改):`PUT /v3/admin/order/advance/:id/approve`、
`PUT /v3/admin/order/advance/:id/reject`、`DELETE /v3/admin/order/advance/:id`、
`PUT /v3/admin/group-batch/:id/staff/:id/reporter-rank`。
---
## 三、接口详情
### 1. 团期财务总览 `GET /v3/admin/order/group-batch/:id/finance`
**VO**: `GroupBatchFinanceRespVO`
#### 使用场景
团期详情切到「财务」Tab 时加载。一次返回顶部四张金额卡、逐户付款表与整团合计、已退团户数、主/次报账人,Tab 全部内容一个请求搞定。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | path | Long | ✓ | 正整数 | 团期 ID(订单侧 `order_group_batch.group_batch_id`) |
无 query 参数,无请求体。需 `group-batch:finance:view` 权限码(比 `group-batch:view` 更严)。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `receivableAmount` | BigDecimal | 整团应收 = Σ 活跃子订单(订单金额 + 附加费 − 优惠) |
| `receivedAmount` | BigDecimal | 整团已收 = Σ 已付(累计毛额,不冲抵退款) |
| `unpaidAmount` | BigDecimal | 整团待收 = 应收 − 已收,下限 0,即本期总尾款 |
| `advanceApproved` | BigDecimal | 已预支(只计已通过)。**第四张卡取此值,不是三个数之和** |
| `advancePending` | BigDecimal | 待审批预支,占额度但未出账,不计入「已预支」卡 |
| `advanceAvailable` | BigDecimal | 可支取余额 = 尾款池 − (已通过 + 待审批),下限 0,即预支上限 |
| `withdrawnCount` | Integer | 已退团户数(前端只拉活跃集,算不出这个数) |
| `primaryPayeeName` | String | 主报账人姓名,未设置为 `null`(后端不兜底默认导游) |
| `secondaryPayeeName` | String | 次报账人姓名,未设置为 `null` |
| `totals.totalPrice` / `.paidAmount` / `.unpaidAmount` | BigDecimal | 逐户表末行「整团合计」,**服务端算**,与顶部卡同源 |
| `items[].orderId` | String | 子订单 ID(字符串回传防精度丢失) |
| `items[].orderNo` | String | 子订单号 |
| `items[].customerName` | String | 客户姓名(不返回手机号与证件号) |
| `items[].consultantName` | String | 定制师姓名 |
| `items[].totalPrice` | BigDecimal | 本户应收 |
| `items[].paidAmount` | BigDecimal | 本户已付。⚠️ 是**全额已付**,不是真定金拆分 |
| `items[].unpaidAmount` | BigDecimal | 本户待收尾款 |
| `items[].payStatus` | String | 仅 `UNPAID` / `DEPOSIT_PAID` / `FULLY_PAID` **三值** |
| `items[].settleStatus` | String | `SETTLED` / `PENDING_BALANCE` / `WITHDRAWN`,**状态胶囊取此值** |
| `items[].settleStatusText` | String | 结清状态中文,可直接渲染 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/finance
```
#### 响应示例
```json
{
"code": 0,
"msg": "success",
"data": {
"receivableAmount": 40200.00,
"receivedAmount": 32900.00,
"unpaidAmount": 7300.00,
"advanceApproved": 0.00,
"advancePending": 0.00,
"advanceAvailable": 7300.00,
"withdrawnCount": 0,
"primaryPayeeName": "张领队",
"secondaryPayeeName": null,
"totals": { "totalPrice": 40200.00, "paidAmount": 32900.00, "unpaidAmount": 7300.00 },
"items": [
{ "orderId": "770145", "orderNo": "GT-26-0081", "customerName": "罗敏", "consultantName": "李雯",
"totalPrice": 12300.00, "paidAmount": 12300.00, "unpaidAmount": 0.00,
"payStatus": "FULLY_PAID", "settleStatus": "SETTLED", "settleStatusText": "已结清" },
{ "orderId": "770147", "orderNo": "GT-26-0083", "customerName": "汪洋", "consultantName": "陈璐",
"totalPrice": 12300.00, "paidAmount": 5000.00, "unpaidAmount": 7300.00,
"payStatus": "DEPOSIT_PAID", "settleStatus": "PENDING_BALANCE", "settleStatusText": "待收尾款" }
]
}
}
```
#### 空数据 / 降级响应
无活跃子订单时,六个金额字段均为 `0.00`(**不是 null**),`items` 为空数组,`withdrawnCount` 为 `0`,两个报账人为 `null`。
#### 错误响应
| 码 | 含义 |
|---|---|
| `589500` | 团期不存在或已软删 |
| `589507` | 缺 `group-batch:finance:view` 权限 |
```json
{ "code": 589500, "msg": "团期不存在", "data": null }
```
#### 业务边界
- 已取消与已软删子订单**不进任何金额、不进 `items`**,只贡献 `withdrawnCount`。
- 隐式团(`IMPLICIT_SINGLE`)按一单退化开放,字段结构不变,**前端不得因团类型走两套渲染分支**。
- 顶部「已收(定金)」卡与逐户「已付定金」列,语义都是**全额已付**,不区分定金/尾款;已结清户该列等于应收。
- 整团应收与团期详情 `totalReceivable`、看板列表该行应收**三处同源**,数值必然一致。
---
### 2. 团期预支记录 `GET /v3/admin/order/group-batch/:id/advances`
**VO**: `List<GroupBatchAdvanceItemVO>`
#### 使用场景
财务 Tab 下半部「预支记录」区块,以及预支弹窗内的记录列表。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | path | Long | ✓ | 正整数 | 团期 ID |
无 query 参数,**不分页**,一次返回本期全部。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | String | 预支单 ID |
| `payeeStaffId` | String | 领款人主键快照 |
| `payeeName` / `payeeRole` / `payeeRoleText` | String | 领款人姓名 / 角色码 / 角色中文 |
| `advanceType` | String | 借款类型 |
| `amount` | BigDecimal | 预支金额 |
| `purpose` / `voucherUrl` | String | 用途说明 / 凭证 URL |
| `status` / `statusText` | String | `SUBMITTED` / `APPROVED` / `REJECTED` 及其中文 |
| `rejectReason` | String | 驳回原因 |
| `createdByName` | String | 申请人姓名 |
| `createTime` / `submittedAt` / `approvedAt` | String | 创建 / 提交 / 审批时间 |
| `approvedBy` | String | 审批人姓名 |
| `scope` | String | `ORDER` 订单级 / `GROUP_BATCH` 团期级(**本次新增**) |
| `scopeName` | String | 归属维度中文,可直接渲染标签(**本次新增**) |
| `orderNo` | String | 子订单号,`scope=ORDER` 时非空;团期级为 `null`(**本次新增**) |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/advances
```
#### 响应示例
```json
{
"code": 0,
"msg": "success",
"data": [
{ "id": "31005", "scope": "GROUP_BATCH", "scopeName": "团期预支", "orderNo": null,
"payeeName": "朝鲁门", "payeeRole": "DRIVER", "payeeRoleText": "司机",
"advanceType": "住宿押金", "amount": 5000.00, "purpose": "沿途住宿押金",
"status": "APPROVED", "statusText": "已通过", "createdByName": "李雯",
"submittedAt": "2026-08-18 22:58:26", "approvedAt": "2026-08-19 09:12:03" }
]
}
```
#### 空数据 / 降级响应
无预支时返回空数组 `[]`,前端显示空态文案「暂无预支 · 在底部「预支」发起,记录将在此显示」(文案由前端提供,后端不返回)。
#### 错误响应
| 码 | 含义 |
|---|---|
| `589500` | 团期不存在 |
| `589507` | 缺 `group-batch:finance:view` 权限 |
```json
{ "code": 589500, "msg": "团期不存在", "data": null }
```
#### 业务边界
- 返回**团期级 + 各子订单级**全部预支,按创建时间倒序。
- **本接口不返回累计金额**,累计取接口 1 的 `advanceApproved` / `advancePending` / `advanceAvailable`。
---
### 3. 领款人候选 `GET /v3/admin/order/group-batch/:id/advance/payee-candidates`
**VO**: `List<AdvancePayeeCandidateVO>`
#### 使用场景
预支弹窗「借款对象」下拉。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | path | Long | ✓ | 正整数 | 团期 ID(订单侧),服务端内部换算成产品侧,前端不感知 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | Long | 候选 ID,即创建预支的 `payeeStaffId`(资源域 `staff.staff_id`) |
| `staffName` | String | 姓名 |
| `staffRole` | String | 角色代码 |
| `staffRoleText` | String | 角色中文 |
| `reporterRank` | String | `PRIMARY` / `SECONDARY` / `NONE` |
| `isDefault` | Boolean | 主报账人为 `true`,前端默认选中 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/advance/payee-candidates
```
#### 响应示例
```json
{
"code": 0, "msg": "success",
"data": [
{ "id": 88101, "staffName": "张领队", "staffRole": "GUIDE", "staffRoleText": "导游",
"reporterRank": "PRIMARY", "isDefault": true },
{ "id": 88102, "staffName": "朝鲁门", "staffRole": "DRIVER", "staffRoleText": "司机",
"reporterRank": "NONE", "isDefault": false }
]
}
```
#### 空数据 / 降级响应
团期未配人员时返回空数组 `[]`;此时预支无法提交(借款对象必填)。
#### 错误响应
| 码 | 含义 |
|---|---|
| `589500` | 团期不存在 |
| `589507` | 缺 `group-batch:finance:view` 权限 |
```json
{ "code": 589500, "msg": "团期不存在", "data": null }
```
#### 业务边界
- 候选 = 本团**全部**人员(与订单级「可选本单任一人员」口径一致),排序:主报账人 → 次报账人 → 其余。
- **不返回手机号**,与订单级候选保持一致。
---
### 4. 发起团期预支 `POST /v3/admin/order/group-batch/:id/advance`
**VO**: `OrderAdvanceRespVO`
#### 使用场景
财务 Tab 底部操作条「预支」→ 弹窗填写 → 「申请预支」。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | path | Long | ✓ | 正整数 | 团期 ID |
| `payeeStaffId` | body | Long | ✓ | 须属本团人员 | 借款对象,取候选下拉项的 `id` |
| `advanceType` | body | String | ✓ | 字典 `advance_type` 的 `dictValue` | 借款类型 |
| `amount` | body | BigDecimal | ✓ | > 0 且 ≤ `advanceAvailable` | 预支金额 |
| `purpose` | body | String | — | ≤ 255 | 用途说明 |
| `voucherUrl` | body | String | — | ≤ 512 | 凭证 URL |
> ⚠️ 旧入参 `{amount, remark}` **已废除**。`remark` 无对应字段,改用 `purpose`。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | String | 新建的预支单 ID(旧实现返回 `Void`,**现在回传**) |
| `payeeStaffId` | String | 领款人主键快照(团期级存资源域 `staff.staff_id`) |
| `payeeName` / `payeeRole` / `payeeRoleText` | String | 领款人姓名 / 角色码 / 角色中文 |
| `advanceType` | String | 借款类型 |
| `amount` | BigDecimal | 预支金额 |
| `purpose` / `voucherUrl` | String | 用途说明 / 凭证 URL |
| `status` / `statusText` | String | 创建后恒为 `SUBMITTED` / 「待审批」 |
| `createdByName` | String | 申请人姓名 |
| `createTime` / `submittedAt` | String | 创建 / 提交时间 |
#### 请求示例
```http
POST /v3/admin/order/group-batch/90211/advance
{
"payeeStaffId": 88102,
"advanceType": "住宿押金",
"amount": "3000.00",
"purpose": "沿途住宿押金",
"voucherUrl": null
}
```
#### 响应示例
```json
{
"code": 0, "msg": "success",
"data": {
"id": "31006", "payeeStaffId": "88102", "payeeName": "朝鲁门",
"payeeRole": "DRIVER", "payeeRoleText": "司机",
"advanceType": "住宿押金", "amount": 3000.00, "purpose": "沿途住宿押金",
"status": "SUBMITTED", "statusText": "待审批",
"createdByName": "李雯", "submittedAt": "2026-09-06 15:20:11"
}
}
```
#### 空数据 / 降级响应
数据字典服务不可用时,借款类型校验降级为服务端内置集合,**不影响正常类型提交**。
#### 错误响应
| 码 | 含义 |
|---|---|
| `589500` | 团期不存在 |
| `589507` | 缺 `group-batch:finance:advance` 权限 |
| `589538` | 团期状态不可发起预支(须为物料准备中 / 待出发 / 出行中) |
| `589539` | 房 / 车 / 导 / 摄四项未配齐 |
| `585003` | 预支金额必须大于 0 |
| `585004` | 预支金额超过可用余额上限 |
| `585006` | 借款类型非法 |
| `585007` | 借款对象不属于本团期人员 |
```json
{ "code": 585004, "msg": "预支金额超过可用余额上限", "data": null }
```
#### 业务边界
- **双前置闸门**:团期状态 + 四项资源全就绪,缺一即拒(此前服务端两道都没有,只有前端做了置灰)。
- **上限走团期统一池**:团期级与各子订单级**共扣一池**。团期把整团尾款预支满后,该团任一子订单再发起订单级预支同样会被 `585004` 拒——这是本次修复的超支漏洞。
- 创建后进入**站内财务审批**,走既有 `advance-approvals` 列表与 approve / reject / 撤回三端点,与订单级完全一致。
- 一期仍**只记账不出款**,实际出款走财务既有付款流程。
- 同一团期 + 同一金额 5 秒内重复提交只成功一次(幂等)。
---
### 5. 预支审批列表 `GET /v3/admin/order/advance-approvals/page`
**VO**: `PageResult<AdvanceApprovalPageItemRespVO>`
#### 使用场景
财务管理 → 预支审批。团期级预支与订单级预支**混排在同一列表、走同一审批流程**。
#### 入参
新增一个可选 query 参数,其余入参不变:
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `scope` | query | String | — | `ALL` / `ORDER` / `GROUP_BATCH` | 归属维度筛选,缺省 `ALL`(两类混排,与改造前行为一致) |
`keyword` 语义扩展:由「订单号 / 团号 / 产品名」三路扩为**六路**,另加「团期号 / 团期名 / 团期产品名」。改造前搜索框标着「团号 / 产品」却搜不出团期级预支。
#### 出参
每行新增四个字段,其余不变:
| 字段 | 类型 | 说明 |
|---|---|---|
| `scope` | String | `ORDER` / `GROUP_BATCH` |
| `scopeName` | String | 归属维度中文 |
| `batchStatus` | String | 团期状态编码,仅 `scope=GROUP_BATCH` 时非空 |
| `batchStatusName` | String | 团期状态中文 |
**既有字段按 `scope` 择一填充,前端零改动即可显示**:
| 字段 | `scope=ORDER` | `scope=GROUP_BATCH` |
|---|---|---|
| `teamNo`(团号列) | 订单团号 | **团期号** |
| `productName` | 订单产品名 | 团期产品名 |
| `departDate` / `returnDate`(行程列) | 订单出行日期 | 团期出团 / 返程日 |
| `orderAmount` / `paidAmount` | 本单应收 / 已收 | **整团应收 / 已收** |
| `orderNo` / `consultantName` | 有值 | `null`(前端显「—」) |
| `orderStatus` / `flowStatus` / `payStatus` / `settlementStatus` | 有值 | `null`,改看 `batchStatus` |
#### 请求示例
```http
GET /v3/admin/order/advance-approvals/page?status=SUBMITTED&scope=GROUP_BATCH&page=1&pageSize=20
```
#### 响应示例
```json
{
"code": 0, "msg": "success",
"data": {
"records": [
{ "id": "31006", "scope": "GROUP_BATCH", "scopeName": "团期预支",
"orderNo": null, "teamNo": "GT-26-05", "productName": "呼伦贝尔草原 5 日游",
"departDate": "2026-09-20", "returnDate": "2026-09-24", "consultantName": null,
"orderStatus": null, "batchStatus": "PENDING_DEPARTURE", "batchStatusName": "待出发",
"orderAmount": 40200.00, "paidAmount": 32900.00,
"payeeName": "朝鲁门", "advanceType": "住宿押金", "amount": 3000.00,
"status": "SUBMITTED", "statusText": "待审批" }
],
"total": 1, "page": 1, "pageSize": 20
}
}
```
#### 空数据 / 降级响应
无匹配记录时 `records` 为空数组,`total` 为 `0`。
#### 错误响应
沿用改造前,本次未新增错误码。
```json
{ "code": 589507, "msg": "无操作权限", "data": null }
```
#### 业务边界
- **前端必须容忍 `orderId` / `orderNo` 为 `null`**(团期级行不挂订单)。
- 审批通过 / 驳回 / 撤回三个端点**零改动**,对团期级行行为与订单级完全一致。
- 团期级预支走**站内财务审批(资金审批)**,与流团 / 退单户的企微 OA **业务审批**是两条线,不合并。
---
### 6. 整团核算汇总 `GET /v3/admin/order/group-batch/:id/settlement/summary`
**VO**: `GroupBatchSettlementSummaryRespVO`
#### 使用场景
整团核算页。本次新增两个只读字段,用于**核单时把整团预支从主报账人代收的尾款中扣回**。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | path | Long | ✓ | 正整数 | 团期 ID。入参未变 |
#### 出参
新增两个字段,其余不变:
| 字段 | 类型 | 说明 |
|---|---|---|
| `groupAdvanceApproved` | BigDecimal | 整团已拨付预支(**只含团期级**),核算时单独成一行扣回 |
| `groupAdvancePending` | BigDecimal | 整团待审批预支,占额度未出账,仅提示 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/90211/settlement/summary
```
#### 响应示例
```json
{
"code": 0, "msg": "success",
"data": {
"settledOrderCount": 3, "totalActiveOrderCount": 3,
"subOrderTotalActualCost": 21000.00, "sharedCostTotal": 6000.00,
"grandTotalCost": 27000.00,
"groupAdvanceApproved": 5000.00,
"groupAdvancePending": 0.00
}
}
```
#### 空数据 / 降级响应
无团期级预支时两个字段均为 `0.00`(不是 null)。
#### 错误响应
沿用改造前。
```json
{ "code": 589500, "msg": "团期不存在", "data": null }
```
#### 业务边界
- 🚫 **只含团期级预支,不含子订单级**。子订单级预支已进各户的核单报销单与对账,整团层再算一次会**同一笔钱扣两遍**。
- 🚫 **不计入 `grandTotalCost`**。预支是**资金拨付**不是成本,计入会与核销后的真实成本科目重复计成本。前端渲染为「整团成本 X / 其中已拨付预支 Y」的独立一行,**不参与任何成本或毛利公式**。
- 逐户报销单与逐户对账**一个字未改**。
---
## 四、契约约束与正确调用方式
1. **财务 Tab 一次请求拿全**:接口 1 已含四张卡、逐户表、整团合计、退团户数、报账人,不要再拼 `/orders`。
2. **金额一律用服务端给的值**。整团合计由服务端算并与四张卡同源;前端自行 sum 会与卡片对不上。
3. **预支上限读 `advanceAvailable`**,不要用「待收尾款 − 记录之和」本地推算。
4. **状态胶囊读 `settleStatus`**,不要用 `payStatus` 推——后者只有三值,表达的是支付事件不是结清与否,已退团户用它渲染会显示成「已付定金」。
5. **设置报账人用产品侧 `productBatchId`**(团期详情接口取),不是财务 Tab 路径上的 `groupBatchId`。
---
## 五、数据库行为
> 只写外部可观察行为。
| 动作 | 可观察结果 |
|---|---|
| 发起团期预支 | 新增一条待审批预支记录,立即出现在本团期预支记录列表与预支审批列表;团期与订单的任何金额字段**均不变动**(预支不改应收/已收/待收) |
| 审批通过 | 该记录转为「已通过」,财务 Tab 的「已预支」增加、「待审批预支」减少、「可支取余额」不变(原本就已占额度) |
| 审批驳回 / 撤回 | 该记录转为「已驳回」或从列表消失,占用的额度**当场释放**,「可支取余额」回升 |
| 上线迁移(一次性) | 存量团期原有的累计预支金额,会转成一条「已通过」的**期初结转**记录出现在预支记录列表,金额与迁移前的累计值相等;该记录无领款人与凭证,按借款类型「期初结转」可识别 |
**幂等**:同一团期 + 同一金额 5 秒内重复提交只成功一次。
**并发**:同一团期的预支申请串行处理,两个管理员同时提交不会双双突破可支取余额。
---
## 六、边界行为
| 场景 | 行为 |
|---|---|
| 团期无活跃子订单 | 六个金额字段 `0.00`,`items` 空数组 |
| 子订单已取消 | 不进金额、不进 `items`,只计入 `withdrawnCount` |
| 未设置报账人 | `primaryPayeeName` 为 `null`,后端不兜底默认导游 |
| 团期状态为招募中 / 核单中 | 发起预支返回 `589538` |
| 四项资源缺任一 | 发起预支返回 `589539` |
| 团期尾款池已被预支占满 | 团期级与该团任一子订单级预支**均**返回 `585004` |
| 数据字典服务不可用 | 借款类型降级到内置集合校验,正常类型仍可提交 |
| 审批列表出现团期级行 | `orderId` / `orderNo` / `consultantName` / 订单四态均为 `null` |
---
## 七、不影响范围
- **订单级预支全链路行为零变化**:创建 / 审批 / 驳回 / 撤回 / 本单列表五个端点与改造前逐字一致(底表 `order_id` 由非空放宽为可空,但既有查询全是等值匹配,空值行天然不命中)。
- **逐户核单报销单与逐户对账未改**:仍只含本户预支。
- **审批通过 / 驳回 / 撤回三端点未改**。
- **设置报账人端点未改**。
- 前端页面、打印导出、发票区、预支实际出款均不在本次范围。
---
## 八、测试环境已验证
> ⏳ **尚未验证**。本文档随 PR #7170 提交,待合并并部署测试服后过网关实测,届时更新
> `backend_status` / `gateway_status` / `verified_at` 并补本节实测结果。
计划实测要点:
1. 财务 Tab 四张卡与逐户表末行合计一致,且与团期详情、看板列表三处应收同源。
2. **超支被堵死**:团期级把整团尾款预支满 → 该团任一子订单再发起订单级预支被 `585004` 拒。
3. **核单零重复扣**:团期级预支 5,000 通过 + 某户订单级预支 2,000 通过 → 该户报销单含 2,000,整团 `groupAdvanceApproved` 仍是 5,000;`grandTotalCost` 不变。
4. 两道闸门各拒一次(`589538` / `589539`)。
5. 团期级预支出现在预支审批列表,「团号 / 产品」「行程」两列有值,按团期号搜得到,就地通过 / 驳回 / 撤回正常。
6. 订单级预支五端点回归无变化。
---
## 十、相关文档
- 团期模块接口文档 v2.0 §0B(预支权威契约)、§0A.3(金额口径)、卡片 GB-ADM-040 / 041 / 042 / 043
- 团期模块数据模型 §A.11(预支复用订单预支)、§A.11.10(团期层单独对账)
- 团期模块表结构 v1.2 §4-5(`order_advance` 改造 DDL)
**待回写正式稿**(本次实现与文档不一致处):
| 处 | 文档现状 | 实际 |
|---|---|---|
| §0B.9 错误码 | 「统一用 `AdvanceErrorCode`(585 段)」 | 改落 `589538` / `589539`;585 段被 v2/v3 整段重叠声明且 585001-585010 已被 order-v2 实占 |
| GB-ADM-040 出参 | `payStatus` 写五值含 `REFUNDING` / `REFUNDED` | 代码只有三值;结清状态另出 `settleStatus` 字段 |
| GB-ADM-040 出参 | 含 `advanceTotal` | 已废,改三个数 |
| GB-ADM-042 | 返回 `GroupBatchWriteResultVO`、不回传 `advanceId` | 改返 `OrderAdvanceRespVO` 并回传 `advanceId` |
| §0A.3 | 「金额缺口是本期 P0,详情恒返回 0.00」 | 已被 #6905 / #6902 实时聚合关闭 |
| §0B.6 | 「查询键列名骗人,必须进 CR checklist」 | `V20260904_001` 改名后该坑消失 |
---
## 关联 / 联系人
- **Issue**: #7154
- **PR**: #7170(base `dev-v3`)
- **后端**: jw
- **前端**: 待认领(三个新端点 + 预支弹窗入参更换 + 审批列表 `scope` 标签)
@@ -0,0 +1,424 @@
---
schema: "hl-changelog/v2"
ticket: "7158"
title: "团期手动成团门槛与提前成团留痕"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "2952ab90"
target_release: ""
verified_at: "2026-09-06"
status_note: "后端已部署 TEST 并实测:成团带理由成功、重复成团 589537、不传 body 兼容路径均通过。前端待接:成团弹窗三格读 minToForm、产品排期表单加最低成团户数输入框。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 团期手动成团:成团门槛 min_to_form 与提前成团留痕
> **影响范围**:管理后台「团期详情 → 整团总览 → 成团」弹窗,以及产品「Step4 班期」维护表单。
> 当前状态:后端已部署 TEST 并实测;前端待接入。
## ⚠️ 关键变化
1. **成团接口新增可选请求体**。`POST .../group` 原来无请求体,现在可传 `formedNote`(提前成团理由)。
**不传 body 时行为与之前完全一致**,前端不改也不会坏。
2. **成团新增权限校验** `group-batch:manage`。此前该接口无任何权限校验。
3. **重复成团改为语义化错误码 589537**,此前返回笼统的 589501「团期状态不允许当前操作」。
4. **新增成团门槛字段 `minToForm`**(最低成团**户数**,户 = 订单 = 房)。
团期详情与产品班期两侧都新增该字段,供成团弹窗三格渲染。
## 一、背景
招募中的团期需管理员手动点「成团」才进入成团状态,进入后才能开始需求审核 / 配置资源。
成团弹窗顶部要显示三格「已售 5/9 户 · 成团标准 满 6 户 · 距标准差 1 户」,
但此前**成团标准没有任何数据源**:团期侧的 `minGroupPeople` 是「人数」口径且全局无写入方恒为 null,
产品侧只有按人数的最低成团人数。本次新增按**户数**的 `minToForm` 补齐这条链路。
**门槛只提示不拦截**:未达标也可提前成团,这是业务要求。系统在任何情况下都不自动成团。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
| --- | --- | --- | --- | --- | --- |
| 1 | 团期手动成团 | POST | `/v3/admin/order/group-batch/{groupBatchId}/group` | 修改接口 | 新增可选请求体 formedNote;新增权限校验;重复成团改 589537 |
| 2 | 团期详情 | GET | `/v3/admin/order/group-batch/{groupBatchId}` | 修改接口 | 出参新增 minToForm(成团门槛户数) |
| 3 | 产品班期修改 | PUT | `/admin/product/item/{id}/schedule` | 修改接口 | 入参新增 minToForm(最低成团户数) |
| 4 | 产品班期列表 | GET | `/admin/product/item/{id}/schedule/list` | 修改接口 | 出参新增 minToForm |
## 三、接口详情
### 1. 团期手动成团 `POST /v3/admin/order/group-batch/{groupBatchId}/group`
**VO**: `GroupBatchFormReqVO`
#### 使用场景
管理员在「团期详情 → 整团总览」底部操作条点「成团」,弹窗确认后调用。
仅招募中(`RECRUITING`)的团期可成团;成团后团期进入资源准备中,才能开始需求审核与配置资源。
已售户数未达成团门槛时也可以成团(提前成团),此时建议在弹窗里填写理由。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| groupBatchId | path | Long | 是 | 正整数 | 团期 ID |
| formedNote | body | String | 否 | 最长 256 字 | 提前成团理由。整个 body 可以不传;不传等同于不带理由,行为与本次改动前一致 |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| code | Integer | 200 表示成团成功 |
| message | String | 提示文案 |
| data | Object | 固定为 null,本接口无返回体 |
| success | Boolean | true 表示成功 |
#### 请求示例
```json
{
"formedNote": "客户催促,线下已谈妥另外 1 户"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": null,
"traceId": null,
"success": true
}
```
#### 空数据 / 降级响应
本接口成功时 `data` 恒为 null,不存在空数据形态。
成团流水文案里的「已售 N/M 户」依赖实时聚合,聚合失败时降级为不带户数的文案,
**不影响成团本身成功**,前端无需为此做特殊处理。
#### 错误响应
```json
{
"code": 589537,
"message": "团期已成团,不可重复成团",
"data": null,
"traceId": null,
"success": false
}
```
#### 业务边界
- 仅 `RECRUITING` 状态可成团;已成团(含之后各状态)返回 589537,已流团返回 589501。
- 重复成团**零副作用**:不写状态、不写流水、不触发任何下游动作。
- 未达成团门槛**不拦截**,照常成团,仅在成团流水里标注未达标与理由。
- 需要 `group-batch:manage` 权限,无权限返回 589507。
- 系统在任何情况下都不自动成团;达标只代表「允许成团」。
### 2. 团期详情 `GET /v3/admin/order/group-batch/{groupBatchId}`
**VO**: `GroupBatchDetailRespVO`
#### 使用场景
打开团期详情页时调用。本次新增的 `minToForm` 与既有 `enrolledRooms` / `maxRooms`
一起,足以渲染成团弹窗顶部三格,前端不需要再调其他接口。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| groupBatchId | path | Long | 是 | 正整数 | 团期 ID |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| minToForm | Integer | **本次新增**。最低成团户数,户 = 订单 = 房。null 或 0 表示未设门槛 |
| enrolledRooms | Integer | 已售户数(成团弹窗第一格分子) |
| maxRooms | Integer | 满团户数(第一格分母),0 表示不限 |
| remainRooms | Integer | 剩余户数,不限时为 null |
| minGroupPeople | Integer | 历史字段,最低成团**人数**,全局无写入方恒为 null。成团门槛请改用 minToForm |
| batchStatus | String | 团期状态码 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/2096412454643802114
Authorization: Bearer <token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"groupBatchId": "2096412454643802114",
"batchStatus": "RECRUITING",
"enrolledRooms": 1,
"maxRooms": 8,
"remainRooms": 7,
"minToForm": 6,
"minGroupPeople": null
},
"success": true
}
```
#### 空数据 / 降级响应
`minToForm` 为 null 或 0 表示该团期未设成团门槛。
此时前端**只显示第一格「已售 X/Y 户」**,不显示「成团标准」与「距标准差」两格。
本次上线前建出的存量团期一律是这种情况,不做数据回填。
#### 错误响应
```json
{
"code": 589500,
"message": "团期不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 三格渲染口径:已售 `enrolledRooms`/`maxRooms` 户;成团标准 满 `minToForm` 户;距标准差 `max(0, minToForm - enrolledRooms)` 户。
- `minToForm` 是**建团时从产品班期快照**下来的,之后改产品班期不会回改已建出的团期。
- `minGroupPeople` 与 `minToForm` 是两个不同口径的字段,并存且互不换算,不要混用。
### 3. 产品班期修改 `PUT /admin/product/item/{id}/schedule`
**VO**: `ScheduleSaveReqVO`
#### 使用场景
产品维护 Step4「班期」表单保存时调用。本次新增 `minToForm`,
即该班期的最低成团户数,与既有 `maxRooms`(满团户数)成对维护。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| id | path | Long | 是 | 正整数 | 产品 ID |
| batchId | body | Long | 是 | 修改时必传 | 班期 ID |
| minToForm | body | Integer | 否 | 非负整数 | **本次新增**。最低成团户数,空或 0 表示不限 |
| maxRooms | body | Integer | 否 | 非负整数 | 满团户数,0 表示不限 |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| data | Long | 班期 ID |
| success | Boolean | 是否成功 |
#### 请求示例
```json
{
"batchId": 2052935476557328386,
"departureDate": "2026-10-01",
"adultPrice": 2925.00,
"childPrice": 2425.00,
"singleRoomDiff": 500.00,
"maxRooms": 8,
"minToForm": 6
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": 2052935476557328386,
"success": true
}
```
#### 空数据 / 降级响应
`minToForm` 不传时服务端兜底为 0(不限),与 `maxRooms` / `maxParticipants` 的兜底口径一致。
存量班期在本次 DDL 后取默认值 0,不做回填。
#### 错误响应
```json
{
"code": 403,
"message": "无操作权限",
"data": null,
"success": false
}
```
#### 业务边界
- `minToForm` **只服务团期运营台的成团弹窗与成团留痕**,不参与任何下单与库存判定。
- 库存与满团判定仍然只看 `maxRooms` / `maxParticipants`,本次不受影响。
- 与既有的按人数的最低成团人数字段并存、互不覆盖、互不换算。
- 改这里**不会**回改已经建出的团期,团期侧是建团时的快照。
### 4. 产品班期列表 `GET /admin/product/item/{id}/schedule/list`
**VO**: `ScheduleRespVO`
#### 使用场景
产品维护 Step4「班期」列表回显,以及班期编辑弹窗打开时取当前值。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| id | path | Long | 是 | 正整数 | 产品 ID |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| minToForm | Integer | **本次新增**。最低成团户数,0 表示不限 |
| maxRooms | Integer | 满团户数,0 表示不限 |
| batchId | Long | 班期 ID |
| departureDate | String | 出发日期 |
#### 请求示例
```http
GET /admin/product/item/2044306857534636034/schedule/list
Authorization: Bearer <token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": [
{
"batchId": 2052935476557328386,
"batchNo": "Q202610012052935476548939777",
"departureDate": "2026-10-01",
"maxRooms": 8,
"minToForm": 0
}
],
"success": true
}
```
#### 空数据 / 降级响应
产品下无班期时 `data` 为空数组。
存量班期的 `minToForm` 一律回显 0,表示尚未设置成团门槛,属正常值而非异常。
#### 错误响应
```json
{
"code": 404,
"message": "产品不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 列表回显的 `minToForm` 是产品侧权威值;团期侧显示的是建团时的快照,两者可能不同,属预期。
- 该字段不影响列表排序与筛选。
## 四、契约约束与正确调用方式
- **成团请求体整体可选**。前端可以继续用无 body 的旧调用,也可以传 `{"formedNote": "..."}`。
建议:已售户数未达 `minToForm` 时在弹窗里出现理由输入框并把值传上来。
- **理由本期不强制必填**。服务端不会因为没填理由而拒绝成团,前端可自行决定是否做前端必填。
- **成团弹窗三格全部来自团期详情接口**,不需要额外接口。`minToForm` 为 null 或 0 时只显示第一格。
- **同一份 `ScheduleSaveReqVO` 也被新建班期 `POST /admin/product/item/{id}/schedule` 使用**,
该接口同样接受 `minToForm`,语义与修改班期完全一致;批量创建
`POST /admin/product/item/{id}/schedule/batch-create` 使用 `ScheduleBatchCreateReqVO`,
同样新增了 `minToForm` 并逐个透传给每个新建班期。这三个入口共用一套口径,前端按同一字段接入即可。
- **权限**:成团需要 `group-batch:manage`。该权限已随本次发布授予 `ADMIN` 与 `SUPER_ADMIN`。
若线上还有其他角色需要点成团,需要单独补授,否则会被拦为 589507。
## 五、数据库行为
- 成团成功时推进团期状态,并写入一条**成团操作流水**,内容形如
「手动成团 · 已售 1/8 户 · 未达标(满6户) · 理由:客户催促」,含操作人与时间。
未设门槛时省略达标判定段,未填理由时省略理由段。
- 重复成团、无权限、团期不存在三种情况**均不产生任何写入**。
- 成团门槛在产品侧维护、在建团时快照到团期侧,之后两侧独立,不做双向同步。
- 存量数据不回填:本次上线前已存在的班期与团期,成团门槛一律为默认值(0 或空)。
## 六、边界行为
- 未达成团门槛照常可以成团,门槛只影响提示与流水文案。
- 系统在任何情况下都不自动成团,达标只代表「允许成团」。
- 成团后的既有下游行为不变:派生导游 / 摄影需求标志、免闸置位、尝试推进物料准备、驱动子订单进需求态并派发配房配车待办。
- 成团流水文案里的已售户数与团期详情同源(实时聚合),不读持久计数列,两处显示不会打架。
## 六.6、修改前后对比
| 项 | 修改前 | 修改后 |
| --- | --- | --- |
| 成团请求体 | 无请求体 | 可选 body,含 `formedNote`;不传时行为不变 |
| 成团权限 | **无任何权限校验** | 需要 `group-batch:manage`,无权限 589507 |
| 重复成团 | 589501「团期状态不允许当前操作」 | 589537「团期已成团,不可重复成团」 |
| 成团流水 | 固定文案「成团」 | 带已售 / 满团 / 达标情况 / 提前成团理由 |
| 团期详情成团门槛 | 只有恒为 null 的 `minGroupPeople`(人数口径) | 新增 `minToForm`(户数口径),弹窗三格可渲染 |
| 产品班期成团门槛 | 只有按人数的最低成团人数 | 新增按户数的 `minToForm`,可在班期表单维护 |
## 六.7、影响评估
- **对现有前端调用零破坏**:成团请求体可选,不传 body 的老调用行为与改动前完全一致,已在 TEST 实测确认。
- **权限是行为变更**:`group-batch:manage` 已随发布授予 `ADMIN` / `SUPER_ADMIN`;
若还有其他角色在点成团,上线后会被拦住,需要补授权限。这是本次唯一需要运维配合的点。
- **错误码语义变更**:重复成团由 589501 变为 589537。若前端对 589501 做过特殊处理,需要同步识别 589537。
- **新增字段均为增量**,不改动任何既有字段的类型与含义;`minGroupPeople` 保持原样不动。
- 团期成团的下游链路(子订单推进、待办派发)本次未做任何改动。
## 七、不影响范围
- 取消成团、流团两个接口本次未改动,仍然没有权限校验,将另行统一处理。
- 下单、库存扣减、满团判定完全不受影响,仍只看 `maxRooms` / `maxParticipants`。
- 小程序端不受影响,本次改动全部在管理后台侧。
- 成团通知(公众号 / 短信)本期不做,弹窗上的通知勾选框暂无后端对应入参,成团后仍需人工通知客户。
## 八、测试环境已验证
TEST 环境已部署 product-v2、user-service、order-v3 三个服务并实测通过:
- 团期详情返回 `minToForm` / `maxRooms` / `enrolledRooms` / `remainRooms`,`minGroupPeople` 保持并存。
- 带 `formedNote` 成团成功,团期由招募中进入资源准备中。
- 重复成团返回 589537「团期已成团,不可重复成团」。
- 不传请求体再次调用同样走到业务校验而非参数错误,确认请求体确实可选。
- 产品班期列表返回 `minToForm`,存量班期回显 0。
未在测试环境覆盖的三项:成团流水文案(团期流水目前无对外查询接口,由单元测试保证)、
产品班期写入 `minToForm`(受产品模块数据权限限制未能实调)、
建团快照(需在设置门槛后新建团期订单才能观察)。
## 十、相关文档
- 工单:HL#7158
- 合并:HL PR#7168(已合入 dev-v3)
- 前置工单:HL#7104(成团驱动子订单流程推进),本次改成团入口,与其下游改动互不冲突
## 关联 / 联系人
- 后端:jw
- 前端待接两项:成团弹窗三格读取 `minToForm` 并在未达标时出现提前成团理由输入框;
产品 Step4 班期表单新增「最低成团户数」输入框。
@@ -0,0 +1,229 @@
---
schema: "hl-changelog/v2"
ticket: "7164"
title: "往来期初建账(客户应收 / 供应商应付 / 员工往来 三本账)"
consumer: "admin"
author: "yst"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "60ca7002"
target_release: ""
verified_at: "2026-09-10"
status_note: "verified 2026-09-10 mmg: 推翻 fin-init 预埋替换为真实契约三端点。opening.js 重写(getOpeningBalancePage/createOpeningBalance/adjustOpeningBalance)+纯函数;fin-init/index.vue 整页重写 segment 三账套页签(切签注入必填 ledgerType),INIT 新建(refId 字符串透传/应收应付至少填一项/FileUpload 佐证必填),ADJUST 调整仅 INIT 行触发存全量值非差额(evidenceUrl+reason 必填);移除现金银行预埋。opening-balance.spec 锁端点/至少填一项/调整全量。checkpoint 全绿含 Vitest 全量+生产构建。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 往来期初建账
## 1. 接口背景
财务域「期初建账」:系统启用财务模块时,对客户应收、供应商应付、员工往来三本账做期初余额的一次性录入与后续调整。期初金额必须附佐证材料(影像 URL),期初基准日由后端取当前未封账账期的起始日,前端无需传。
- 路径前缀:`/admin/finance/opening-balances`(3 个新端点)
- 服务:hl-finance(财务服务)
## 2. 变更清单
| 类型 | 接口 | 说明 |
|---|---|---|
| 新增 | GET `/admin/finance/opening-balances/page` | 往来期初分页(按账套过滤,支持类别/往来对象名筛选) |
| 新增 | POST `/admin/finance/opening-balances` | 往来期初一次录入(同往来对象仅可录入一次初始期初) |
| 新增 | POST `/admin/finance/opening-balances/adjust` | 往来期初调整单(须已录入初始期初;存调整全量值非差额) |
## 3. 接口详情
### 3.1 期初分页 GET /page
- 使用场景:管理后台「期初建账」三个页签(客户应收 / 供应商应付 / 员工往来)的列表查询,页签切换 = 换 `ledgerType`。
- 认证:管理后台管理员 Token(`Authorization: Bearer <admin-token>`),未登录返 401。
- 幂等性:查询接口,天然幂等。
- 限流:网关默认限流,无特殊配置。
### 3.2 期初一次录入 POST /
- 使用场景:首次为某往来对象建立期初余额(INIT 行)。同一 `(ledgerType, refId)` 仅允许一条 INIT,重复录入报 598401。
- 认证:同上。幂等性:非幂等(重复提交报 598401 不会产生重复 INIT 行,前端提交后应禁用按钮)。
- 限流:网关默认。
### 3.3 期初调整单 POST /adjust
- 使用场景:已录入 INIT 后修正期初金额。**存的是调整后全量值(非差额)**,每次调整落一行 `kind=ADJUST`,当前净额 = INIT + ΣADJUST 由后端现算。
- 认证:同上。幂等性:非幂等(每次提交落一行调整记录)。
- 限流:网关默认。
## 4. 接口入参
### 4.1 分页 Query(OpeningBalancePageReqVO)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| `ledgerType` | String | 是 | `CUSTOMER`/`SUPPLIER`/`STAFF` | 账套:CUSTOMER 客户应收 / SUPPLIER 供应商应付 / STAFF 员工往来 |
| `kind` | String | 否 | `INIT`/`ADJUST` | 类别:INIT 初始 / ADJUST 期初调整;空=全部 |
| `refName` | String | 否 | 模糊匹配 | 往来对象名筛选;空=不限 |
| `page` | Number | 否 | ≥1,默认 1 | 页码 |
| `pageSize` | Number | 否 | 默认 20 | 每页条数 |
### 4.2 一次录入 Body(OpeningBalanceCreateReqVO)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| `ledgerType` | String | 是 | 同上枚举 | 账套 |
| `refId` | Number | 是 | — | 往来对象 ID(客户/供应商/员工主键,按账套对应) |
| `refName` | String | 是 | ≤128 字 | 往来对象名称(冗余快照,落库后不随主数据改名) |
| `openingPayable` | Number | 条件 | >0 | 期初应付(我欠他);与 openingReceivable 至少填一项 |
| `openingReceivable` | Number | 条件 | >0 | 期初应收(他欠我们);与 openingPayable 至少填一项 |
| `evidenceUrl` | String | 是 | — | 佐证材料影像 URL(必传,缺失抛 598402) |
| `remark` | String | 否 | ≤200 字 | 备注 |
### 4.3 调整单 Body(OpeningBalanceAdjustReqVO)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| `ledgerType` | String | 是 | 同上枚举 | 账套 |
| `refId` | Number | 是 | — | 往来对象 ID(须已存在 INIT 行,否则 598404) |
| `openingPayable` | Number | 否 | >0 | 调整后**全量**期初应付(不调整传 null) |
| `openingReceivable` | Number | 否 | >0 | 调整后**全量**期初应收(不调整传 null) |
| `evidenceUrl` | String | 是 | — | 佐证材料影像 URL(必传,缺失抛 598402) |
| `reason` | String | 是 | ≤200 字 | 调整原因(必填留痕) |
## 5. 出参字段
### 5.1 分页行(OpeningBalanceRowRespVO)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | String | 期初行 ID(雪花 ID,Long 序列化为字符串防 JS 精度丢失) |
| `ledgerType` | String | 账套码(CUSTOMER/SUPPLIER/STAFF) |
| `refId` | String | 往来对象 ID(雪花字符串) |
| `refName` | String | 往来对象名称快照 |
| `openingDate` | String | 期初基准日(yyyy-MM-dd,= 当前未封账账期起始日,后端带入) |
| `kind` | String | 类别:INIT 初始 / ADJUST 期初调整 |
| `openingPayable` | Number | 期初应付(无 = null) |
| `openingReceivable` | Number | 期初应收(无 = null) |
| `evidenceUrl` | String | 佐证材料影像 URL |
| `recordedByName` | String | 录入人姓名快照 |
| `createTime` | String | 创建时间(yyyy-MM-dd HH:mm:ss) |
分页包裹:`data.records[]` / `data.total` / `data.page` / `data.pageSize`。
### 5.2 录入 / 调整响应(OpeningBalanceIdRespVO)
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | String | 新落库期初行 ID(雪花字符串) |
## 6. 枚举 / 数据字典
### `ledgerType`(账套,代码枚举)
| 值 | 中文 | 说明 |
|---|---|---|
| `CUSTOMER` | 客户应收 | 客户欠我们的期初 |
| `SUPPLIER` | 供应商应付 | 我们欠供应商的期初 |
| `STAFF` | 员工往来 | 员工借款/备用金期初 |
### `kind`(期初行类别,代码枚举)
| 值 | 中文 | 说明 |
|---|---|---|
| `INIT` | 初始期初 | 首次录入,同往来对象唯一 |
| `ADJUST` | 期初调整 | 调整单,存调整后全量值,可多条 |
## 7. 错误码(段位 598400-598499)
| 错误码 | 含义 | 触发场景 |
|---|---|---|
| 598401 | 该往来对象期初已录入 | 同 `(ledgerType, refId)` 重复 POST 录入 INIT |
| 598402 | 佐证材料必传 | `evidenceUrl` 为空 |
| 598403 | 账期已封账,不允许录入或调整期初 | 当前账期已封账 / 无未封账账期(视同未开账) |
| 598404 | 该往来对象尚未录入初始期初,不可调整 | POST /adjust 时无 INIT 行 |
| 598405 | 期初应付/应收须至少填一项 | 两项均空或均 ≤0 |
| 598406 | 账套类型非法,须为 SUPPLIER、CUSTOMER 或 STAFF | `ledgerType` 传其他值 |
## 8. 示例
### 8.1 典型成功(供应商应付期初录入)
```http
POST /admin/finance/opening-balances
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"ledgerType": "SUPPLIER",
"refId": 2094123456789012345,
"refName": "云山景区管理有限公司",
"openingPayable": 50000.00,
"openingReceivable": null,
"evidenceUrl": "https://oss.example.com/evidence/abc.jpg",
"remark": "2026 年 8 月对账单确认"
}
```
```json
{"code":200,"message":"成功","success":true,"data":{"id":"2094300011122233445"}}
```
分页查询:
```http
GET /admin/finance/opening-balances/page?ledgerType=SUPPLIER&page=1&pageSize=20
```
```json
{"code":200,"success":true,"data":{"records":[{"id":"2094300011122233445","ledgerType":"SUPPLIER","refId":"2094123456789012345","refName":"云山景区管理有限公司","openingDate":"2026-09-01","kind":"INIT","openingPayable":50000.00,"openingReceivable":null,"evidenceUrl":"https://oss.example.com/evidence/abc.jpg","recordedByName":"腰苏图","createTime":"2026-09-06 10:00:00"}],"total":1,"page":1,"pageSize":20}}
```
### 8.2 边界情况(期初调整为零 / 双项同调)
调整单传全量值,可把某项调成新值、另一项保持 null 不动:
```json
{
"ledgerType": "SUPPLIER",
"refId": 2094123456789012345,
"openingPayable": 48000.00,
"openingReceivable": null,
"evidenceUrl": "https://oss.example.com/evidence/def.jpg",
"reason": "对方减免 2000 元尾款"
}
```
```json
{"code":200,"success":true,"data":{"id":"2094300099988877766"}}
```
空分页:无数据时 `records=[]`、`total=0`,HTTP 200,前端正常渲染空列表。
### 8.3 业务失败(重复录入 / 未录初始就调整)
```http
POST /admin/finance/opening-balances # 同一 refId 再次录入
```
```json
{"code":598401,"message":"该往来对象期初已录入","success":false,"data":null}
```
```http
POST /admin/finance/opening-balances/adjust # 该对象从未录入 INIT
```
```json
{"code":598404,"message":"该往来对象尚未录入初始期初,不可调整","success":false,"data":null}
```
缺佐证材料:`{"code":598402,"message":"佐证材料必传"}`;两项金额均空:`{"code":598405,"message":"期初应付/应收须至少填一项"}`。
## 9. 业务边界
**适用**:
- 财务模块上线初始化时,批量为三本账建立期初基准。
- 期初金额有误时,通过调整单修正(保留全部调整留痕)。
**不适用 / 限制**:
- 同往来对象只能录入一次 INIT,后续一律走调整单。
- 账期封账后禁止录入与调整(598403);未开账(无未封账账期)同样拦截。
- 期初基准日不可指定,由后端取当前未封账账期的 `start_date`。
- 期初数据**暂未回流勾稽到往来账**(方案 A,留 TODO 接缝),本期仅作独立台账。
**特殊边界**:
- `refName` 是冗余快照,主数据改名不影响历史期初行显示。
- 调整单存**全量值**不是差额,前端表单应展示「调整后金额」而非「调增/调减」。
## 10. 注意事项
- 所有雪花 ID(`id` / `refId`)均已序列化为字符串,前端按 String 处理,勿转 Number。
- `openingDate` 由后端带入,入参里没有该字段。
- 前端三个页签(客户应收 / 供应商应付 / 员工往来)对应 `ledgerType` 三值,建议页签切换即重置筛选条件重新查询。
- 新表 `fin_opening_balance`(Flyway `V20260906_101`),部署时自动执行,无需前端动作。
## 11. 关联 / 联系人
- Issue:https://git.1814.love:8443/wx/HL/issues/7164
- PR:https://git.1814.love:8443/wx/HL/pulls/7166
- Commit:https://git.1814.love:8443/wx/HL/commit/d0f06fa2e8
- Epic:https://git.1814.love:8443/wx/HL/issues/7163
- 负责人:腰苏图(yst)
@@ -0,0 +1,274 @@
---
schema: "hl-changelog/v2"
ticket: "7165"
title: "业务外收支流水(收入 IN / 支出 OUT 同表两入口)"
consumer: "admin"
author: "yst"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "016572ee"
target_release: ""
verified_at: "2026-09-10"
status_note: "verified 2026-09-10 mmg: 收入IN/支出OUT 同表两入口共享 NonbizFlowPage(props.direction)。nonbiz-flow.js 七端点+calcFeeByRate/buildNonbizFlowPayload(编辑剥 direction/ID 字符串化/不收 actualAmount);金额直读 actualAmount 不自算,fee 三件套千分率联动;按状态机显隐(PENDING 编辑/提交/删除、SUBMITTED 批准);主会话集成补「确认到账」(IN 且 APPROVED)调 cashierConfirmIn。receipt/nonbiz-in 与 payable/nonbiz-out 两薄壳。checkpoint 全绿。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 业务外收支流水
## 1. 接口背景
财务域「业务外收支」:记录与订单业务无关的公司收支(如利息收入、罚款支出、押金退回等),收入(IN)与支出(OUT)同表同接口、靠 direction 区分,管理后台拆成「业务外收入」「业务外支出」两个菜单页签。单据落库为 PENDING 草稿,本期草稿可编辑/删除;提交与审批流转在后续 PR #7221 补齐。
- 路径前缀:/admin/finance/nonbiz-flows(5 个新端点)
- 服务:hl-finance(财务服务)
## 2. 变更清单
| 类型 | 接口 | 说明 |
|---|---|---|
| 新增 | GET /admin/finance/nonbiz-flows/page | 业务外收支分页(direction 必填区分收入/支出页签) |
| 新增 | POST /admin/finance/nonbiz-flows | 业务外收支申请(收入/支出同接口,落 PENDING 草稿) |
| 新增 | GET /admin/finance/nonbiz-flows/{id} | 收支单详情(收入/支出通用全字段) |
| 新增 | PUT /admin/finance/nonbiz-flows/{id} | 编辑收支草稿(仅 PENDING;direction 不可改) |
| 新增 | DELETE /admin/finance/nonbiz-flows/{id} | 删除收支草稿(仅 PENDING,软删) |
## 3. 接口详情
### 3.1 分页 GET /page
- 使用场景:管理后台「业务外收入」「业务外支出」两个菜单共用一个接口,菜单切换 = 换 direction(IN/OUT)。
- 认证:管理后台管理员 Token(Authorization: Bearer admin-token),未登录返 401。
- 幂等性:查询接口,天然幂等。限流:网关默认。
### 3.2 申请 POST /
- 使用场景:财务录入一笔业务外收入或支出,落 PENDING 草稿。
- 认证:同上。幂等性:非幂等(每次创建生成新单据,前端提交后应禁用按钮)。限流:网关默认。
### 3.3 详情 GET /{id}
- 使用场景:列表行点击进详情抽屉/页。
- 认证:同上。幂等:查询接口。限流:网关默认。
### 3.4 编辑 PUT /{id}
- 使用场景:修改 PENDING 草稿的类别/单位/金额/凭证等字段;方向不可改(收入单不能改成支出单)。
- 认证:同上。幂等性:同参数重复提交效果一致。限流:网关默认。
### 3.5 删除 DELETE /{id}
- 使用场景:作废 PENDING 草稿(软删,单据从所有列表消失)。
- 认证:同上。幂等性:重复删除返 598504。限流:网关默认。
## 4. 接口入参
### 4.1 分页 Query(NonbizFlowPageReqVO)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| direction | String | 是 | IN/OUT | 方向:IN 业务外收入 / OUT 业务外支出 |
| category | String | 否 | — | 收支类别码(fin_nonbiz_category,按方向分组);空=不限 |
| unitId | Number | 否 | — | 外部单位 ID;空=不限 |
| status | String | 否 | 见状态枚举 | 单据状态;空=全部 |
| occurDateStart | String | 否 | yyyy-MM-dd | 发生日期起 |
| occurDateEnd | String | 否 | yyyy-MM-dd | 发生日期止 |
| page | Number | 否 | 默认 1 | 页码 |
| pageSize | Number | 否 | 默认 20 | 每页条数 |
### 4.2 申请 Body(NonbizFlowCreateReqVO)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| direction | String | 是 | IN/OUT | 方向 |
| category | String | 是 | 不超64字 | 收支类别码,须为 fin_nonbiz_category 同方向组的正常项(598502) |
| unitId | Number | 是 | — | 外部单位 ID(本期仅非空校验,存在性校验待往来账域接缝) |
| unitName | String | 是 | 不超128字 | 外部单位名(冗余快照) |
| amount | Number | 是 | 大于0 | 收支金额(598501) |
| feeRate | Number | 否 | 千分率 | 手续费率(默认 0;收入页改动自动重算 fee) |
| fee | Number | 否 | 不小于0 | 手续费(收入缺省按费率算、可手改;支出手填;actualAmount 联动重算) |
| fundAccountId | Number | 否 | — | 入/出账公司账户 ID(空=批准时仅入账不联动资金结存) |
| occurDate | String | 是 | yyyy-MM-dd | 发生日期 |
| payMethod | String | 否 | 不超32字 | 收付方式(字典 fin_pay_way 码值) |
| deptId | Number | 否 | — | 归属部门 ID(公司主体;用户域部门树) |
| voucherNo | String | 否 | 不超64字 | 凭证号 |
| voucherUrl | String | 否 | 不超500字 | 凭证影像 URL |
| remark | String | 否 | 不超200字 | 备注 |
### 4.3 编辑 Body(NonbizFlowUpdateReqVO)
字段与申请 Body 完全一致,唯不含 direction(方向由单据自身决定,编辑不换方向),见 4.2 表。
### 4.4 路径参数
| 接口 | 字段 | 类型 | 说明 |
|---|---|---|---|
| GET/PUT/DELETE /{id} | id | Number | 收支单 ID(路径传数字即可,响应中为字符串) |
## 5. 出参字段
### 5.1 分页行(NonbizFlowRowRespVO)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | String | 收支单 ID(雪花字符串) |
| direction | String | 方向:IN / OUT |
| category | String | 收支类别码 |
| categoryName | String | 收支类别中文名(取自 fin_nonbiz_category;类别停用不影响历史单据回显) |
| unitId | String | 外部单位 ID(雪花字符串) |
| unitName | String | 外部单位名快照 |
| amount | Number | 收支金额 |
| fee | Number | 手续费(挂本单) |
| actualAmount | Number | 实收/实付 = amount − fee |
| deptId | String | 归属部门 ID(雪花字符串) |
| operatorName | String | 经办人姓名快照 |
| occurDate | String | 发生日期(yyyy-MM-dd) |
| status | String | 单据状态码 |
| payMethod | String | 收付方式码值(字典 fin_pay_way) |
| payMethodName | String | 收付方式中文名(字典标签,字典缺失时为空) |
### 5.2 详情(NonbizFlowDetailRespVO)
列表行全字段 + 以下增量字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| feeRate | Number | 手续费率(千分率) |
| fundAccountId | String | 入/出账公司账户 ID(空=批准时仅入账不联动结存) |
| operatorId | String | 经办人 ID(雪花字符串) |
| voucherNo | String | 凭证号 |
| voucherUrl | String | 凭证影像 URL |
| remark | String | 备注 |
| createTime | String | 创建时间(yyyy-MM-dd HH:mm:ss) |
### 5.3 申请响应(NonbizFlowIdRespVO)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | String | 新建收支单 ID(PENDING 草稿) |
### 5.4 编辑 / 删除响应
data 为 null(Result<Void>),code=200 即成功。
## 6. 枚举 / 数据字典
### direction(方向,代码枚举)
| 值 | 中文 |
|---|---|
| IN | 业务外收入 |
| OUT | 业务外支出 |
### status(单据状态,代码枚举)
| 值 | 中文 | 说明 |
|---|---|---|
| PENDING | 草稿 | 可编辑可删除(本期创建后唯一状态) |
| SUBMITTED | 审批中 | 提交后锁定(流转端点在 PR #7221 补齐) |
| APPROVED | 已批准 | 待出纳收付(流转端点在 PR #7221 补齐) |
| REJECTED | 已驳回 | 审批驳回 |
| PAID | 已收付讫 | 出纳执行完成,终态 |
### category(收支类别,基础数据表 fin_nonbiz_category)
- 按类别码传值;类别按方向分组(IN 组 / OUT 组),跨组传值报 598502。
- 类别维护走「业务外收支分类」管理接口(见 09_7052 changelog)。
### payMethod(收付方式,数据字典 fin_pay_way,dict_type_id=10157)
| 码值 | 中文 |
|---|---|
| CASH | 现金 |
| BANK_TRANSFER | 银行转账 |
| WECHAT | 微信 |
| ALIPAY | 支付宝 |
字典标签由后端回显为 payMethodName,前端列表/详情直接展示,无需自行映射。
## 7. 错误码(段位 598500-598599)
| 错误码 | 含义 | 触发场景 |
|---|---|---|
| 598501 | 金额无效(收支金额须大于0,且实收/实付不得为负) | amount≤0、fee<0 或 amount−fee<0 |
| 598502 | 收支类别不存在或不属于该方向组 | category 不存在 / 跨方向组 / 已停用 |
| 598503 | 当前状态不允许此操作,仅草稿可编辑或删除 | 非 PENDING 状态调 PUT/DELETE |
| 598504 | 收支单不存在 | 详情/编辑/删除传错 ID(或已软删) |
| 598505 | 状态流转非法(提交须草稿态,批准须审批中) | 状态机推进校验(流转端点见 PR #7221 changelog) |
| 598506 | 方向非法,须为 IN 或 OUT | direction 传其他值 |
## 8. 示例
### 8.1 典型成功(录一笔业务外支出)
```http
POST /admin/finance/nonbiz-flows
Authorization: Bearer <admin-token>
Content-Type: application/json
{
"direction": "OUT",
"category": "DEPOSIT_REFUND",
"unitId": 2094001122334455667,
"unitName": "市文旅局",
"amount": 2000.00,
"fee": 0,
"fundAccountId": 2097009988776655443,
"occurDate": "2026-09-05",
"payMethod": "BANK_TRANSFER",
"deptId": 10086,
"voucherNo": "PZ-20260905-01",
"voucherUrl": "https://oss.example.com/voucher/pz01.jpg",
"remark": "质保金退回"
}
```
```json
{"code":200,"message":"成功","success":true,"data":{"id":"2094311122233344455"}}
```
收入页签分页:
```http
GET /admin/finance/nonbiz-flows/page?direction=IN&page=1&pageSize=20&status=PENDING
```
```json
{"code":200,"success":true,"data":{"records":[{"id":"2094311000000000001","direction":"IN","category":"INTEREST","categoryName":"利息收入","unitId":"2094001122334455667","unitName":"某银行","amount":500.00,"fee":0,"actualAmount":500.00,"deptId":"10086","operatorName":"腰苏图","occurDate":"2026-09-01","status":"PENDING","payMethod":"BANK_TRANSFER","payMethodName":"银行转账"}],"total":1,"page":1,"pageSize":20}}
```
### 8.2 边界情况(可选项全空 / 最小金额)
```json
{
"direction": "OUT",
"category": "FINE",
"unitId": 2094001122334455667,
"unitName": "交管局",
"amount": 0.01,
"occurDate": "2026-09-06"
}
```
最小金额 0.01、可选项(feeRate/fee/fundAccountId/payMethod/deptId/voucher*/remark)全省略也可创建;actualAmount 后端按 amount − fee(默认0) 算好返回。空分页:records=[]、total=0,HTTP 200,前端正常渲染空列表。
### 8.3 业务失败(类别跨方向组 / 编辑非草稿)
direction=IN 但传了 OUT 组的类别码:
```json
{"code":598502,"message":"收支类别不存在或不属于该方向组","success":false,"data":null}
```
对非 PENDING 单据调 PUT/DELETE:
```json
{"code":598503,"message":"当前状态不允许此操作,仅草稿可编辑或删除","success":false,"data":null}
```
amount=0 时:{"code":598501,"message":"金额无效(收支金额须大于0,且实收/实付不得为负)"}。
## 9. 业务边界
适用:
- 与订单无关的公司收支记账(利息、罚款、押金、捐赠等)。
不适用 / 限制:
- 本期(PR #7179)只落 PENDING 草稿 + 草稿编辑/删除;提交/审批流转端点在 PR #7221 补齐(submit/approve),状态机走到 PAID 依赖出纳域 cashier 端点。
- 勾稽联动(批准后联动资金结存、进出纳队列、企微审批)本期未接通(方案 A,留 TODO 接缝);fundAccountId 传了也只是记账引用,不会在批准时自动动账——动账由出纳域执行。
- unitId 本期仅非空校验,不校验外部单位真实存在(待往来账域接缝,Epic #7163)。
- direction 创建后不可改;改方向 = 删草稿重新录。
特殊边界:
- fee 收入单缺省按 feeRate(千分率)自动算、可手改覆盖;支出单手填。改 amount/feeRate/fee 后端联动重算 actualAmount。
- 类别停用不删,历史单据 categoryName 正常回显。
## 10. 注意事项
- 所有雪花 ID 均为字符串,前端按 String 处理,勿转 Number。
- 两个菜单(收入/支出)共用接口,direction 必填,建议菜单切换即重置筛选重新查询。
- payMethodName / categoryName 由后端字典回显,前端不要自己维护码表。
- 新表 fin_nonbiz_flow(Flyway V20260906_102)+ 新字典 fin_pay_way(user-service V20260906_003,dict_type_id=10157),部署顺序先 user-service 后 finance,均部署自动执行。
- 提交/审批端点(PUT /{id}/submit、/{id}/approve)与出纳付款能力见「07_7217_支付管理出纳」changelog。
## 11. 关联 / 联系人
- Issue:https://git.1814.love:8443/wx/HL/issues/7165
- PR:https://git.1814.love:8443/wx/HL/pulls/7179
- Commit:https://git.1814.love:8443/wx/HL/commit/dc3b306f73
- Epic:https://git.1814.love:8443/wx/HL/issues/7163
- 负责人:腰苏图(yst)
@@ -0,0 +1,225 @@
---
schema: "hl-changelog/v2"
ticket: "7178"
title: "团期调整满团名额同步产品域库存"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "84c0cd5b"
target_release: ""
verified_at: "2026-09-06"
status_note: "修复「调了不生效」:调名额此前只写订单域,而库存权威在产品域。后端已部署 TEST 并实测:加减名额驱动产品域满团即停/放开继续招募、低于已报名拒绝且零写入、物料准备起阶段门。请求体形状已变更(净增量),前端待接。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 团期调整满团名额:同步产品域库存 + 阶段门 + 净增量入参
> **影响范围**:管理后台「团期详情 → 整团总览 → 调整满团名额」弹窗。
> 当前状态:后端已部署 TEST 并实测;前端待接入(**请求体形状已变更**)。
## ⚠️ 关键变化
1. **修复「调了不生效」**。此前调名额只写订单域,而下单侧的剩余名额由**产品域**算出,
于是加名额放不出空位、减名额停不了售,但看板数字会变。现在调整会真正驱动售卖状态。
2. **请求体不兼容变更**:由 `{maxParticipants, maxRooms}` 两个绝对值,
改为 `{capacityDelta, reason}` —— **净增量、只调户数**。
3. **新增阶段门**:仅「招募中」与「资源准备中」可调,物料准备中及之后返回 589538。
4. **新增权限校验** `group-batch:manage`(此前该接口无任何权限校验)。
5. **响应由空改为返回结果对象**,直接给出弹窗三格所需数据。
## 一、背景
调整满团名额是给运营临时增减本期可报名户数用的。此前的实现只更新了团期侧的展示值,
没有同步到真正决定「还能不能报名」的那一侧,导致这个功能实际上不起作用——
**页面上数字变了,但客户仍按老上限被卡住**。本次修复把调整落到库存权威侧,
并按新库存重算班期是否已满额。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
| --- | --- | --- | --- | --- | --- |
| 1 | 调整团期满团名额 | PUT | `/v3/admin/order/group-batch/{groupBatchId}/capacity` | 修改接口 | 入参改为净增量并只调户数;新增阶段门与权限校验;同步产品域库存;响应改为结果对象 |
## 三、接口详情
### 1. 调整团期满团名额 `PUT /v3/admin/order/group-batch/{groupBatchId}/capacity`
**VO**: `AdjustCapacityReqVO`
#### 使用场景
管理员在「团期详情 → 整团总览」底部操作条点「调整满团名额」,
弹窗用 −/+ 步进器改满团户数,点「保存名额」时把**净变化量**提交给本接口。
典型用法:某期已满员但还有客户想报,加 2 个名额把空位放出来继续招募;
或临近出团减名额提前收口。**已成团的团期加名额后仍保持成团,不会退回招募中。**
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| groupBatchId | path | Long | 是 | 正整数 | 团期 ID |
| capacityDelta | body | Integer | 是 | 不能为 0 | 满团名额净增量(户),可正可负。新满团户数 = 当前满团户数 + 该值 |
| reason | body | String | 否 | 最长 256 字 | 调整原因,填了写进团期操作记录。**不强制必填** |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| batchId | String | 团期 ID |
| beforeMaxRooms | Integer | 调整前满团户数 |
| maxRooms | Integer | 调整后满团户数,0 表示不限 |
| capacityDelta | Integer | 本次净增量 |
| enrolledRooms | Integer | 已报名户数(户 = 订单 = 房) |
| remainRooms | Integer | 调整后余量 = max(0, maxRooms − enrolledRooms);满团户数为 0(不限)时为 null |
#### 请求示例
```json
{
"capacityDelta": 3,
"reason": "客户加订,放三个空位继续招募"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"batchId": "2096510069465088002",
"beforeMaxRooms": 2,
"maxRooms": 5,
"capacityDelta": 3,
"enrolledRooms": 1,
"remainRooms": 4
},
"traceId": null,
"success": true
}
```
#### 空数据 / 降级响应
调整后满团户数为 0 时表示**不限名额**,此时 `remainRooms` 返回 null,
前端不显示余量。这是正常语义而非异常。
本接口没有空列表形态;任何失败都以错误码返回,且**失败一律零写入**——
产品侧与团期侧都不会留下半改状态。
#### 错误响应
```json
{
"code": 589538,
"message": "物料准备开始后不可再调整满团名额(仅招募中、资源准备中可调)",
"data": null,
"traceId": null,
"success": false
}
```
#### 业务边界
- **仅「招募中」与「资源准备中」可调**;物料准备中及之后返回 589538。
- **调整后不得低于已报名户数**,否则返回 589509;新值也不得为负。
- **新值为 0 表示不限名额**,此时不受已报名数约束。
- `capacityDelta` 为 0 返回 589539(等于没调)。
- 需要 `group-batch:manage` 权限,无权限返回 589507。
- **调整不改团期状态**:已成团的仍保持成团,不会退回招募中。
- 产品侧同步失败时整笔失败并返回 589540,团期侧零写入。
## 四、契约约束与正确调用方式
- **提交净增量,不要提交总数**。前端步进器算出的总数只用于展示,
提交时给差值即可。这样两人同时调也不会互相覆盖成对方的总数。
- **不再接收人数容量**。此前的 `maxParticipants` 字段已移除,人数容量由产品侧维护。
- **调整前先看能不能调**:团期进入物料准备后按钮应置灰,避免用户点了才报错。
- **弹窗三格数据**:调整前可从团期详情取「当前名额 / 已报名」;
调整成功后直接用本接口返回的 `maxRooms` / `enrolledRooms` / `remainRooms` 刷新,
不必再拉一次详情。
- **重试是安全的**。若前端超时后重试同一请求,服务端以团期侧当前值为基数重算,
不会把同一个增量叠加两次。
## 五、数据库行为
- 调整会**同时更新产品侧与团期侧的满团户数**,并按新库存重算班期是否已满额:
售罄时停止继续接单,库存恢复时重新开放报名。
- **先写产品侧,成功后才写团期侧**;产品侧写失败即整笔失败、团期侧不留任何痕迹。
- 每次成功调整写一条团期操作记录,内容形如
「满团名额:9→10(+1) · 理由:客户加订一间」,含操作人与时间;未填理由时省略后半段。
- 拒绝的三种情况(阶段不允许 / 低于已报名 / 增量为 0)**均不产生任何写入**。
- 历史上两侧数值已经不一致的团期,本次不做批量订正;下一次调整会自动把两侧拉齐。
## 六、边界行为
- 已成团的团期加名额后**仍保持成团**,只是把空位放出来继续招募,满团即停。
- 减名额减到正好等于已报名户数是允许的(余量为 0),再减一户即被拒绝。
- 满团户数为 0 表示不限,此时无论已报名多少都不触发下限校验。
- 团期未绑定产品班期时返回 589540(无处可写,不静默放过)。
## 六.6、修改前后对比
| 项 | 修改前 | 修改后 |
| --- | --- | --- |
| 是否真正影响售卖 | ❌ 只改团期侧展示值,**加名额放不出空位、减名额停不了售** | ✅ 同步到库存权威侧,满团即停 / 放开继续招募都生效 |
| 请求体 | `{maxParticipants, maxRooms}` 两个绝对值,均必填 | `{capacityDelta, reason}`,净增量、只调户数 |
| 响应 | 空(调完要再拉一次详情) | 返回调整前后值、已报名与余量 |
| 阶段限制 | **无**,任何状态都能调 | 仅招募中与资源准备中,其余 589538 |
| 权限 | **无任何校验** | `group-batch:manage` |
| 调整原因 | 无此入参 | `reason` 选填,写进操作记录 |
| 操作记录 | 只有「最大人数:20→24,最大房间:8→9」 | 「满团名额:9→10(+1) · 理由:…」 |
## 六.7、影响评估
- **请求体不兼容**:字段整体更换。上线前已核对管理后台前端仓库,
**没有任何页面在调用本接口**(原型侧本就是提交净增量,只是此前只在前端本地累加),
因此未设兼容期。若有未知调用方,需同步改造。
- **阶段门是行为变更**:此前任何状态都能调,现在物料准备后会被拒。
这是有意收紧——那之后房车已按户数配好,改名额会让资源计划失真。
- **权限是行为变更**:`group-batch:manage` 已随上一单建好并授予管理员与超级管理员,
本次直接复用,无需额外配置。
- **资源准备中调整的已知风险**:该阶段房务/车务可能已按当前户数派单订房订车,
此时加减名额**不会回头改动已派资源**,需人工复核资源计划。
- 新增字段与新响应均为增量,不改动任何既有字段的类型与含义。
## 七、不影响范围
- 下单与库存扣减的判定口径不变,仍按满团户数与人数上限;本次只是让调整真正作用到它。
- 人数容量不受影响,仍由产品侧维护。
- 团期状态机不变:调整不推进也不回退任何状态。
- 成团、取消成团、流团三个动作本次未改动。
- 小程序端不受影响,改动全部在管理后台侧。
## 八、测试环境已验证
TEST 环境已部署产品服务与订单服务并实测通过:
- 加名额 +1:产品侧满团户数随之变化,团期侧同步,返回三格数据。
- 减名额 −2:两侧同步。
- 减到正好等于已报名户数:班期转为**已满额、停止接单**。
- 再加 1 户:班期**恢复报名中**,空位重新放出。
- 减到低于已报名户数:拒绝且**零写入**(产品侧数值未变)。
- 增量为 0:拒绝。
- 物料准备中及之后调整:拒绝且零写入。
- **已成团(资源准备中)加名额:成功,班期继续招募,团期仍保持成团不回退**。
## 十、相关文档
- 工单:HL#7178
- 合并:HL PR#7181(已合入 dev-v3)
- 关联工单:HL#7158(团期手动成团),本单复用其建立的 `group-batch:manage` 权限码;
弹窗顶部「满 6 户成团 · 满 9 户满团」的成团标准数据亦由该单交付
## 关联 / 联系人
- 后端:jw
- 前端待接:弹窗步进器改为提交净增量、接收新的响应结构刷新三格;
团期进入物料准备后按钮置灰;未达标提示与「不得低于已报名数」的前端拦截保持不变
@@ -0,0 +1,292 @@
---
schema: "hl-changelog/v2"
ticket: "7182"
title: "供应商账户删除、启用与新记录式修改审批"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "326a1523"
target_release: ""
verified_at: "2026-09-07"
status_note: "前端已交付 ref 326a1523(+sync-log 20eb2a19):accounts.js 新增 enable/delete/update 三 API(disable 已有);SupplierAccountsManageModal 操作列扩启用/修改/删除(显隐 status×isDefault、在途审批本地禁用+后端 395011/395028 兜底);新建 SupplierAccountEditModal 新记录式修改(条件必填月结账期/开票税率、proofFileUrls 按权限省略);移除「无删除/停用入口」过时提示。supplier 域 175+全量 vitest 2171 绿,生产构建过。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 供应商:账户删除、启用与新记录式修改审批
## 关键变化
`#7029` 中“无删除/停用入口”已过时。后端现提供删除、停用、启用和修改四类审批操作;提交成功只表示待审,企微通过后才应用账户变更。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 申请停用账户 | POST | `/admin/supplier/bank-accounts/{accountId}/disable` | 权限与链路延用 | 保留现有入口,待审期间仍启用 |
| 2 | 申请启用账户 | POST | `/admin/supplier/bank-accounts/{accountId}/enable` | 新增接口 | 待审期间仍停用 |
| 3 | 申请删除账户 | POST | `/admin/supplier/bank-accounts/{accountId}/delete` | 新增接口 | 通过后软删除 |
| 4 | 申请修改账户 | PUT | `/admin/supplier/bank-accounts/{accountId}/update` | 新增接口 | 新建 PENDING 记录,原账户待审期间继续生效 |
## 三、接口详情
### 1. 申请停用 `POST /admin/supplier/bank-accounts/{accountId}/disable`
**VO**: `SupplierAccountDisableReqVO / BankAccountSubmitResultRespVO`
#### 使用场景
对非默认 `ACTIVE` 账户申请停用。`FINANCE` 与 `SUPER_ADMIN` 均可在具备账户维护和审批提交权限时操作。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| accountId | Path | String | 是 | 正整数 | 目标账户 ID |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 最新账户详情版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| accountId / approvalLogId | String | 账户 / 审批记录 ID |
| requestNo / provider / spNo | String | 幂等号 / 审批提供方 / 企微单号 |
| approvalStatus / approvalStatusName | String | 审批状态 / 中文名 |
| syncStatus / accountStatus / isDefault | String | 同步状态 / 账户现态 / 默认标记 |
| submittedAt / finishedAt | String/null | 提交 / 终态时间 |
#### 请求示例
```json
{"expectedUpdateTime":"2026-09-06 12:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"accountId":"7182001","approvalLogId":"7182002","approvalStatus":"PENDING","approvalStatusName":"审核中","spNo":"202609060001","syncStatus":"REQUESTING","accountStatus":"ACTIVE","isDefault":"NO"}}
```
#### 空数据 / 降级响应
无空成功数据;账户不存在返回 `395001`。企微提交或对账异常按 `syncStatus` 返回原审批事实,不直接停用账户。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","success":false,"data":null}
```
#### 业务边界
- 仅非默认 `ACTIVE` 账户可提交;待审期间仍 `ACTIVE`,通过后才 `DISABLED`。
- 供应商必须 `ACTIVE`,主体与账户均不能有在途审批;失败零写入。
### 2. 申请启用 `POST /admin/supplier/bank-accounts/{accountId}/enable`
**VO**: `SupplierAccountActionReqVO / BankAccountSubmitResultRespVO`
#### 使用场景
对 `DISABLED` 账户申请恢复启用。`FINANCE` 与 `SUPER_ADMIN` 均可在具备账户维护和审批提交权限时操作。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| accountId | Path | String | 是 | 正整数 | 目标账户 ID |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 最新账户详情版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| accountId / approvalLogId | String | 账户 / 审批记录 ID |
| requestNo / provider / spNo | String | 幂等号 / 审批提供方 / 企微单号 |
| approvalStatus / approvalStatusName | String | 审批状态 / 中文名 |
| syncStatus / accountStatus / isDefault | String | 同步状态 / 账户现态 / 默认标记 |
| submittedAt / finishedAt | String/null | 提交 / 终态时间 |
#### 请求示例
```json
{"expectedUpdateTime":"2026-09-06 12:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"accountId":"7182001","approvalLogId":"7182003","approvalStatus":"PENDING","approvalStatusName":"审核中","spNo":"202609060002","syncStatus":"REQUESTING","accountStatus":"DISABLED","isDefault":"NO"}}
```
#### 空数据 / 降级响应
无空成功数据;账户不存在返回 `395001`。企微异常不会提前启用账户,调用方须按 `syncStatus` 展示待同步/待对账。
#### 错误响应
```json
{"code":395011,"message":"该账户正在审批中,请勿重复提交","success":false,"data":null}
```
#### 业务边界
- 仅 `DISABLED` 账户可提交;待审期间仍 `DISABLED`,通过后才 `ACTIVE`。
- 供应商必须 `ACTIVE`,主体与账户均不能有在途审批;失败零写入。
### 3. 申请删除 `POST /admin/supplier/bank-accounts/{accountId}/delete`
**VO**: `SupplierAccountActionReqVO / BankAccountSubmitResultRespVO`
#### 使用场景
对非默认 `ACTIVE` 或 `DISABLED` 账户申请删除。`FINANCE` 与 `SUPER_ADMIN` 均可在具备账户维护和审批提交权限时操作。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| accountId | Path | String | 是 | 正整数 | 目标账户 ID |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 最新账户详情版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| accountId / approvalLogId | String | 账户 / 审批记录 ID |
| requestNo / provider / spNo | String | 幂等号 / 审批提供方 / 企微单号 |
| approvalStatus / approvalStatusName | String | 审批状态 / 中文名 |
| syncStatus / accountStatus / isDefault | String | 同步状态 / 账户现态 / 默认标记 |
| submittedAt / finishedAt | String/null | 提交 / 终态时间 |
#### 请求示例
```json
{"expectedUpdateTime":"2026-09-06 12:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"accountId":"7182001","approvalLogId":"7182004","approvalStatus":"PENDING","approvalStatusName":"审核中","spNo":"202609060003","syncStatus":"REQUESTING","accountStatus":"ACTIVE","isDefault":"NO"}}
```
#### 空数据 / 降级响应
无空成功数据;已软删除或不存在返回 `395001`。企微异常时保留原账户,不把提交成功当作删除成功。
#### 错误响应
```json
{"code":395005,"message":"当前状态不允许执行该操作","success":false,"data":null}
```
#### 业务边界
- 仅非默认 `ACTIVE` 或 `DISABLED` 账户可提交;待审期间保留原状态,通过后才软删除。
- 供应商必须 `ACTIVE`,主体与账户均不能有在途审批;失败零写入。
### 4. 申请修改 `PUT /admin/supplier/bank-accounts/{accountId}/update`
**VO**: `SupplierAccountUpdateReqVO / BankAccountSubmitResultRespVO`
#### 使用场景
对 `ACTIVE` 账户提交完整新资料。后端新建待审账户,不覆盖原记录。`FINANCE` 与 `SUPER_ADMIN` 均可在具备账户维护和审批提交权限时操作。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| accountId | Path | String | 是 | 正整数 | 原账户 ID |
| accountType / bankName / accountNo | Body | String | 是 | 沿用账户校验 | 新账户核心资料 |
| bankBranch / proofFileUrls | Body | String/List | 否 | 附件最多 20 个 | 开户支行 / 证明材料 |
| settleMode / accountPeriod | Body | String | 否 | 月结时账期必填 | 结算方式 / 账期 |
| invoiceType / taxRate | Body | String | 否 | 开票时税率必填 | 发票类型 / 税率 |
| expectedUpdateTime | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 原账户最新版本 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| accountId / approvalLogId | String | 新建待审账户 / 审批记录 ID |
| requestNo / provider / spNo | String | 幂等号 / 审批提供方 / 企微单号 |
| approvalStatus / approvalStatusName | String | 审批状态 / 中文名 |
| syncStatus / accountStatus / isDefault | String | 同步状态 / 新账户现态 / 默认标记 |
| submittedAt / finishedAt | String/null | 提交 / 终态时间 |
#### 请求示例
```json
{"accountType":"CORPORATE","bankName":"示例银行","bankBranch":"示例支行","accountNo":"718200000002","proofFileUrls":[],"settleMode":"PREPAY","invoiceType":"NONE","expectedUpdateTime":"2026-09-06 12:00:00"}
```
#### 响应示例
```json
{"code":200,"message":"成功","success":true,"data":{"accountId":"7182010","approvalLogId":"7182011","approvalStatus":"PENDING","approvalStatusName":"审核中","spNo":"202609060004","syncStatus":"REQUESTING","accountStatus":"PENDING","isDefault":"NO"}}
```
#### 空数据 / 降级响应
无空成功数据;原账户不存在返回 `395001`。企微异常时原账户保持 `ACTIVE`,已创建的新记录按同步状态对账,不覆盖原账户。
#### 错误响应
```json
{"code":395027,"message":"该收款账号已被占用,请联系财务核实","success":false,"data":null}
```
#### 业务边界
- 仅 `ACTIVE` 原账户可提交,新 `accountNo` 必须全局未占用;返回的 `accountId` 是新记录。
- 待审期间原账户 `ACTIVE`、新记录 `PENDING`;通过后原账户 `DISABLED`、新记录 `ACTIVE` 并承接默认标记。
- 驳回或撤销后原账户不变、新记录 `DISABLED`;所有门禁失败零写入。
## 四、契约约束与正确调用方式
1. 先读取账户详情,将最新 `updateTime` 原样传为 `expectedUpdateTime`。
2. 提交后根据 `approvalStatus`、`syncStatus`、`accountStatus` 展示待审态,不要把 `code=200` 解释为已应用。
3. 同账户有在途审批时禁用其他变更按钮;回调或轮询进入终态后刷新账户列表和详情。
4. 修改成功返回的 `accountId` 指向新记录;驳回/撤销后不要用新记录覆盖原账户。
5. 前端需移除“无删除/停用入口”的过时提示,接入四类操作的按钮、版本参数、待审展示和终态刷新。
## 五、数据库行为
无 DDL。四类操作复用现有账户审批表和企微回调/轮询链路;修改新建账户行,删除仅在审批通过后软删除目标行。
## 六、边界行为
- `401`:未登录;`400`:缺参或字段校验失败。
- `395001`:账户/供应商不存在;`395002`:角色或权限不允许。
- `395005`:供应商主审批中、账户状态或默认标记不允许;`395010`:供应商非 `ACTIVE`(如暂停合作)。
- `395011` / `395028`:账户已有在途审批/变更;`395014`:版本过期;`395027`:新账号已占用。
- `395019`–`395022`:企微配置、绑定、提交失败或结果待对账;按 `syncStatus` 处理,不要无限新建申请。
## 六.5、枚举 / 数据字典
- `approvalStatus`:`PENDING` 审核中,`APPROVED` 已通过,`REJECTED` 已驳回,`CANCELED` 已撤销。
- `accountStatus`:`PENDING`、`ACTIVE`、`DISABLED`、`DELETED`;历史 `REJECTED` 仅兼容读取。
- `syncStatus`:`REQUESTING`、`APPLIED`、`APPLY_FAILED`、`RESULT_UNCERTAIN`。
## 七、不影响范围
仅影响管理后台供应商账户维护。已有账户新增、查询、设为默认的路径与响应结构不变;无数据迁移、Redis、MQ 或前端源码变更。
## 八、测试环境已验证
TEST Gateway 已验证真实企微通过后的修改、停用、启用与删除:修改待审期间原账户继续生效,通过后新旧记录正确迁移;停用/启用待审期间保持原状态;`ACTIVE` 与 `DISABLED` 删除通过后均软删除。同版本重试复用原审批,在途变更、主体审批中、暂停合作和未登录请求均被拒绝且零写入。合成账户已清理,有效账户数回到基线 0;前端待接入。
## 十、相关文档
- **Issue**:[#7182](https://git.1814.love:8443/wx/HL/issues/7182)
- **PR**:[#7192](https://git.1814.love:8443/wx/HL/pulls/7192)
- **Merge commit**:[`06e682ae417defddcfb391d2afc4e928fc702627`](https://git.1814.love:8443/wx/HL/commit/06e682ae417defddcfb391d2afc4e928fc702627)
## 关联 / 联系人
- **后端负责人**:@lc
@@ -0,0 +1,458 @@
---
schema: "hl-changelog/v2"
ticket: "7188"
title: "团期看板/列表/详情透出「第N期」班期序号 batchLabel"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "2eb27845"
target_release: ""
verified_at: "2026-09-08"
updated_at: "2026-09-08"
base: "dev-v3"
status_note: "前端已交付并验证(commit 2eb27845, 汇总工单项1):PeriodRow 行标题/BatchHero 详情头/detail toolbar 三处 batchLabel 数字字符串纯拼接为「第{batchLabel}期 {名称}」,null 兜底只显名称,禁算术/排序;新增 spec 锁三处拼接与 null 兜底。"
---
# 团期模块:看板 / 列表 / 详情透出「第N期」班期序号
## ⚠️ 关键变化
- 三个团期接口新增 / 修正字段 **`batchLabel`**(String,班期序号,如 `"7"`):product 侧按同产品全部班期**出发日期升序位置 = 下标 + 1** 实时重算(不信存储列,删班期后自愈;同日期同排序值按 batchId 兜底),经内部接口透传到 order-v3。
- **前端行标题改为「第{batchLabel}期 {batchName}」**(hl-ui `src/views/order-v2/batch/components/PeriodRow.vue:164-171` `title` computed 目前只显 `batchName`),`batchLabel` 为空时退回只显 `batchName`——存量团期在下一次该班期下单前分页/详情里是 null,必须兜底。
- 班期名口径:product 保存与 order 快照两侧 **trim**;新增/编辑班期名为空白 → 回退班期编号 `batchNo`;编辑不传 `batchName` → 保留旧名。现场「 没,那你」(前导空格)这类脏名下单后自愈。
- 分页项(GB-ADM-001)本单取快照值;#7189 合入后分页改以产品全班期为基底、`batchLabel` 取 product 实时值,**字段名不变**。
---
## 一、背景
### 现象
wx 2026-09-06 看测试服「团期订单」看板:行标题显示班期名「没,那你」,期望「第7期 没,那你」(该班期是产品「冻干粉发短信给」按出发日的第 7 期)。序号只存在于 product 管理端班期列表,Feign 契约与团期三个响应 VO 都没有,`order_group_batch.batch_label` 列从未被写入。
### 调用链
1. hl-ui `src/stores/orderV2Batch.js:93-97` `getGroupBatchPage()` → `GET /v3/admin/order/group-batch`(GB-ADM-001)→ `records[].batchLabel`(快照)
2. 看板合并层 `GET /v3/admin/order/group-batch/board` → order-v3 Feign `GET /internal/product/group/{productId}/detail` → product `GroupTourBatchService.getBatchCalendarByProductId` 逐项 `batchLabel = 位置 + 1` → 命中/未建团行实时值、孤儿行快照
3. 团期详情 `GET /v3/admin/order/group-batch/{groupBatchId}`(GB-ADM-002)→ `batchLabel` 快照
4. 快照写入:GROUP 创单建团时 `GroupBatchService.buildGroupBatchDO` 从 `GET /internal/product/batch/{batchId}/info` 的 `batchLabel` 写 `batch_label`;此后每次该班期下单 `refreshSnapshot` 同步刷新序号 / 班期名 / 产品名 / 出发日 / 返团日 / 报名截止日六列
### 地面真相(测试服 dev-v3 a5eacefd,2026-09-06 21:2x,产品 2044306857534636034,班期 2026-10-01 productBatchId=2052935476557328386,团期 2096412454643802114)
| 接口 | 结果 |
|---|---|
| board | 7 行按出发日升序 `batchLabel` = "1"(06-04) "2"(07-03) "3"(07-10) "4"(07-17) "5"(07-24) "6"(07-31) "7"(10-01);06-04 期存储列是 "6" 却回 "1",10-01 期回 "7" ✓ 按位置实时重算 |
| 分页 / 详情(刷新前) | `batchLabel` 字段存在、值 null(存量团期) |
| 分页 / 详情(在该班期新下一单后,测完已取消) | 详情 `batchLabel="7"`、分页项 `batchLabel="7"`、`batchName="没,那你"`(前导空格已 trim) |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期看板分页(GB-ADM-001) | GET | `/v3/admin/order/group-batch` | 响应新增字段 | `records[].batchLabel`(快照;存量团期下单前为 null) |
| 2 | 团期看板合并层列表 | GET | `/v3/admin/order/group-batch/board` | 响应新增字段 | 每项 `batchLabel`:命中/未建团行取 product 实时值,孤儿行取快照 |
| 3 | 团期详情(GB-ADM-002) | GET | `/v3/admin/order/group-batch/{groupBatchId}` | 响应字段语义修正 | 既有字段 `batchLabel` 由恒 null 改为班期序号快照 |
---
## 三、接口详情
### 1. 团期看板分页 `GET /v3/admin/order/group-batch`
**VO**: `GroupBatchListReqVO → Result<PageResult<GroupBatchPageItemRespVO>>`
#### 使用场景
团期看板列表;行标题用 `batchLabel` + `batchName` 拼「第N期 名称」。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | query | Long(字符串) | 否 | 雪花 ID | 按产品筛选 |
| opsStage | query | String | 否 | RECRUIT / FORMED / PENDING_TRIP / TRAVELLING / TRIP_FINISHED / AUDITING / CHECKED / DISBANDED | 桶筛选(不变) |
| month | query | String | 否 | yyyy-MM | 出发月份 |
| keyword | query | String | 否 | 已转义 | 班期编号 / 名称模糊 |
| batchStatus | query | String | 否 | 团期状态码 | 可选 |
| deadlineFrom / deadlineTo | query | String | 否 | yyyy-MM-dd | 报名截止日区间 |
| pageNo | query | Integer | 否 | 默认 1 | 页码 |
| pageSize | query | Integer | 否 | 默认 20,最大 100 | 每页条数 |
#### 出参 `Result<PageResult<GroupBatchPageItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.records[] | Array | 团期行(分页容器 `records / total / page / pageSize`) |
| data.records[].batchLabel | String | **新增**。班期序号快照(`order_group_batch.batch_label`):建团写入、每次该班期下单刷新;存量团期在下一次下单前为 null;#7189 起改取 product 实时值 |
| data.records[].batchName | String | 班期名(快照,下单刷新时已 trim) |
| data.records[].batchNo | String | 班期编号 |
| 其余字段 | — | 不变(groupBatchId / productBatchId / productId / productName / batchStatus / batchStatusName / minGroupPeople / maxRooms / maxParticipants / enrolledPeople / enrolledRooms / remainRooms / remainParticipants / orderCount / enrollDeadline / departDate / endDate / receivableAmount / receivedAmount / chips) |
#### 请求示例
```http
GET /v3/admin/order/group-batch?productId=2044306857534636034&pageNo=1&pageSize=50 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
在该班期下单刷新快照后实测(2026-09-06 21:2x,非相关字段略):
```json
{
"code": 200,
"message": "成功",
"data": {
"records": [
{
"groupBatchId": "2096412454643802114",
"productBatchId": "2052935476557328386",
"productId": "2044306857534636034",
"batchNo": "Q202610012052935476548939777",
"batchName": "没,那你",
"batchLabel": "7",
"batchStatus": "RESOURCE_PREPARING",
"batchStatusName": "资源准备中",
"departDate": "2026-10-01",
"endDate": "2026-10-03"
}
],
"total": 1,
"page": 1,
"pageSize": 50
},
"success": true
}
```
#### 空数据 / 降级响应
存量团期在下一次该班期下单前 `batchLabel` 为 null(不是缺字段),前端退回只显 `batchName`;无命中团期时 `records` 为空数组。
```json
{
"code": 200,
"message": "成功",
"data": {
"records": [
{ "groupBatchId": "2096412454643802114", "batchName": "没,那你", "batchLabel": null }
],
"total": 1,
"page": 1,
"pageSize": 50
},
"success": true
}
```
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"data": null,
"success": false
}
```
HTTP 始终 200,按 `code` 判断。
#### 业务边界
- 权限 `group-batch:view`
- `batchLabel` 是数字字符串(`"7"`),后端不拼「第N期」文案,前端拼
- 序号会随 product 侧增删班期漂移,快照只在下单时刷新;需要绝对实时以 board / #7189 后的分页为准
### 2. 团期看板合并层列表 `GET /v3/admin/order/group-batch/board`
**VO**: `Result<List<GroupBatchBoardItemRespVO>>`(查询参数 productId,无请求 VO)
#### 使用场景
以产品全部班期为基底的看板(命中 / 未建团 / 孤儿三类行);hl-ui 当前未调用,#7189 后分页也走同一合并基底。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | query | Long(字符串) | 是 | 雪花 ID | 产品 ID |
#### 出参 `Result<List<GroupBatchBoardItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data[].batchLabel | String | **新增**。命中行 / 未建团行 = product 实时序号;孤儿行(product 已删该班期)= 快照,可能 null |
| data[].productBatchId / productId / batchNo / batchName | String | 不变 |
| data[].departureDate / endDate / enrollmentDeadline | String(yyyy-MM-dd) | 不变(product 实时) |
| data[].groupBatchId | String(Long) | 未建团行为 null(不变) |
| 其余字段 | — | 不变(maxRooms / maxParticipants / enrolledRooms / enrolledPeople / remainRooms / remainParticipants / batchStatus / batchStatusLabel / hotelReady / vehicleReady / guideReady / photographerReady / needsGuide / needsPhotographer / orderCount / productBatchRemoved / contractSignedCount / insuranceInsuredCount) |
#### 请求示例
```http
GET /v3/admin/order/group-batch/board?productId=2044306857534636034 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
实测(2026-09-06 21:2x,只列序号相关字段,共 7 行):
```json
{
"code": 200,
"message": "成功",
"data": [
{ "productBatchId": "2045498872326717442", "departureDate": "2026-06-04", "batchName": "fadege", "batchLabel": "1", "groupBatchId": null },
{ "productBatchId": "2045401225930690562", "departureDate": "2026-07-03", "batchLabel": "2", "groupBatchId": null },
{ "productBatchId": "2045401225960050689", "departureDate": "2026-07-10", "batchLabel": "3", "groupBatchId": null },
{ "productBatchId": "2045401225964244993", "departureDate": "2026-07-17", "batchLabel": "4", "groupBatchId": null },
{ "productBatchId": "2045401225972633601", "departureDate": "2026-07-24", "batchLabel": "5", "groupBatchId": null },
{ "productBatchId": "2045401225976827906", "departureDate": "2026-07-31", "batchLabel": "6", "groupBatchId": null },
{ "productBatchId": "2052935476557328386", "departureDate": "2026-10-01", "batchName": " 没,那你", "batchLabel": "7", "groupBatchId": "2096412454643802114" }
],
"success": true
}
```
#### 空数据 / 降级响应
产品无班期时 `data` 为空数组;孤儿行 `batchLabel` 可能为 null。
```json
{
"code": 200,
"message": "成功",
"data": [],
"success": true
}
```
#### 错误响应
```json
{
"code": 589515,
"message": "获取团期产品列表失败,请稍后重试",
"data": null,
"success": false
}
```
另:`589507` 无操作权限。
#### 业务边界
- 序号按 product 当前全部未删班期的出发日升序位置计算(同日期按 sortOrder、再按 batchId);软删的班期不占位
- 未建团行的 `batchName` 取 product 侧当前值(可能未 trim,如示例第 7 行前导空格),命中行分页/详情取快照(已 trim)
### 3. 团期详情 `GET /v3/admin/order/group-batch/{groupBatchId}`
**VO**: `Result<GroupBatchDetailRespVO>`(路径参数,无请求 VO)
#### 使用场景
团期详情页头部;`batchLabel` 语义修正为班期序号快照。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | path | Long(字符串) | 是 | 团期主订单 ID | 不是 productBatchId |
#### 出参 `Result<GroupBatchDetailRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.batchLabel | String | **语义修正**:改前恒 null(列从未写入,文案「班期标签快照 example=主推」);改后 = 班期序号快照(建团写入、下单刷新),文案「班期序号(第N期)快照 example=7」 |
| data.batchName | String | 班期名快照(下单刷新时已 trim) |
| 其余字段 | — | 不变 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/2096412454643802114 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
刷新快照后实测(非相关字段略):
```json
{
"code": 200,
"message": "成功",
"data": {
"groupBatchId": "2096412454643802114",
"productBatchId": "2052935476557328386",
"batchNo": "Q202610012052935476548939777",
"batchName": "没,那你",
"batchLabel": "7",
"batchStatus": "RESOURCE_PREPARING",
"departDate": "2026-10-01",
"endDate": "2026-10-03"
},
"success": true
}
```
#### 空数据 / 降级响应
存量团期下单刷新前 `batchLabel` 为 null;前端退回只显 `batchName`。
```json
{
"code": 200,
"message": "成功",
"data": { "groupBatchId": "2096412454643802114", "batchName": "没,那你", "batchLabel": null },
"success": true
}
```
#### 错误响应
```json
{
"code": 589500,
"message": "团期不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 同 GB-ADM-001:数字字符串、快照语义、null 兜底
---
## 四、契约约束与正确调用方式
> 三个接口均为只读 GET;本节写的是**前端消费 `batchLabel` 的规则**。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload / 处理 |
|------|---------|
| ✅ 行标题 | `row.batchLabel ? \`第${row.batchLabel}期 ${row.batchName}\` : row.batchName` |
| ✅ 徽标 / 导出用序号 | 直接用 `batchLabel` 字符串,不要自己按行下标算 |
| ❌ 把 `batchLabel` 当数字做算术或排序键 | 它是字符串,且随 product 增删班期漂移;排序用 `departDate` |
| ❌ 不兜底 null | 存量团期分页/详情在下单刷新前为 null,会渲染成「第undefined期」 |
### 切换状态时的必要动作
无写接口。序号变化来自 product 侧班期增删/改期,看板刷新即可。
---
## 五、数据库行为
无表变更、无 Flyway。`order_group_batch.batch_label VARCHAR(64)` 列早已存在,本单开始写入:
| 时点 | 写入 |
|------|------|
| GROUP 首单建团 | `batch_label` = product `GET /internal/product/batch/{batchId}/info` 的 `batchLabel`;`batch_name` trim |
| 该班期每次下单 | `refreshSnapshot` 非 null 且变化才更新:`product_name / batch_name / batch_label / depart_date / end_date / enroll_deadline` |
product 侧 `group_tour_batch.batch_label` 存储列不再作为读源(脏值如 06-04 期存 "6" 不影响输出);`batch_name` 保存时 trim,空白回退 `batch_no`。
---
## 六、边界行为
- 未登录 → 网关 401;无权限 → `589507`
- 团期不存在 → `589500`;board 拉产品失败 → `589515`
- product 定位不到该班期(理论不会)→ `batchLabel` null 并记 WARN,不报错
- 产品下只有一期 → `batchLabel="1"`
- 下单时 product 回传日期串非法 → 刷新跳过该列并记 WARN,不影响下单
## 六.5、枚举 / 数据字典
无新增枚举;`batchLabel` 为自由数字字符串。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 修改前 | 修改后 |
|------|--------|--------|
| GB-ADM-001 `records[].batchLabel` | 无 | 新增(快照) |
| board `data[].batchLabel` | 无 | 新增(实时 / 孤儿行快照) |
| GB-ADM-002 `data.batchLabel` | 存在但恒 null | 班期序号快照 |
| product `GET /internal/product/batch/{batchId}/info` `batchLabel` | 无 | 新增(内部接口,前端不直接调) |
### 行为级对比
| 场景 | 修改前 | 修改后 |
|------|--------|--------|
| 看板行标题 | 只有班期名 | 「第7期 没,那你」(前端拼) |
| 班期名带前后空格 | 原样落库、原样显示 | 保存 / 快照两侧 trim |
| 新增或编辑班期名为空白 | 新增回退 batchNo,编辑可能落 null | 两条路径都回退 `batchNo` |
| 编辑不传 batchName | 保留旧名 | 保留旧名(不变) |
## 六.7、影响评估
- 向后兼容:只加字段 / 修正语义,老前端不改不报错
- 前端是否必须同步上线:否;但不改则标题仍无序号
- 数据:无表变更;存量 `batch_label` 不回填,随下单自愈
---
## 七、不影响范围
- **仅影响**: 团期看板行标题、团期详情头部、board 合并层
- **零影响**:
- 创单 / 报价接口契约(`POST /v3/admin/order` 入参出参不变)
- product 管理端班期列表(已是同一序号口径)
- 芯片、桶、状态机
- 小程序
---
## 八、测试环境已验证
真实接口输出(测试服 api.test.1814.love,2026-09-06 21:2x,登录后切 ADMIN 角色):
```
GET /v3/admin/order/group-batch/board?productId=2044306857534636034 → 200, 7 行 batchLabel 1..7 按出发日升序,10-01 期 "7" ✓
GET /v3/admin/order/group-batch?productId=2044306857534636034&pageSize=50 → 200, 存量行 batchLabel 字段存在=null ✓
GET /v3/admin/order/group-batch/2096412454643802114 → 200, batchLabel 字段存在=null ✓
POST /v3/admin/order(该班期新下一单,测完取消) → 200, 触发 refreshSnapshot ✓
GET /v3/admin/order/group-batch/2096412454643802114 → batchLabel="7", batchName="没,那你" ✓
GET /v3/admin/order/group-batch?productId=2044306857534636034&pageSize=50 → 该行 batchLabel="7" ✓
```
验证产品: `productId=2044306857534636034`(冻干粉发短信给),班期 `productBatchId=2052935476557328386`(2026-10-01),团期 `groupBatchId=2096412454643802114`。单测:product `GroupTourBatchServiceTest` 3/0、`GroupTourBatchMapperTest` 2/0(ORDER BY 三键,变异验证)、`GroupBatchLabelResolverTest` 6/0、`InternalProductServiceTest` 7/0、`ProductPricingServiceTest` 97/0;order `GroupBatchServiceTest` 73/0、`GroupBatchConverterTest` 37/0、`BatchInfoVODeserializationTest` 4/0、ArchTest 41/0;合入后 dev-v3 HEAD 全量 product-v2 1550/0、order-v3 8726/0。部署:Deploy Panel 21:19-21:22 product-v2 与 order-v3 双实例滚动完成,分支 dev-v3。
---
## 十、相关文档
- 关联 Issue: [wx/HL#7188](https://git.1814.love:8443/wx/HL/issues/7188)
- 关联 PR: [wx/HL#7209](https://git.1814.love:8443/wx/HL/pulls/7209)
- 同现场: #7189(看板以产品全班期为基底 + 班期范围筛选,分页 batchLabel 改实时值)、#7190(「出行完毕」状态与八桶)、#7204(导/摄芯片零指派口径)
- 前端缺陷 changelog: `06_frontend_团期看板展开行子订单列表恒空-前端缺陷-管理后台.md`
## 关联 / 联系人
### 链接
- **Issue**: [#7188](https://git.1814.love:8443/wx/HL/issues/7188)
- **PR**: [#7209](https://git.1814.love:8443/wx/HL/pulls/7209)
- **Merge commit**: [a5eacefd](https://git.1814.love:8443/wx/HL/commit/a5eacefdb270b2f783db95afc790c9d0eb65a8da)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,458 @@
---
schema: "hl-changelog/v2"
ticket: "7190"
title: "团期状态新增「出行完毕」TRIP_FINISHED,看板七桶扩八桶"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "2eb27845"
target_release: ""
verified_at: "2026-09-08"
updated_at: "2026-09-08"
base: "dev-v3"
status_note: "前端已交付并验证(commit 2eb27845, 汇总工单项3):batchLifecycle OPS_STAGES/OPS_STAT_TABS/BATCH_STATUS_TO_OPS 三处插 TRIP_FINISHED「出行完毕」单射,位置在出行中与待审核之间,统计条按键名读 buckets.TRIP_FINISHED;batchLifecycle.spec 硬断言扩八键顺序+TRIP_FINISHED 单射。"
---
# 团期模块:状态九态(新增「出行完毕」)与看板八桶
## ⚠️ 关键变化
- **团期状态 8 → 9 态**:新增 `TRIP_FINISHED`「出行完毕」,位于 `TRAVELLING`「出行中」之后、`REVIEWING`「核单中」之前。九态与中文名:`RECRUITING` 招募中 / `RESOURCE_PREPARING` 资源准备中 / `MATERIAL_PREPARING` 物料准备中 / `PENDING_DEPARTURE` 待出发 / `TRAVELLING` 出行中 / **`TRIP_FINISHED` 出行完毕** / `REVIEWING` 核单中 / `SETTLED` 已结算 / `CANCELLED` 已取消。
- **看板运营阶段 7 桶 → 8 桶**:新增桶 `TRIP_FINISHED`「出行完毕」,位于 `TRAVELLING` 之后、`AUDITING` 之前。八桶顺序:`RECRUIT, FORMED, PENDING_TRIP, TRAVELLING, TRIP_FINISHED, AUDITING, CHECKED, DISBANDED`。GB-ADM-009 统计条 `buckets` 固定键由 7 个变 8 个;GB-ADM-001 / GB-ADM-008 的 `opsStage` 多一个可选值。
- **推进由两个定时任务按日自动完成**(出发日 ≤ 今天:待出发 → 出行中;返团日 < 今天:出行中 → 出行完毕;日期取 product 侧实时班期日期,不取团期快照)。**上线时两个 sys_job 为 PAUSED,「出行完毕 → 核单中」发起核单入口落地前不会 resume**,所以近期页面上不会自然出现该态,但契约已变,前端要先把桶/页签/映射补齐,否则出现时页签少一个、桶键被忽略。
- 联动:出行完毕团期的房/车/导/摄芯片恒 `DONE`(与已返团同硬规则);对其子订单退团被拒 `589501`;预支入口在该态仍开放;转期/调容量/需求提报按既有规则在该态均拒。
---
## 一、背景
### 现象
wx 2026-09-06 要求看板状态页签在「出行中」后增加「出行完毕」。排查发现团期八态的后半段(待出发 → 出行中 → 核单中 → 已结算)此前没有任何代码推进(订单级有按日期推进的 job,团期级没有),测试库全部团期都停在已成团。本单新增正式状态并补齐团期级按日推进。
### 调用链
1. hl-user-service sys_job(Quartz)`groupBatchLifecycleJob.processDepartures() / processTripFinishes()` → 桥接 bean 经 Feign → order-v3 `POST /v3/internal/jobs/group-batch-departure/run`、`/group-batch-trip-finish/run`(内部端点,网关不开放)
2. order-v3 `GroupBatchLifecycleJobService`:取候选(`PENDING_DEPARTURE` / `TRAVELLING`)→ 按产品经 Feign 取 product 实时班期日期 → `GroupBatchLifecycleTxService.advanceOne` 同一事务 CAS 改状态 + 写时间线(`BATCH_DEPART`「系统自动发团」/ 新事件 `BATCH_TRIP_FINISH`「系统自动出行完毕」)
3. 看板读侧:`GroupBatchStageBuckets` 九态折叠八桶 → GB-ADM-001 `records[].batchStatus / batchStatusName`、GB-ADM-009 `buckets`、GB-ADM-008 导出 `opsStage`
### 地面真相(测试服 dev-v3,2026-09-06 21:38-21:42,团期房务会话临时造数)
| 团期 | 造数 | 触发 job 后 | 说明 |
|---|---|---|---|
| A `2096495107078328322`(#7158验收班期) | 状态 → PENDING_DEPARTURE;product 侧出发日 12-20 → 09-05;order 快照出发日仍 12-20 | 第一轮(出发日 12-20,未来)不动;第二轮(09-05)→ **TRAVELLING** | 读 product 实时日期,快照未动 |
| B `2096510069465088002`(#7178验收班期) | 状态 → TRAVELLING;product 侧返团日 12-28 → 09-05;order 快照返团日仍 12-27 | 第一轮不动;第二轮 → **TRIP_FINISHED**「出行完毕」 | 同上 |
两轮 sys_job 1041 / 1042 执行日志均 SUCCESS(毫秒级)。造数已由团期房务会话复原。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期看板分页(GB-ADM-001) | GET | `/v3/admin/order/group-batch` | 入参枚举扩展 + 响应取值扩展 | `opsStage` / `batchStatus` 新增 `TRIP_FINISHED`;`records[].batchStatus` 可为 `TRIP_FINISHED`、`batchStatusName="出行完毕"` |
| 2 | 团期看板统计条(GB-ADM-009) | GET | `/v3/admin/order/group-batch/summary` | 响应结构扩展 | `buckets` 固定键 7 → 8,新增 `TRIP_FINISHED`,`total` = 八桶之和 |
| 3 | 团期看板导出(GB-ADM-008) | GET | `/v3/admin/order/group-batch/export` | 入参枚举扩展 | `opsStage` 新增 `TRIP_FINISHED` |
---
## 三、接口详情
### 1. 团期看板分页 `GET /v3/admin/order/group-batch`
**VO**: `GroupBatchListReqVO → Result<PageResult<GroupBatchPageItemRespVO>>`
#### 使用场景
团期看板列表(hl-ui `src/stores/orderV2Batch.js:93-97` `getGroupBatchPage()`);点「出行完毕」页签时传 `opsStage=TRIP_FINISHED`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | query | Long(字符串) | 否 | 雪花 ID | 按产品筛选 |
| opsStage | query | String | 否 | `RECRUIT / FORMED / PENDING_TRIP / TRAVELLING / TRIP_FINISHED / AUDITING / CHECKED / DISBANDED` | **新增 `TRIP_FINISHED`**;非法值忽略不报错 |
| batchStatus | query | String | 否 | 九态之一 | **可传 `TRIP_FINISHED`** |
| month | query | String | 否 | yyyy-MM | 出发月份 |
| keyword | query | String | 否 | 已转义 | 班期编号 / 名称模糊 |
| deadlineFrom / deadlineTo | query | String | 否 | yyyy-MM-dd | 报名截止日区间 |
| pageNo | query | Integer | 否 | 默认 1 | 页码 |
| pageSize | query | Integer | 否 | 默认 20,最大 100 | 每页条数 |
#### 出参 `Result<PageResult<GroupBatchPageItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.records[] | Array | 团期行(分页容器 `records / total / page / pageSize`) |
| data.records[].groupBatchId | String(Long) | 团期主订单 ID |
| data.records[].batchStatus | String | 九态之一,**可为 `TRIP_FINISHED`** |
| data.records[].batchStatusName | String | 中文名,`TRIP_FINISHED` 对应「出行完毕」 |
| data.records[].chips | Object | 六芯片;`TRIP_FINISHED` 团期房/车/导/摄恒 `DONE`(硬规则,与已返团同) |
| 其余字段 | — | 不变(productBatchId / productId / productName / batchNo / batchName / batchLabel / maxRooms / maxParticipants / enrolledPeople / enrolledRooms / remainRooms / remainParticipants / orderCount / enrollDeadline / departDate / endDate / receivableAmount / receivedAmount) |
#### 请求示例
```http
GET /v3/admin/order/group-batch?productId=2056947670512971778&opsStage=TRIP_FINISHED&pageSize=10 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
第二轮造数后实测(2026-09-06 21:42):
```json
{
"code": 200,
"message": "成功",
"data": {
"records": [
{
"groupBatchId": "2096510069465088002",
"productId": "2056947670512971778",
"productName": "测试小蒙马-多档-固定金额",
"batchName": "#7178验收班期",
"batchStatus": "TRIP_FINISHED",
"batchStatusName": "出行完毕",
"orderCount": 1,
"departDate": "2026-12-27",
"endDate": "2026-12-28"
}
],
"total": 1,
"page": 1,
"pageSize": 10
},
"success": true
}
```
#### 空数据 / 降级响应
`opsStage=TRIP_FINISHED` 在 sys_job 未 resume 期间通常无命中:`records` 为空数组、`total=0`,不是错误。
```json
{
"code": 200,
"message": "成功",
"data": { "records": [], "total": 0, "page": 1, "pageSize": 20 },
"success": true
}
```
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"data": null,
"success": false
}
```
#### 业务边界
- `opsStage` 与 `batchStatus` 的映射:`TRIP_FINISHED` 桶 = `TRIP_FINISHED` 态(单射,不是复合桶;`FORMED` 仍是 RESOURCE_PREPARING + MATERIAL_PREPARING 复合桶)
- #7189 合入后有「班期范围」筛选,默认「未结束」按返团日过滤;出行完毕/待审核/已审核的团期返团日必然已过,前端点这三个页签时应自动把范围切到「全部」
### 2. 团期看板统计条 `GET /v3/admin/order/group-batch/summary`
**VO**: `Result<GroupBatchSummaryVO>`(查询参数 productId / month / keyword,无请求 VO)
#### 使用场景
看板页签计数(hl-ui `orderV2Batch.js:74` `getGroupBatchSummary()`),与 GB-ADM-001 同筛选、不含 opsStage。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | query | Long(字符串) | 否 | 雪花 ID | 按产品 |
| month | query | String | 否 | yyyy-MM | 出发月份 |
| keyword | query | String | 否 | 已转义 | 班期编号 / 名称模糊 |
#### 出参 `Result<GroupBatchSummaryVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.total | Integer | 命中团期总数 = 八桶之和 |
| data.buckets | Object | **固定 8 键**,顺序 `RECRUIT, FORMED, PENDING_TRIP, TRAVELLING, TRIP_FINISHED, AUDITING, CHECKED, DISBANDED`,无命中为 0(改前 7 键,无 `TRIP_FINISHED`) |
| data.subOrderCount | Integer | 命中团期活跃子订单合计(不变) |
#### 请求示例
```http
GET /v3/admin/order/group-batch/summary?productId=2056947670512971778 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
第二轮造数后实测(21:42):
```json
{
"code": 200,
"message": "成功",
"data": {
"total": 6,
"buckets": {
"RECRUIT": 0,
"FORMED": 2,
"PENDING_TRIP": 0,
"TRAVELLING": 1,
"TRIP_FINISHED": 1,
"AUDITING": 0,
"CHECKED": 0,
"DISBANDED": 2
},
"subOrderCount": 4
},
"success": true
}
```
#### 空数据 / 降级响应
无命中团期时 `total=0`、`subOrderCount=0`,`buckets` 仍固定返回 8 键全 0。
```json
{
"code": 200,
"message": "成功",
"data": { "total": 0, "buckets": { "RECRUIT": 0, "FORMED": 0, "PENDING_TRIP": 0, "TRAVELLING": 0, "TRIP_FINISHED": 0, "AUDITING": 0, "CHECKED": 0, "DISBANDED": 0 }, "subOrderCount": 0 },
"success": true
}
```
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"data": null,
"success": false
}
```
#### 业务边界
- 桶键由后端枚举顺序生成,前端按对象键顺序或按自己的页签表读取都可以,但**不要按索引位置取第 5 个当「待审核」**(改前第 5 个是 AUDITING,改后是 TRIP_FINISHED)
- 老前端未加新键时:多出的 `TRIP_FINISHED` 键被忽略,`total` 与页签计数之和会差出该桶的数量
### 3. 团期看板导出 `GET /v3/admin/order/group-batch/export`
**VO**: `无请求 VO(查询参数) → CSV 文件流 text/csv`
#### 使用场景
看板「导出」按钮(hl-ui `exportGroupBatch()`),筛选参数与 GB-ADM-001 同义、不含分页。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | query | Long(字符串) | 否 | 雪花 ID | 按产品 |
| month | query | String | 否 | yyyy-MM | 出发月份 |
| keyword | query | String | 否 | 已转义 | 班期编号 / 名称模糊 |
| opsStage | query | String | 否 | 八桶之一 | **新增 `TRIP_FINISHED`** |
#### 出参 `CSV 文件流`
| 字段 | 类型 | 说明 |
|------|------|------|
| (响应体) | text/csv | 列与改前一致;状态列可出现「出行完毕」 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/export?productId=2056947670512971778&opsStage=TRIP_FINISHED HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
```json
{
"code": 200,
"message": "文件流(Content-Type: text/csv; Content-Disposition: attachment),此处仅示意,实际响应体为 CSV"
}
```
#### 空数据 / 降级响应
无命中时返回只有表头的 CSV;命中 > 2000 行返回 `589517`。
```json
{
"code": 589517,
"message": "导出行数超过上限,请缩小筛选范围",
"data": null,
"success": false
}
```
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"data": null,
"success": false
}
```
#### 业务边界
- 权限 `group-batch:export`
- `opsStage` 非法值忽略(与 GB-ADM-001 一致)
---
## 四、契约约束与正确调用方式
> 三个接口均为只读 GET,无请求体;本节写的是**前端消费九态/八桶的规则**。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload / 处理 |
|------|---------|
| ✅ 页签 → 筛选 | 点「出行完毕」页签传 `opsStage=TRIP_FINISHED`(与其它页签同一套 `opsStage`) |
| ✅ 状态 → 桶 | 八态映射表加 `TRIP_FINISHED: 'TRIP_FINISHED'`;未知状态值按灰/默认样式,不抛错 |
| ✅ 统计条 | 按键名读 `buckets.TRIP_FINISHED`,不按索引 |
| ❌ 硬编码七桶数组 | 少一个页签、`total` 对不上 |
| ❌ 把 `TRIP_FINISHED` 当「已完成」终态 | 它之后还有核单中 / 已结算;核单入口在后续单 |
### 切换状态时的必要动作
无写接口。团期进入 `TRIP_FINISHED` 只由 sys_job 1042 完成(当前 PAUSED);前端不需要也不能手动推进。
---
## 五、数据库行为
`order_group_batch.batch_status` 为 VARCHAR(32),直接存新值 `TRIP_FINISHED`,无表变更、无 Flyway(order-v3)。hl-user-service Flyway `V20260906_005__register_group_batch_lifecycle_jobs.sql` 种子两行 `sys_job`(1041 团期出发推进 `0 10 0 * * ?`、1042 团期出行完毕推进 `0 20 0 * * ?`,`job_group=ORDER`,`status=PAUSED`)。
| job | 扫描条件 | 推进 | 时间线事件 |
|------|-----------------|------|------|
| 1041 | `PENDING_DEPARTURE` 且 product 侧出发日 ≤ 今天 | → `TRAVELLING` | `BATCH_DEPART` 系统自动发团 |
| 1042 | `TRAVELLING` 且 product 侧返团日 < 今天 | → `TRIP_FINISHED` | `BATCH_TRIP_FINISH` 系统自动出行完毕 |
状态 CAS 与时间线在同一事务内原子提交;日期取不到 product 实时值时回退团期快照并记 WARN。
---
## 六、边界行为
- 未登录 → 网关 401;无权限 → `589507`
- `opsStage` / `batchStatus` 传未知值 → 忽略该筛选,不报错
- 返团日当天仍算出行中(`< 今天` 才推进);出发日当天即算出行中(`≤ 今天`)
- 出行完毕团期:退团 → `589501`;转期 / 调满团名额 / 需求提报按既有阶段门拒绝;预支仍可发起
- HTTP 始终 200,按 `code` 判断
## 六.5、枚举 / 数据字典
### batchStatus(`com.hulalv.order.groupbatch.enums.GroupBatchStatus`)
| 值 | 中文名 | 桶(`GroupBatchStageBuckets.Bucket`) |
|---|---|---|
| `RECRUITING` | 招募中 | `RECRUIT` |
| `RESOURCE_PREPARING` | 资源准备中 | `FORMED` |
| `MATERIAL_PREPARING` | 物料准备中 | `FORMED` |
| `PENDING_DEPARTURE` | 待出发 | `PENDING_TRIP` |
| `TRAVELLING` | 出行中 | `TRAVELLING` |
| **`TRIP_FINISHED`** | **出行完毕(新增)** | **`TRIP_FINISHED`(新增)** |
| `REVIEWING` | 核单中 | `AUDITING` |
| `SETTLED` | 已结算 | `CHECKED` |
| `CANCELLED` | 已取消 | `DISBANDED` |
### opsStage / buckets 键(`GroupBatchStageBuckets.Bucket`)
`RECRUIT` 招募中 / `FORMED` 已成团 / `PENDING_TRIP` 待出行 / `TRAVELLING` 出行中 / **`TRIP_FINISHED` 出行完毕** / `AUDITING` 待审核 / `CHECKED` 已审核 / `DISBANDED` 流团(前端页签文案沿用现有)。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 修改前 | 修改后 |
|------|--------|--------|
| GB-ADM-001 / 008 `opsStage` 取值 | 7 值 | 8 值(+ `TRIP_FINISHED`) |
| GB-ADM-001 `records[].batchStatus` 取值 | 8 值 | 9 值(+ `TRIP_FINISHED`,中文名「出行完毕」) |
| GB-ADM-009 `buckets` 键 | 7 键 | 8 键,`TRIP_FINISHED` 插在 `TRAVELLING` 与 `AUDITING` 之间 |
### 行为级对比
| 场景 | 修改前 | 修改后 |
|------|--------|--------|
| 待出发团期到出发日 | 无人推进,永远待出发 | sys_job 1041(resume 后)每日 00:10 → 出行中 |
| 出行中团期过返团日 | 无人推进,永远出行中 | sys_job 1042(resume 后)每日 00:20 → 出行完毕 |
| 出行完毕团期芯片 | 不存在该态 | 房/车/导/摄恒 `DONE`(与已返团同) |
| 出行完毕团期退团 | 不存在该态 | `589501` |
| 原七桶各自口径 | — | 不变 |
## 六.7、影响评估
- 向后兼容:只增枚举值/键,不改字段名;老前端不改代码不报错,但页签少「出行完毕」、统计条多出的键被忽略
- 前端是否必须同步上线:否(job PAUSED 期间不会出现该态数据);但应在 job resume 前补齐
- 数据:order-v3 无表变更;user-service 只有 sys_job 种子
---
## 七、不影响范围
- **仅影响**: 团期看板页签/统计条/导出的桶集合,团期状态取值
- **零影响**:
- #7204 导/摄芯片零指派口径、房/车/约/保芯片
- 订单(子订单)状态机与订单接口
- 团期成团 / 取消成团 / 流团 / 物资确认 / 结算守卫(仍要求核单中)
- 小程序
---
## 八、测试环境已验证
真实接口输出(测试服 api.test.1814.love,2026-09-06 21:38-21:42,登录后切 ADMIN / 触发 job 切 SUPER_ADMIN):
```
GET /v3/admin/order/group-batch/summary?productId=2044306857534636034 → 200, buckets 键序 RECRUIT,FORMED,PENDING_TRIP,TRAVELLING,TRIP_FINISHED,AUDITING,CHECKED,DISBANDED ✓
POST /admin/job/1041/trigger、/admin/job/1042/trigger(第一轮,造数期均为未来日期) → sys_job_log SUCCESS,A/B 状态不动 ✓
POST /admin/job/1041/trigger、/admin/job/1042/trigger(第二轮,product 侧日期改到 09-05) → SUCCESS,A→TRAVELLING、B→TRIP_FINISHED,order 快照日期未动 ✓
GET /v3/admin/order/group-batch?productId=2056947670512971778&opsStage=TRIP_FINISHED → 200, 1 行 B「出行完毕」 ✓
GET /v3/admin/order/group-batch/summary?productId=2056947670512971778 → TRAVELLING=1, TRIP_FINISHED=1 ✓
GET /v3/admin/order/group-batch/2096510069465088002/chips/hotel → aggregateStatus=DONE(硬规则) ✓
POST /v3/admin/order/group-batch/2096510069465088002/sub-order/2096510069234401282/withdraw → 589501 ✓
```
验证团期: A `2096495107078328322`、B `2096510069465088002`(产品 2056947670512971778「测试小蒙马-多档-固定金额」),造数已复原。单测:`GroupBatchStageBucketsTest` 12/0、`GroupBatchSummaryVOTest` 2/0、`GroupBatchStatusTest` 6/0、`GroupBatchLifecycleJobServiceTest` 11/0、`GroupBatchLifecycleTxServiceTest` 4/0、`GroupBatchLifecycleJobTest` 3/0、`GroupBatchMapperIT` 13/0;合入后 dev-v3 HEAD 全量 order-v3 8726/0、hl-user-service `QuartzJobExecutorTest` 16/0。部署:Deploy Panel 21:20-21:22 双实例滚动完成(order-v3 + user-service),分支 dev-v3。
---
## 十、相关文档
- 关联 Issue: [wx/HL#7190](https://git.1814.love:8443/wx/HL/issues/7190)
- 关联 PR: [wx/HL#7212](https://git.1814.love:8443/wx/HL/pulls/7212)
- 同现场: #7188(「第N期」序号)、#7189(看板以产品全班期为基底 + 班期范围筛选)、#7204(导/摄芯片零指派口径)
- 后续单: 「出行完毕 → 核单中」发起核单入口(落地前两个 sys_job 保持 PAUSED)
## 关联 / 联系人
### 链接
- **Issue**: [#7190](https://git.1814.love:8443/wx/HL/issues/7190)
- **PR**: [#7212](https://git.1814.love:8443/wx/HL/pulls/7212)
- **Merge commit**: [ccb4d8fd](https://git.1814.love:8443/wx/HL/commit/ccb4d8fd5177ef816e71fadd33335f8f1dce3966)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,262 @@
---
schema: "hl-changelog/v2"
ticket: "7197"
title: "订单详情 main 透传团期状态 groupBatchStatus"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "22eb9b03"
target_release: ""
verified_at: "2026-09-08"
status_note: "后端已合 dev-v3 并部署测试服、网关实测通过(PR #7213)。前端待接入 groupBatchStatus 用于判团及下游需求冻结期置灰。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 团期模块:订单详情 main 透传团期状态 groupBatchStatus
> **服务**: `hl-order-service-v3`
> **Issue**: #7197
> **PR**: #7213(squash 合入 dev-v3 `1f87dd04`)
> **日期**: 2026-09-06
> **影响范围**: 管理后台订单详情页面、团期子订单需求冻结期置灰(#7149 后续)
## ⚠️ 关键变化
`GET /v3/admin/order/{id}` 响应 `data.main` 新增只读字段 `groupBatchStatus`(String / null),映射所属团期的 `order_group_batch.batch_status` 原样透传。普通订单、团期查不到、团期已软删均返回 null;与既有 `groupBatchId / batchNo / batchName` 同源同事务。**无破坏向后兼容**——纯新增字段,旧客户端无需改动。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 订单详情 | GET | `/v3/admin/order/{id}` | 响应字段新增 | `data.main.groupBatchStatus` 透传团期状态 |
---
## 三、接口详情
### 1. 订单详情 `GET /v3/admin/order/{id}`
**VO**: `OrderDetailRespVO → OrderMainVO`(`data.main`,`hl-order-service-v3/src/main/java/com/hulalv/order/core/controller/admin/vo/detail/OrderMainVO.java`)新增 `groupBatchStatus: String / null`
#### 使用场景
管理后台订单详情页面加载订单全量信息;定制师根据 `groupBatchStatus` 判定团期是否进入物料准备及之后的冻结期,下游(提需求弹窗 #7149)据此置灰「提交房型 / 用车需求」入口。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | Path | String(Long) | 是 | 订单 ID | 团期子订单、核心订单均支持 |
#### 出参 `Result<OrderDetailRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.main.groupBatchStatus` | String / null | 团期状态枚举值;普通订单 / 团期查不到 / 软删 均为 null;新增字段 |
| `data.main.groupBatchId` | String | 团期 ID(既有字段) |
| `data.main.batchNo` | String | 团期编号(既有字段) |
| `data.main.batchName` | String | 团期名称(既有字段) |
| `data.main.*` | — | 其它字段不变 |
#### 请求示例
```json
GET /v3/admin/order/2096412454488612866
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"main": {
"id": "2096412454488612866",
"groupOrder": true,
"groupBatchId": "2096412454643802114",
"batchNo": "Q202610012052935476548939777",
"batchName": "测试班期",
"groupBatchStatus": "RESOURCE_PREPARING",
"orderStatus": "CUSTOMIZING",
"roomControlStatus": "PENDING_REVIEW",
"vehicleControlStatus": "PENDING",
...
},
"hotelRequirementBrief": { ... },
"vehicleRequirementBrief": { ... },
...
}
}
```
#### 空数据 / 降级响应
无空列表语义。订单 ID 不存在返回 404;团期查不到(查询失败、已软删)时 `groupBatchStatus` 返回 null,其它订单字段正常填充。
#### 错误响应
```json
{
"code": 404,
"message": "订单不存在",
"success": false,
"data": null
}
```
#### 业务边界
- **团期查询**:执行 `SELECT batch_status FROM order_group_batch WHERE id=groupBatchId AND is_deleted=0`;无结果返回 null,不异常。
- **数据一致**:`groupBatchStatus` 与团期查询同一事务,保证与 `groupBatchId / batchNo / batchName` 同源。
- **自动扩展**:后端不维护 `groupBatchStatus` 枚举白名单,新增团期状态(如 #7190 的 `TRIP_FINISHED`)自动透出,前端按未知值兜底不置灰。
- **权限**:继承订单查询权限,无独立权限码。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | 预期结果 |
|---|---|
| ✅ 团单(`productBatchId` 非空)查询 | `groupBatchStatus` 返回团期状态值(RECRUITING / RESOURCE_PREPARING / … / CANCELLED)或 null |
| ✅ 普通订单(`productBatchId` 为空)查询 | `groupBatchStatus` 恒为 null,`groupBatchId / batchNo` 也为 null |
| ✅ 团期已软删或查询失败 | `groupBatchStatus = null`,其它订单字段正常 |
| ✅ 后端新增团期状态 | 前端收到新枚举值时应兜底(如当作「其它状态」处理),不硬编码状态列表 |
### 调用约束
- 本接口为 GET,无请求体,路径参数 `id` 必填。
- 频率不限;响应内容随订单 / 团期实时变化(团期状态变更时 `groupBatchStatus` 同步更新)。
---
## 五、数据库行为
纯查询接口,无写操作。
查询逻辑:
```
SELECT batch_status FROM order_group_batch
WHERE id = order_main.product_batch_id AND is_deleted = 0 LIMIT 1
```
如果 `product_batch_id` 为 null 或查询无结果,`groupBatchStatus` 返回 null。无任何 INSERT / UPDATE / DELETE。
---
## 六、边界行为
- **软删处理**:团期软删(`is_deleted=1`)视作查不到,返回 null,不异常。
- **并发团期状态变更**:响应返回查询时刻的 `batch_status` 快照;前端在分秒级变更(如定制师拖入物料准备中)时仍可能看到旧值,但后端需求冻结拒绝以实时团期状态为准。
- **前端置灰逻辑**:可据 `groupBatchStatus ∈ {MATERIAL_PREPARING, PENDING_DEPARTURE, TRAVELLING, REVIEWING, SETTLED}` 弱化「提需求」入口(#7149);但**打回户例外**:该户最新需求被团期管理员 `REJECTED_TO_CONSULTANT` 后可重提一次,前端无法从本接口判定,需依赖提交时的 589536 或管理员系统提示。
- **未知状态兜底**:枚举后续扩展时前端应将新枚举值按某种默认行为处理(如当作"冻结"、不置灰),不抛异常。
---
## 六.5、枚举 / 数据字典
### groupBatchStatus(`com.hulalv.order.groupbatch.enums.GroupBatchStatus`)
**所属字段**: `data.main.groupBatchStatus` | **类型**: `String / null`
| 值 | 芯片文案 | 含义 | 需求提交 |
|---|---|---|---|
| RECRUITING | 招募中 | 正在招募参加者 | ✅ 放行 |
| RESOURCE_PREPARING | 资源准备中 | 已成团,资源采购中 | ✅ 放行 |
| MATERIAL_PREPARING | 物料准备中 | 物资准备中,需求冻结 | ❌ 589536(打回户例外) |
| PENDING_DEPARTURE | 待出发 | 即将出发 | ❌ 589536(打回户例外) |
| TRAVELLING | 出行中 | 正在出行 | ❌ 589536(打回户例外) |
| TRIP_FINISHED | 行程结束 | 出行结束 | ❌ 589536(打回户例外) |
| REVIEWING | 核单中 | 核对结算数据中 | ❌ 589536(打回户例外) |
| SETTLED | 已结算 | 结算完成 | ❌ 589536(打下户例外) |
| CANCELLED | 已取消 | 团期已取消 | ❌ 589501(团期不存在) |
| null | — | 普通订单或团期查不到 | — |
**注意**:列表以后端枚举为准,后续可能新增状态。前端应对未来枚举值容错,不能只处理上述 9 个值。
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 修改前 | 修改后 |
|---|---|---|
| `data.main.groupBatchStatus` | 无(缺失) | 新增,返回团期 `batch_status` 原值或 null |
| `data.main.*(其它)` | 无变化 | 无变化 |
### 行为级对比
| 行为 | 修改前 | 修改后 |
|---|---|---|
| 判团依据 | 靠 `groupBatchId` 非空判定(前端硬编码) | 直接读 `groupBatchStatus`,含义更明确 |
| 需求冻结期判定 | 前端/下游服务需自己维护团期状态枚举 | 后端透传团期实时状态,减少失同步 |
| 新增团期状态 | 需改前端硬编码列表 | 自动支持,前端兜底即可 |
---
## 六.7、影响评估
- **破坏向后兼容**:否——纯新增字段,旧客户端忽略 `groupBatchStatus` 仍能正常工作。
- **前端是否必须同步上线**:建议同步——为了支持 #7149 的冻结期置灰。不同步时功能不受影响,但无法根据团期状态判定需求是否冻结。
- **前端需清理的分支**:「硬编码团期状态枚举」应改为后端透传,若有本地状态映射表需同步新增状态。
- **后端改动范围**:仅 `OrderMainVO`(`OrderDetailRespVO.main`)新增一个只读字段,零业务逻辑改动,兼容性最高。
---
## 七、不影响范围
- **订单详情其它字段**:`groupBatchId / batchNo / batchName / orderStatus / roomControlStatus` 等既有字段零改动。
- **订单创建 / 修改**:`POST /admin/order / PUT /admin/order/{id}` 等写操作不变。
- **需求提交接口**:#7149 的 `PUT /admin/order/{id}/hotel-requirement` 等冻结期逻辑独立,本接口仅供前端读取状态。
- **权限码 / 网关路由**:无新增权限码,网关路由规则不变。
- **数据库表结构**:无 Flyway 迁移。
---
## 八、测试环境已验证
**环境**:TEST 网关 `https://api.test.1814.love:9443`,order-v3 dev-v3 `1f87dd04` 两实例 2026-09-06 21:29 滚动部署完成。
| 场景 | 请求 | 结果 |
|---|---|---|
| 团单(状态 RESOURCE_PREPARING) | `GET /v3/admin/order/2096412454488612866` | 200,`main.groupBatchStatus="RESOURCE_PREPARING"` ✓ |
| 团单详情(状态 PENDING_DEPARTURE) | `GET /v3/admin/order/2096495107032190978` | 200,`main.groupBatchStatus="PENDING_DEPARTURE"` ✓ |
| 普通订单 | `GET /v3/admin/order/2095549346731757569` | 200,`main.groupBatchStatus=null`,`groupBatchId=null` ✓ |
| 团期与订单同源 | DB `order_group_batch.batch_status` | 与响应 `groupBatchStatus` 一致 ✓ |
**单测**:`OrderServiceTest` 全绿;order-v3 `mvn test` 完全编译通过。
---
## 十、相关文档
- 团期模块接口文档 `docs/order-v3/api/API-SPEC.html` §1.3.1(订单详情、判团字段透出)
- 契约文档已同步:`docs/order-v3/api/contract/order-detail-vo.md`
- 关联工单 #7149(下游置灰需求)、#7142(判团字段透出方案)、#7190(新增 TRIP_FINISHED)
---
## 关联 / 联系人
### 链接
- **Issue**: [#7197](https://git.1814.love:8443/wx/HL/issues/7197)
- **PR**: [#7213](https://git.1814.love:8443/wx/HL/pulls/7213)
- **Merge commit**: [`1f87dd04`](https://git.1814.love:8443/wx/HL/commit/1f87dd04)
### 联系人
- **后端负责人**: wx
- **前端负责人(hl-ui)**: mmg
@@ -0,0 +1,236 @@
---
schema: "hl-changelog/v2"
ticket: "7199"
title: "供应商分页/列表出参补字段(联系电话)"
consumer: "admin"
author: "yst"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "not_required"
frontend_owner: "mmg"
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "not_required 2026-09-10 mmg:净变化仅 SupplierListItemRespVO 新增 contactPhone(balance 已被 #7291 移除勿消费)。changelog 自答非必须同步上线、需要电话时才接入;grep 实证 SupplierPickerModal 选择器列(fullName/supplierNo/types/creditLevel)未读 contactPhone,供应商列表页/财务域供应商选择弹窗均无该字段消费点。纯新增可选出参、旧前端不读不受影响。将来财务大批(应付款/业务外/往来账选供应商)需电话列时在对应模块接入,不属本条。"
updated_at: "2026-09-10"
base: "dev-v3"
---
# 供应商分页/列表出参补字段
## 1. 接口背景
财务域弹窗选供应商(应付款 / 业务外收支 / 往来账 / 收票四个场景)需要在选择器里直接看到供应商的联系电话。复用现有供应商分页 / 有界列表接口(弹窗分页搜索形态,供应商数量多不适用下拉全量加载),出参 `SupplierListItemRespVO` 补字段。
> **重要时序说明**:本 PR(#7202,2026-09-06 合并)当时新增了 `balance` + `contactPhone` 两个字段;次日 PR #7291(2026-09-07 合并)做供应商历史字段清理,又把 `balance` 从出参移除(详见同目录 `07_7291_供应商与结算信息移除历史字段-修改接口-管理后台.md`,前端已配合清理完毕)。**因此当前线上相对旧版的净变化只有 `contactPhone` 一个新增字段,请勿消费 `balance`。**
## 2. 变更清单
| 类型 | 接口 | 说明 |
|---|---|---|
| 修改 | GET `/admin/supplier/items/page` | 出参 `records[]` 新增 `contactPhone` |
| 修改 | GET `/admin/supplier/items/list` | 出参数组项新增 `contactPhone` |
两接口共用同一出参装配逻辑,同时生效。入参与其他出参字段不变,仅新增字段,向后兼容。
## 3. 接口详情
| 项 | 说明 |
|---|---|
| 使用场景 | 管理后台供应商列表页;财务域弹窗选供应商(应付款 / 业务外收支 / 往来账 / 收票) |
| 认证 | 管理后台 JWT(`/admin/*` 网关鉴权),Service 层校验可信角色和列表权限 |
| 幂等性 | 只读查询,天然幂等 |
| 限流 | 无特殊限流 |
## 4. 接口入参
### 4.1 GET /admin/supplier/items/page(Query,SupplierPageReqVO)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| pageNo | Integer | 是 | 页码(沿用现有分页规则) |
| pageSize | Integer | 是 | 每页条数 |
| keyword | String | 否 | 模糊匹配业务编码 / 全称 / 简称;明文税号优先等值匹配,最长 500 |
| status | String | 否 | 状态筛选:`DRAFT` / `VETTING` / `ACTIVE` / `SUSPENDED` / `BLACKLIST` / `ARCHIVED` |
| typeCode | String | 否 | 生效供应商类型字典值,最长 64 |
| creditLevel | String | 否 | 信用等级:`A` / `B` / `C` / `D` |
| creatorId | Long | 否 | 创建管理员 ID |
### 4.2 GET /admin/supplier/items/list(Query,SupplierListReqVO)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| keyword | String | 否 | 同 4.1 |
| status | String | 否 | 同 4.1 |
| typeCode | String | 否 | 同 4.1 |
| resourceModule | String | 否 | 设置供应商场景的资源模块;必须与 `resourceId` 同时提供 |
| resourceId | Long | 否 | 设置供应商场景的资源 ID;必须与 `resourceModule` 同时提供 |
| limit | Integer | 否 | 最大返回条数,默认 50,最大 200 |
**本次入参无任何变化**,以上仅为完整契约内联。
## 5. 出参字段
- `/page` 返回 `PageResult<SupplierListItemRespVO>`:`{ records: [], total, page, pageSize }`
- `/list` 返回 `List<SupplierListItemRespVO>`(数组)
`SupplierListItemRespVO` 行字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| supplierId | Long(String) | 供应商 ID(雪花 ID,JSON 序列化为字符串) |
| supplierNo | String | 供应商业务编码,创建草稿时按 `SUP{supplierId}` 生成且全生命周期不变 |
| fullName | String | 供应商法定全称 |
| shortName | String | 供应商业务简称 |
| types | Array | 供应商类型列表:`{ typeCode, typeName, isPrimary }` |
| status | String | 当前生命周期状态(枚举值见 §6) |
| statusName | String | 当前对外展示状态;企微审批在途时统一为「审核中」 |
| creditLevel | String | 当前信用等级 |
| totalScore | BigDecimal | 当前综合评分 |
| activeAccountCount | Integer | 未删除且状态为 ACTIVE 的收款账户数量 |
| **contactPhone** | **String** | **本次新增:公司联系电话完整原值(不脱敏),仅供管理后台授权场景展示;明文优先取值,无明文时回退历史密文解密值,可为 null** |
| createTime | LocalDateTime | 供应商创建时间 |
| updateTime | LocalDateTime | 供应商当前并发版本(乐观锁用) |
> `balance` 曾随本 PR 短暂加入,已被 #7291 移除,**当前响应不含 `balance`**。
## 6. 枚举 / 数据字典
`status`(供应商生命周期状态):
| 值 | 含义 |
|---|---|
| DRAFT | 草稿 |
| VETTING | 审核中 |
| ACTIVE | 正常合作 |
| SUSPENDED | 暂停合作 |
| BLACKLIST | 黑名单 |
| ARCHIVED | 已归档 |
`creditLevel`:`A` / `B` / `C` / `D`。`typeCode` 走供应商类型数据字典。
## 7. 错误码
无新增错误码。沿用现有通用错误:
| 码 | 含义 | 触发场景 |
|---|---|---|
| 401 | 未登录或登录已过期 | 未携带 / 过期 JWT |
| 400 | 参数校验失败 | 如 `供应商状态不合法`、`搜索关键字长度不能超过500`、`limit最大为200` 等 |
## 8. 示例
### 8.1 典型成功(分页)
```http
GET /admin/supplier/items/page?pageNo=1&pageSize=10&status=ACTIVE
```
```json
{
"code": 200,
"message": "成功",
"data": {
"records": [
{
"supplierId": "2084636804090089473",
"supplierNo": "SUP2084636804090089473",
"fullName": "示例供应商有限公司",
"shortName": "示例供应商",
"types": [{"typeCode": "SCENIC", "typeName": "景区", "isPrimary": true}],
"status": "ACTIVE",
"statusName": "正常合作",
"creditLevel": "A",
"totalScore": 92.50,
"activeAccountCount": 1,
"contactPhone": "0471-1234567",
"createTime": "2026-08-01 10:00:00",
"updateTime": "2026-09-06 18:00:00"
}
],
"total": 1,
"page": 1,
"pageSize": 10
},
"success": true
}
```
### 8.2 边界(有界列表 + 电话为空)
```http
GET /admin/supplier/items/list?limit=50
```
```json
{
"code": 200,
"message": "成功",
"data": [
{
"supplierId": "2084636804090089473",
"supplierNo": "SUP2084636804090089473",
"fullName": "示例供应商有限公司",
"shortName": null,
"types": [],
"status": "DRAFT",
"statusName": "草稿",
"creditLevel": null,
"totalScore": null,
"activeAccountCount": 0,
"contactPhone": null,
"createTime": "2026-09-06 17:00:00",
"updateTime": "2026-09-06 17:00:00"
}
],
"success": true
}
```
供应商未填写公司联系电话时 `contactPhone` 为 `null`,前端选择器按空值兜底展示。
### 8.3 业务失败(未认证)
```http
GET /admin/supplier/items/page?pageNo=1&pageSize=10 # 不带 JWT
```
```json
{"code": 401, "message": "未登录或登录已过期", "data": null, "success": false}
```
## 9. 业务边界
- 适用:管理后台供应商列表页、财务域弹窗选供应商(应付款 / 业务外收支 / 往来账 / 收票)。
- 不适用:小程序端(`/mp/*` 无此接口);对外展示场景禁止直接透出 `contactPhone` 原值。
- `contactPhone` 是公司联系电话(非联系人手机号),完整原值不脱敏,仅限管理后台授权角色可见;Service 层已做角色与列表权限校验。
- 弹窗选供应商推荐用 `/page`(分页搜索形态);`/list` 最多返回 200 条,适合小数据集选择器。
## 10. 修改前后对比
| 项 | 修改前 | 修改后(当前线上,含 #7291 后续清理) |
|---|---|---|
| 出参字段 | 无 `contactPhone`、无 `balance` | 新增 `contactPhone`(`balance` 已于 #7291 移除,不含) |
| 入参 | — | 不变 |
| 行为 | 财务弹窗选供应商看不到联系电话 | 可直接展示联系电话,无需再调详情接口 |
## 11. 影响评估 / 回滚
- **是否破坏向后兼容**:否。仅新增字段,旧前端不读 `contactPhone` 不受影响。
- **前端是否必须同步上线**:否。财务域弹窗需要联系电话时才接入。
- **回滚方案**:后端回退 PR #7202 对应提交即恢复无 `contactPhone` 的旧响应;前端已接入时同步摘除列展示即可。
## 12. 注意事项
- **不要消费 `balance`**:本 PR 曾短暂引入,已由 #7291(2026-09-07)移除,以同目录 `07_7291_供应商与结算信息移除历史字段-修改接口-管理后台.md` 为准。
- `supplierId` 为雪花 ID,JSON 中是字符串,前端按字符串处理避免精度丢失。
- `contactPhone` 明文原值属于敏感信息,仅限管理后台授权场景展示,禁止外传到 C 端或日志。
## 13. 关联 / 联系人
- Issue:https://git.1814.love:8443/wx/HL/issues/7199
- PR:https://git.1814.love:8443/wx/HL/pulls/7202
- Commit:https://git.1814.love:8443/wx/HL/commit/8ff8a2462801c81400a25d0c8046cd401ac75ff6
- 后续移除 balance 的 PR:https://git.1814.love:8443/wx/HL/pulls/7291
- 后端负责人:腰苏图(yst)
@@ -0,0 +1,522 @@
---
schema: "hl-changelog/v2"
ticket: "7204"
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: "2026-09-06"
updated_at: "2026-09-06"
base: "dev-v3"
status_note: "后端已合并(PR #7207,合并提交 7c705b80)并于 2026-09-06 19:54 部署测试服 dev-v3,网关复测通过(零指派改前 DOING → 改后 TODO);前端无需改代码,建议补芯片图例/tooltip。"
---
# 团期模块:看板导/摄芯片聚合态口径调整(零指派返未开始)
## ⚠️ 关键变化
**只改一条聚合规则,不改任何字段名、枚举值集或户级明细。** 导游(guide)/ 摄影(photo)两枚芯片的整团聚合态 `aggregateStatus`(同一份值也就是 GB-ADM-001 `records[].chips.guide` / `chips.photo`):
| 计入户(needsIt=true)情况 | 改前 | 改后 |
|---|---|---|
| 一户都没指派(doneCount = 0,totalCount > 0) | `DOING`(橙「进行中」) | **`TODO`(灰「未开始」)** |
| 部分已指派(0 < doneCount < totalCount) | `DOING` | `DOING`(不变) |
| 全部已指派(doneCount = totalCount > 0) | `DONE` | `DONE`(不变) |
| 没有计入户(totalCount = 0,全部免闸或无子订单) | `TODO` | `TODO`(不变) |
**根因**:户级 `guide_status / photographer_status` 为 NULL(从未指派)时读侧归一为 `PENDING`「待指派」,而 `PENDING` 又在导/摄的「进行中」集合里,于是只要有户需要导游就恒 `DOING`。wx 2026-09-06 拍板改为与房/车「未提需求=灰、提了需求=橙、配完=绿」同一直觉。
**前端影响**:hl-ui `src/views/order-v2/batch/_shared/batchLifecycle.js` `chipAggState` 已把 `TODO` 映射为灰、`DOING` 橙、`DONE` 绿、`ERROR` 红,**无需改代码**;效果是「一户都没指派」的团期导/摄从橙变灰。建议:芯片区补图例或 tooltip(灰=未开始、橙=进行中、绿=已完成、红=异常)——运营把橙读成「配置完了」是本单起因。
房 / 车 / 约 / 保四芯片、GB-ADM-090~095 户级明细的 `items[].status`(`NONE / PENDING / DONE`)与 `statusText`(无需 / 待指派 / 已指派)**均不变**。
---
## 一、背景
### 现象
2026-09-06 测试服「团期订单」看板,产品「冻干粉发短信给」班期 2026-10-01 行,只有 1 个需要导游/摄影但从未指派的活跃子订单,「导 / 摄」芯片却是橙色(`DOING`),运营误读为「已配置完」。
### 调用链
1. hl-ui `src/stores/orderV2Batch.js:93-97` `getGroupBatchPage()` → `GET /v3/admin/order/group-batch`(GB-ADM-001)→ `records[].chips.guide / photo`
2. hl-ui 点击芯片 → `getGroupBatchChip(groupBatchId, 'guide'|'photo')` → `GET /v3/admin/order/group-batch/{groupBatchId}/chips/guide|photo`(GB-ADM-092/093)→ `aggregateStatus`
3. hl-order-service-v3:两处共用 `order/groupbatch/helper/GroupBatchChipResolver.resolveAggregateStatus`;本次在「失败→ERROR」「全完成→DONE」之后、扫描进行中值之前,对 GUIDE / PHOTOGRAPHER 增加 `doneCount == 0` 返 `TODO` 的分支
4. 硬规则不变:流团(CANCELLED)六芯片恒 `TODO`;已返团(REVIEWING / SETTLED)房车导摄恒 `DONE`;约 / 保在房车导摄四项全 `DONE` 前恒 `TODO`
### 地面真相(测试服 dev-v3,团期 groupBatchId=2096412454643802114)
| 时点 | 子订单 | `chips/guide` 响应要点 |
|---|---|---|
| 改前 17:20(部署前) | 1 户,needs_guide=1,guide_status=NULL | `aggregateStatus="DOING"`,totalCount=1,doneCount=0,items[0].status=`PENDING`「待指派」 |
| 改后 19:58(部署 7c705b80 后,另造 1 户零指派测试单 2096568044598820866,测完已取消) | 同上 | `aggregateStatus="TODO"`,totalCount=1,doneCount=0,items[0].status=`PENDING`「待指派」(户级不变) |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期看板分页(GB-ADM-001) | GET | `/v3/admin/order/group-batch` | 响应值语义变化 | `records[].chips.guide` / `chips.photo` 零指派由 `DOING` 改为 `TODO` |
| 2 | 导游芯片逐户明细(GB-ADM-092) | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/guide` | 响应值语义变化 | `aggregateStatus` 零指派由 `DOING` 改为 `TODO`;`items[]` 不变 |
| 3 | 摄影芯片逐户明细(GB-ADM-093) | GET | `/v3/admin/order/group-batch/{groupBatchId}/chips/photo` | 响应值语义变化 | 同上 |
---
## 三、接口详情
### 1. 团期看板分页 `GET /v3/admin/order/group-batch`
**VO**: `GroupBatchListReqVO → Result<PageResult<GroupBatchPageItemRespVO>>`
#### 使用场景
团期看板列表(hl-ui `getGroupBatchPage()`);本次只有 `records[].chips.guide` / `chips.photo` 的取值语义变化,其余字段与参数不变。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | query | Long(字符串) | 否 | 雪花 ID | 按产品筛选 |
| opsStage | query | String | 否 | RECRUIT / FORMED / PENDING_TRIP / TRAVELLING / AUDITING / CHECKED / DISBANDED | 七桶筛选 |
| month | query | String | 否 | yyyy-MM | 出发月份 |
| keyword | query | String | 否 | 已转义 | 班期编号 / 名称模糊 |
| batchStatus | query | String | 否 | 团期状态码 | 可选 |
| deadlineFrom / deadlineTo | query | String | 否 | yyyy-MM-dd | 报名截止日区间 |
| pageNo | query | Integer | 否 | 默认 1 | 页码 |
| pageSize | query | Integer | 否 | 默认 20,最大 100 | 每页条数 |
#### 出参 `Result<PageResult<GroupBatchPageItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.records[] | Array | 团期行(分页容器字段为 `records / total / page / pageSize`) |
| data.records[].groupBatchId | String(Long) | 团期主订单 ID |
| data.records[].productBatchId | String(Long) | product 侧班期 ID |
| data.records[].batchStatus / batchStatusName | String | 团期状态码 / 中文名 |
| data.records[].orderCount | Integer | 活跃子订单数 |
| data.records[].chips | Object | 六键固定:`hotel / vehicle / guide / photo / contract / insurance`,值为 `TODO / DOING / DONE / ERROR` 字符串;无活跃子订单时整个 `chips` 为 `null` |
| data.records[].chips.guide | String | **本次变化**:零指派 `TODO`,部分指派 `DOING`,全部指派 `DONE`(导/摄没有失败值,不会出现 `ERROR`) |
| data.records[].chips.photo | String | 同 `chips.guide` |
| 其余字段 | — | 不变(enrolledRooms / remainRooms / receivableAmount / receivedAmount / departDate / endDate …) |
#### 请求示例
```http
GET /v3/admin/order/group-batch?productId=2044306857534636034&pageNo=1&pageSize=20 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
改后(2026-09-06 19:58 实测,该行 1 户零指派、未提房车需求):
```json
{
"code": 200,
"message": "成功",
"data": {
"records": [
{
"groupBatchId": "2096412454643802114",
"productBatchId": "2052935476557328386",
"productId": "2044306857534636034",
"batchName": " 没,那你",
"batchStatus": "RESOURCE_PREPARING",
"batchStatusName": "资源准备中",
"orderCount": 1,
"chips": {
"hotel": "TODO",
"vehicle": "TODO",
"guide": "TODO",
"photo": "TODO",
"contract": "TODO",
"insurance": "TODO"
}
}
],
"total": 1,
"page": 1,
"pageSize": 20
},
"success": true
}
```
改前同一场景 `chips.guide` / `chips.photo` 为 `"DOING"`。
#### 空数据 / 降级响应
没有命中团期时 `records` 为空数组;团期无活跃子订单时该行 `chips` 为 `null`(前端六芯片全灰,不要当字符串解析)。
```json
{
"code": 200,
"message": "成功",
"data": { "records": [], "total": 0, "page": 1, "pageSize": 20 },
"success": true
}
```
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"data": null,
"success": false
}
```
HTTP 始终 200,按 `code` 判断。
#### 业务边界
- 权限 `group-batch:view`
- `chips.guide` / `chips.photo` 与 GB-ADM-092/093 的 `aggregateStatus` 同源同算法,两处必然一致
- 硬规则优先:流团行六芯片恒 `TODO`;已返团行房车导摄恒 `DONE`;约/保在房车导摄四项全 `DONE` 前恒 `TODO`
### 2. 导游芯片逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/guide`
**VO**: `Result<GroupBatchChipDetailRespVO>`(无请求 VO,仅路径参数)
#### 使用场景
看板行点击「导」芯片时拉逐户明细(hl-ui `getGroupBatchChip(id, 'guide')`);头部 `aggregateStatus` 即看板 `chips.guide`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | path | Long(字符串) | 是 | 团期主订单 ID | 不是 product 侧 productBatchId |
#### 出参 `Result<GroupBatchChipDetailRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.batchId | String(Long) | 团期主订单 ID |
| data.chipLabel | String | 「配导游」 |
| data.aggregateStatus | String | **本次变化**:`TODO`(零指派)/ `DOING`(部分指派)/ `DONE`(全部指派);导/摄不会出现 `ERROR` |
| data.totalCount | Integer | 计入户数(needsIt=true 的活跃子订单) |
| data.doneCount | Integer | 已指派户数 |
| data.items[] | Array | 逐户明细(**不变**) |
| data.items[].orderId / orderNo / contactName / peopleCount | String / String / String / Integer | 户标识与人数 |
| data.items[].status | String | `NONE`(免闸)/ `PENDING`(待指派)/ `DONE`(已指派) |
| data.items[].statusText | String | 无需 / 待指派 / 已指派 |
| data.items[].needsIt | Boolean | 是否需要导游 |
| data.items[].updateTime | String | 恒 null(无独立时间列) |
#### 请求示例
```http
GET /v3/admin/order/group-batch/2096412454643802114/chips/guide HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
改后(2026-09-06 19:58 实测):
```json
{
"code": 200,
"message": "成功",
"data": {
"batchId": "2096412454643802114",
"chipLabel": "配导游",
"aggregateStatus": "TODO",
"totalCount": 1,
"doneCount": 0,
"items": [
{
"orderId": "2096568044598820866",
"orderNo": "HL20260906195548607",
"contactName": "7204复测",
"peopleCount": 1,
"status": "PENDING",
"statusText": "待指派",
"needsIt": true,
"updateTime": null
}
]
},
"success": true
}
```
改前(17:20 实测,同一团期另一户零指派)除 `aggregateStatus="DOING"` 外结构相同。
#### 空数据 / 降级响应
团期无活跃子订单或全部免闸:`totalCount=0`、`doneCount=0`、`aggregateStatus="TODO"`;`items[]` 为全部活跃户(免闸户 `status="NONE"`),无活跃户时为空数组。
```json
{
"code": 200,
"message": "成功",
"data": {
"batchId": "2096412454643802114",
"chipLabel": "配导游",
"aggregateStatus": "TODO",
"totalCount": 0,
"doneCount": 0,
"items": []
},
"success": true
}
```
#### 错误响应
```json
{
"code": 589500,
"message": "团期不存在",
"data": null,
"success": false
}
```
另:`589507` 无操作权限。
#### 业务边界
- 免闸户(needsIt=false)不计入 `totalCount / doneCount`,`status="NONE"`
- `aggregateStatus` 只读派生,不落库;在团期详情「配置导游」后扇出到全部子订单即 `DONE`
### 3. 摄影芯片逐户明细 `GET /v3/admin/order/group-batch/{groupBatchId}/chips/photo`
**VO**: `Result<GroupBatchChipDetailRespVO>`(无请求 VO,仅路径参数)
#### 使用场景
看板行点击「摄」芯片时拉逐户明细(hl-ui `getGroupBatchChip(id, 'photo')`);头部 `aggregateStatus` 即看板 `chips.photo`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | path | Long(字符串) | 是 | 团期主订单 ID | 同上 |
#### 出参 `Result<GroupBatchChipDetailRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.chipLabel | String | 「配摄影」 |
| data.aggregateStatus | String | **本次变化**:规则同导游 |
| data.totalCount / doneCount | Integer | 同导游 |
| data.items[] | Array | 结构与导游接口完全相同,`items[].needsIt` 取需要摄影 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/2096412454643802114/chips/photo HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
改后(19:58 实测):
```json
{
"code": 200,
"message": "成功",
"data": {
"batchId": "2096412454643802114",
"chipLabel": "配摄影",
"aggregateStatus": "TODO",
"totalCount": 1,
"doneCount": 0,
"items": [
{
"orderId": "2096568044598820866",
"orderNo": "HL20260906195548607",
"contactName": "7204复测",
"peopleCount": 1,
"status": "PENDING",
"statusText": "待指派",
"needsIt": true,
"updateTime": null
}
]
},
"success": true
}
```
#### 空数据 / 降级响应
同导游接口:无计入户时 `totalCount=0`、`aggregateStatus="TODO"`。
```json
{
"code": 200,
"message": "成功",
"data": { "batchId": "2096412454643802114", "chipLabel": "配摄影", "aggregateStatus": "TODO", "totalCount": 0, "doneCount": 0, "items": [] },
"success": true
}
```
#### 错误响应
```json
{
"code": 589500,
"message": "团期不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 同导游接口
---
## 四、契约约束与正确调用方式
> 三个接口均为只读 GET,无请求体;本节写的是**前端消费聚合态的规则**。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload / 处理 |
|------|---------|
| ✅ 按四值渲染 | `chips.guide` ∈ `TODO / DOING / DONE / ERROR` → 灰 / 橙 / 绿 / 红;未知值按灰 |
| ✅ `chips` 为 null | 六芯片全灰,不请求 090~095 明细 |
| ✅ 弹层头部与看板一致 | `chips/guide.aggregateStatus` 与 `records[].chips.guide` 同源,勿各算各的 |
| ❌ 用户级 `items[].status` 反推整团态 | 户级 `PENDING`「待指派」不等于整团进行中:零指派时整团是 `TODO` |
| ❌ 把 `DOING` 当「已配置」 | `DOING` 只表示部分户已指派 |
### 切换状态时的必要动作
无写接口;指派动作走团期详情「配置导游 / 摄影」,完成后重新拉 GB-ADM-001 或 090~095 即可看到 `DONE`。
---
## 五、数据库行为
本次无写操作、无表变更。`aggregateStatus` / `chips.*` 为读时派生(`GroupBatchChipResolver` 内存计算),不落库;户级来源仍是 `order_main.guide_status / photographer_status`(取值只有 `DONE` 或 NULL)。
| 计入户情况 | 派生结果 |
|------|-----------------|
| doneCount = 0,totalCount > 0 | `TODO` |
| 0 < doneCount < totalCount | `DOING` |
| doneCount = totalCount > 0 | `DONE` |
| totalCount = 0 | `TODO` |
---
## 六、边界行为
- 未登录 → 网关 401;无 `group-batch:view` → `589507`
- 团期不存在 → `589500`
- 流团团期 → 六芯片恒 `TODO`(硬规则 1);已返团团期 → 房车导摄恒 `DONE`(硬规则 2)
- 约 / 保在房车导摄四项未全 `DONE` 前恒 `TODO`(硬规则 3,本次导/摄零指派落 `TODO` 后约/保同样保持 `TODO`)
- HTTP 始终 200,按 `code` 判断
## 六.5、枚举 / 数据字典
### aggregateStatus / chips.*(`com.hulalv.order.groupbatch.helper.GroupBatchChipResolver` 常量 `AGGREGATE_TODO / AGGREGATE_DOING / AGGREGATE_DONE / AGGREGATE_ERROR`)
| 值 | 含义 | 前端色 |
|---|---|---|
| `TODO` | 未开始(导/摄:零指派;房/车:未提需求) | 灰 |
| `DOING` | 进行中(导/摄:部分指派;房/车:需求已提交/处理中/待审核) | 橙 |
| `DONE` | 已完成 | 绿 |
| `ERROR` | 异常(房/车驳回、约作废、保失败;导/摄不会出现) | 红 |
### items[].status(导/摄户级,`GroupBatchChipResolver.readGuideStatus`)
| 值 | 含义 | statusText |
|---|---|---|
| `NONE` | 该户无需导游/摄影(免闸,不计入) | 无需 |
| `PENDING` | 需要且未指派 | 待指派 |
| `DONE` | 已指派 | 已指派 |
## 六.6、修改前后对比
### 字段级对比
| 字段 | 修改前 | 修改后 |
|------|--------|--------|
| `records[].chips.guide` / `chips.photo`、`aggregateStatus` 取值集 | `TODO / DOING / DONE` | 不变 |
| 零指派场景取值 | `DOING` | `TODO` |
### 行为级对比
| 场景 | 修改前 | 修改后 |
|------|--------|--------|
| 零指派(doneCount=0,totalCount=1) | `DOING`(橙) | `TODO`(灰) |
| 部分指派(doneCount=1,totalCount=2) | `DOING`(橙) | `DOING`(橙) |
| 全部指派(doneCount=2,totalCount=2) | `DONE`(绿) | `DONE`(绿) |
| 全部免闸(totalCount=0) | `TODO` | `TODO` |
| 已返团(REVIEWING / SETTLED) | `DONE`(硬规则) | `DONE` |
| 流团(CANCELLED) | `TODO`(硬规则) | `TODO` |
## 六.7、影响评估
- 向后兼容:字段名、枚举值集不变,只是零指派场景的取值变化;老前端不改也能正确渲染
- 前端是否必须同步上线:否
- 数据:无表变更、无迁移;聚合态实时派生不落库
---
## 七、不影响范围
- **仅影响**: 管理后台团期看板行「导 / 摄」芯片颜色,以及 090~095 弹层头部聚合态
- **零影响**:
- 房 / 车 / 约 / 保四芯片
- 户级明细 `items[]`(值与文案不变)
- 团期详情 `hotelReady / guideReady / photographerReady` 标志
- 指派流程、团期状态机、订单接口
- 小程序
---
## 八、测试环境已验证
真实接口输出(测试服 api.test.1814.love,2026-09-06,登录后切 ADMIN 角色):
```
GET /v3/admin/order/group-batch/2096412454643802114/chips/guide → 200, aggregateStatus=TODO, total=1, done=0, items[0]=PENDING/待指派 ✓(改前 17:20 同场景 DOING)
GET /v3/admin/order/group-batch/2096412454643802114/chips/photo → 200, aggregateStatus=TODO, total=1, done=0 ✓
GET /v3/admin/order/group-batch?productId=2044306857534636034 → 200, 该行 chips.guide=TODO, chips.photo=TODO, hotel=TODO, vehicle=TODO ✓
```
验证团期: `groupBatchId=2096412454643802114`(产品 2044306857534636034「冻干粉发短信给」,班期 2026-10-01);造的零指派测试单 2096568044598820866 已取消。
单测:`GroupBatchChipResolverTest` 30/0/0(新增零指派 / 部分指派 / 全部指派 / 免闸 / 房车不受影响五例)、`GroupBatchChipServiceTest` 14/0、`GroupBatchQueryServiceTest` 23/0、ArchTest 门禁 41/0。部署:Deploy Panel 19:54 双实例滚动完成,分支 dev-v3。
---
## 十、相关文档
- 关联 Issue: [wx/HL#7204](https://git.1814.love:8443/wx/HL/issues/7204)
- 关联 PR: [wx/HL#7207](https://git.1814.love:8443/wx/HL/pulls/7207)
- 同现场: #7188(「第N期」序号)、#7189(看板以产品全班期为基底 + 班期范围筛选)、#7190(「出行完毕」状态与八桶)
- 前端缺陷 changelog: `06_frontend_团期看板展开行子订单列表恒空-前端缺陷-管理后台.md`
## 关联 / 联系人
### 链接
- **Issue**: [#7204](https://git.1814.love:8443/wx/HL/issues/7204)
- **PR**: [#7207](https://git.1814.love:8443/wx/HL/pulls/7207)
- **Merge commit**: [7c705b80](https://git.1814.love:8443/wx/HL/commit/7c705b808723bc415b5dc99d0535d0521668f90f)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,249 @@
---
schema: "hl-changelog/v2"
ticket: "frontend"
title: "团期产品新建订单向导创单漏传 productBatchId"
consumer: "admin"
author: "wx(GIT)"
change_type: "前端缺陷"
backend_status: "not_required"
gateway_status: "not_required"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "03a985b6"
target_release: ""
verified_at: "2026-09-06"
status_note: "后端已随 #7135/#7159 加固并部署测试服 dev-v3、网关复测 15/15 通过(新增 581055/581056/581057、修复 581034 人数预查,合并提交 977be08e);前端可据此联调:在 order-v2/new 向导创单 payload 补 productBatchId(仅 GROUP 产品,且必须是该产品的班期),并修团期看板「新增子订单」入口带团期上下文。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 团期订单:新建订单向导对 GROUP 产品创单漏传 productBatchId(前端缺陷)
## ⚠️ 关键变化
**现象**:团期(GROUP)产品在管理后台新建订单向导完成表单、点"确认创建"后报 HTTP 200 `code=581026 message='团期产品必须选择团期'`,无法创建。
**根因**:前端 `src/views/order-v2/new/index.vue` `handleCreate()` 组装的创单 payload 漏传 `productBatchId` 字段。而向导其实已在报价阶段成功拿到班期 ID(存于 `pricingContext.batchId`),报价后端响应 ¥5,850 正确,但创建时没把它放入请求体。
**结论**:前端补 `productBatchId` 是主修复。**后端已随 #7135/#7159 同步加固**(不再是「零改动」):现在仅 GROUP 产品可传 `productBatchId`,CORE/CUSTOM 传了会被拒(581056);班期必须属于所选产品(否则 581055);`tierSeq` 必须在产品配置内(否则 581057);人数超班期剩余名额会被拒(581034,此前因字段名漂移长期失效,#7159 修复)。因此前端务必**只对 GROUP 产品**传 `productBatchId`,且取自该产品价格日历的 `items[].batchId`。
---
## 一、背景
### 复现步骤
页面:管理后台 `192.168.100.219:9527/order-v2/new`(hl-ui 路由 `orderV2NewRoute`,`src/router/routes.js:166-175`,四步向导:选主题 → 选产品/档位 → 基本信息 → 确认创建)。
1. **Step 0 选主题**:「阿斯蒂芬撒点」(GROUP 产品线 `lineId=2044248925572919297`)
2. **Step 1 选产品**:「冻干粉发短信给」(`productId=2044306857534636034`,GROUP,`tierSeq=1` 档位「轻奢」)
3. **Step 2 基本信息**:出发日期 2026-10-01,成人 1 名、儿童 1 名 → 系统报价 ¥5,850.00(其中单房差 +¥500.00)
4. **Step 3 确认创建**:填客户姓名、手机号 → 点「确认创建订单」
5. **结果**:Toast 弹窗「团期产品必须选择团期」,订单创建失败
### 调用链
1. hl-ui `src/views/order-v2/new/index.vue:405-450` `handleCreate()` 组装 payload → `createOrder(payload)`
2. `src/api/orderV2.js:79-81` `createOrder(data)` = `http.post('/v3/admin/order', data, V3)`
3. hl-gateway `application.yml:246-249` 路由 `/v3/admin/**` → `lb://hl-order-service-v3`
4. hl-order-service-v3 `OrderController.java:86-91` 接收 → `orderService.createOrder(req, ...)`
5. `OrderService.java:630` 直接 `ctx.setProductBatchId(req.getProductBatchId())`,无兜底反查
6. `OrderService.java:635` `strategy.validate(ctx, productDetail)`
7. `GroupOrderStrategy.java:46-49` 校验失败:`if (ctx.getProductBatchId() == null) throw new BusinessException(OrderCoreErrorCode.GROUP_BATCH_REQUIRED)`
8. `OrderCoreErrorCode.java:102-103` 返回 `581026`「团期产品必须选择团期」
### 地面真相(测试库验证)
| 属性 | 值 |
|------|-----|
| 主题(product_line) | 阿斯蒂芬撒点 `2044248925572919297` |
| 产品(product) | 冻干粉发短信给 `2044306857534636034`,`product_type=GROUP` |
| 档位(tier) | `tierSeq=1` 轻奢 |
| 班期(group_tour_batch) | `batch_id=2052935476557328386`,`batch_no=Q202610012052935476548939777`,`departure_date=2026-10-01`,`batch_status=ENROLLING`,`end_date=2026-10-03`,`adult_price=2925`,`child_price=2425`,`single_room_diff=500` |
| 预计金额 | 2925 + 2425 = 5,350,加单房差 500 = **5,850** ✓ (与前端报价一致,证明向导已成功拿到班期信息计价) |
---
## 二、变更接口清单
| # | 接口名 | 方法 | 网关路径 | 前端函数 | 变更 | 说明 |
|---|--------|------|---------|---------|------|------|
| 1 | 创建订单 | POST | `/v3/admin/order` | `createOrder()` | **调用方式修正** | GROUP 产品**必传** `productBatchId`;CORE/CUSTOM 必须**不传** |
| 2 | 统一价格日历 | GET | `/admin/product/item/{productId}/pricing-calendar` | `getUnifiedPricingCalendar()` | 无变更 | GROUP 时返回班期列表,`items[].batchId` 即为所需班期 ID |
| 3 | 报价 | POST | `/admin/product/item/{productId}/quote` | `quoteProduct()` | 无变更 | GROUP 时现已传 `batchId`,保持即可 |
---
## 三、接口详情
### 1. 创建订单 `POST /v3/admin/order`
**VO**: `OrderCreateReqVO → Result<OrderCreateRespVO>`
#### 使用场景
新建订单向导 Step 3 确认创建时调用,后端落库并触发团期聚合等副作用。
#### 入参
| 字段 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|
| productId | String(Long 值) | ✓ | 正整数 | 产品 ID |
| tierSeq | Integer | ✓ | 1~N,且必须在产品已配档位内 | 档位序号;不在产品 tierPrices ∪ tiers 配置内 → 581057(#7135 起全类型校验;产品未配任何档位时不拦) |
| departureDate | String (yyyy-MM-dd) | ✓ | 不早于今天 | 出发日期;GROUP 下服务端会用班期权威出发日落库,但仍必填;CORE/CUSTOM 按字面值 |
| adultCount | Integer | ✓ | ≥1 | 成人数 |
| childCount | Integer | — | ≥0,默认 0 | 儿童数(6~12 岁) |
| youngChildCount | Integer | — | ≥0,默认 0 | 小童数(2~5 岁) |
| babyCount | Integer | — | ≥0,默认 0 | 婴儿数;GROUP 下服务端强制置为 0,不计入名额和价格 |
| customerName | String | ✓ | 非空,≤50 | 客户姓名 |
| customerPhone | String | ✓ | 格式 `^1[3-9]\d{9}$` | 手机号明文 |
| customerRemark | String | — | ≤500 | 订单备注 |
| createSource | String | — | ≤20,默认 CONSULTANT | 创建来源标记 |
| **productBatchId** | **String(Long 值)** | **GROUP ✓ / CORE、CUSTOM ✗** | 仅 GROUP 可传且必传;班期须属于本 productId | **GROUP 产品必传班期 ID**(来自价格日历 `items[].batchId`),**非 GROUP 产品禁止传递**(CORE/CUSTOM 带了 → 581056);班期 productId 须等于请求 productId(否则 581055);建议用字符串如 `"2052935476557328386"`(JSON Number 亦可,后端接受,但字符串防前端精度丢失) |
| roomCount | Integer | — | ≥1,默认 1 | 房间数;超过班期剩余房间数报 `581031` |
| tags | Array<String> | — | — | 订单标签 |
#### 出参 `Result<OrderCreateRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| code | Integer | 200 = 成功,否则为业务错误码 |
| data.id | String | 订单 ID(雪花 Long)|
| data.orderNo | String | 订单编号(HL+时间+序号,如 HL20260906093733163) |
| data.orderStatus | String | 订单状态(创建初态 = PENDING_PAY) |
| data.totalAmount | String | 订单总金额,格式 decimal(12,2) |
| data.depositAmount | String | 订金额 |
| data.departureDate | String | 出发日期(yyyy-MM-dd);GROUP 以班期为准,回显班期出发日 |
| data.returnDate | String | 归程日期(yyyy-MM-dd);根据 tripDays = endDate - departureDate + 1 推算 |
| data.groupBatchName | String/null | 团批次名称(当前实现为 null) |
| data.tierName | String/null | 档位名称(#7135 起 GROUP 也回显来自 tiers 配置的档位名;产品未配档位时为 null) |
| data.consultantId | String | 发单定制师 ID(创建时落 1001) |
#### 请求示例(GROUP 产品,正例)
```json
{
"productId": "2044306857534636034",
"tierSeq": 1,
"departureDate": "2026-10-01",
"adultCount": 1,
"childCount": 1,
"youngChildCount": 0,
"babyCount": 0,
"customerName": "张三",
"customerPhone": "13800009601",
"createSource": "CONSULTANT",
"customerRemark": "团期测试订单",
"productBatchId": "2052935476557328386",
"roomCount": 1
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"id": "2096412454488612866",
"orderNo": "HL20260906093733163",
"orderStatus": "PENDING_PAY",
"totalAmount": "5850.00",
"depositAmount": "1000.00",
"departureDate": "2026-10-01",
"returnDate": "2026-10-03",
"groupBatchName": null,
"tierName": null,
"consultantId": "1001"
},
"success": true
}
```
#### 错误响应
| code | message | 触发条件 |
|------|---------|---------|
| 581026 | 团期产品必须选择团期 | productBatchId 为 null 且 productType = GROUP |
| 581027 | 团期信息获取失败 | Feign 调班期服务异常 |
| 581028 | 团期状态不允许报名 | 班期状态不在可订范围 |
| 581029 | 团期已过报名截止日 | 当前日期 > enrollment_deadline |
| 581031 | 团期剩余房间不足 | roomCount > 班期剩余房间数 |
| 581034 | 团期剩余名额不足 | 成人+儿童+小童 > 班期剩余名额(#7159 修复:此前字段名漂移致此校验长期失效;人数不限的班期不拦) |
| 581055 | 所选团期不属于该产品 | 班期 productId ≠ 请求 productId(跨产品串号;#7135 新增) |
| 581056 | 非团期产品不能指定团期 | CORE/CUSTOM 请求带了 productBatchId(#7135 新增) |
| 581057 | 所选档位不存在 | tierSeq 不在产品 tierPrices ∪ tiers 配置内(#7135 新增;产品未配档位时不拦) |
#### 业务边界
- 后端已校验 tierSeq 存在性(581057)与班期归属产品(581055);GROUP 出发日/返团日以班期为准回显(#7135)
- babyCount 强制 0,不参与计价
- returnDate 按班期 tripDays 计算
- 创建后触发 staff 分配、group_batch 懒建
---
### 2. 统一价格日历 `GET /admin/product/item/{productId}/pricing-calendar`
GROUP 时返回班期列表,items[] 每条含:
- date、batchId(JSON Number,**前端必须 String() 转换**)
- batchNo、batchName、batchStatus、endDate、enrollmentDeadline
- maxRooms、bookedRooms、remainParticipants(null 表不限)
- 价格字段、sellable(仅 ENROLLING 为 true)
---
### 3. 报价 `POST /admin/product/item/{productId}/quote`
GROUP 时入参需 batchId,出参 grandTotal、singleRoomSurcharge 等。
---
## 四、前端修复要点与自测清单
### 修复要点
1. `index.vue handleCreate()`:GROUP 时 `payload.productBatchId = String(pricingContext.batchId)`
2. Step3 确认前二次校验 pricingContext 非空且与当前表单一致
3. Step3 展示 batchNo/batchName
4. 团期看板 onAddSub() 带深链 ?productBatchId&departureDate
### 自测清单
- ✓ GROUP 选日期 → 报价 → 创建成功
- ✓ CORE 创建不含 productBatchId
- ✓ 日期未选时按钮拦截
---
## 五、验证证据
### 场景矩阵(测试服 2026-09-06)
14 场景通过,1 场景失败(S8),10 单已清理。
### DB 落库
orderNo HL20260906093733163,product_batch_id=2052935476557328386
---
## 六、影响与不影响范围
**不影响**:
- 正常 CORE/CUSTOM 创单(不带 productBatchId):完全不变
- 订单读接口(列表 / 详情 / 编辑):不变
- 前端改动只涉及 `order-v2/new` 向导创单 payload 与团期看板「新增子订单」入口
**受后端加固影响(前端需知晓)**:
- 小程序端 `POST /v3/mp/order` 与管理端共用内核,581055/581056/581057/581034 同样生效,但小程序请求契约不变(`groupBatchId` 语义不变)
- CORE/CUSTOM 若误带 productBatchId:此前静默落库,现在直接 581056 拒单
---
## 七、关联
- 前端向导既有工单 #7083(已合);后端判团字段透出 #7142、看板 productId 深链 #7143(均已合 dev-v3)
- 后端加固 #7135(收紧 productBatchId 校验:581055/581056/581057、日期按班期)+ #7159(人数预查改读 remainingParticipants 使 581034 生效),随 PR #7169 合入 dev-v3
- 证据:gateway-verify.txt、gateway-matrix.txt
@@ -0,0 +1,260 @@
---
schema: "hl-changelog/v2"
ticket: "frontend"
title: "团期看板展开行子订单列表恒空"
consumer: "admin"
author: "wx(GIT)"
change_type: "前端缺陷"
backend_status: "not_required"
gateway_status: "not_required"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "7d652bdc"
target_release: ""
verified_at: "2026-09-06"
status_note: "后端 GB-ADM-003 已在测试服 dev-v3 网关实测返回 1 条活跃子订单;纯前端门控 bug:store.currentGroupBatchId 只在详情页 fetchDetail 赋值,看板展开走的 fetchBatchOrders 不赋值,PeriodRow 子订单列表恒空。首选按 groupBatchId 键缓存(PeriodRow 原注释的设计),并把 ordersError 显示出来。"
updated_at: "2026-09-06"
base: "dev-v3"
---
# 团期订单:看板展开行子订单列表恒空(前端缺陷)
## ⚠️ 关键变化
**现象**:管理后台「团期订单」看板,产品「冻干粉发短信给」班期 2026-10-01「没,那你」这一行显示「已订房 1/8 · 剩 7 · 应收 5850.00 · 已收 5850.00」,点击展开却显示「本期暂无子订单。」,也没有加载态。
**根因(hl-ui `gitea/v2.1` 提交 e78ba5be)**:`src/stores/orderV2Batch.js:147-166` `fetchBatchOrders(groupBatchId)` 请求成功后只写 `batchOrders`,**不写 `currentGroupBatchId`**;该字段全文件唯一赋值点是 `:129-131` `fetchDetail()`(团期详情页才调)。而 `src/views/order-v2/batch/components/PeriodRow.vue:233-235` 的子订单列表和 `:236-238` 的加载态都门控于 `String(store.currentGroupBatchId) === groupBatchId.value`。看板页展开只走 `onToggle → store.fetchBatchOrders`(`:240-245`),`currentGroupBatchId` 保持初始 `''`,门控永远不等 → `orders` 恒 `[]` → `SubOrderTable.vue:4-7` `v-if="!subs.length"` 渲染「本期暂无子订单。」。只有在同一 SPA 会话里先进过**该团期**的详情页(`fetchDetail` 把 `currentGroupBatchId` 设成它)再回看板展开同一行,才碰巧显示正确;刷新页面后直接展开必现。
**结论**:后端零改动(GB-ADM-001 / GB-ADM-003 实测正确)。前端修法首选**按 `groupBatchId` 键缓存子订单**(`PeriodRow.vue:232` 原注释「按 groupBatchId 缓存」就是这个设计,现实现漂移成了借用详情页的 `currentGroupBatchId` 单值门控);同时把 `store.ordersError` 显示出来,否则接口 4xx/5xx 时也会伪装成「暂无子订单」。
行上的「1/8」「5850」和「房/导/摄」橙色芯片**都是当时那条被挡住的活跃子订单 HL20260906093733163 的真实状态**,不是看板算错:已订房按「1 单 = 1 房」持久计数器算(`order_group_batch.enrolled_rooms`),产品侧「线下占位房数」不参与已订房/剩余;橙 = 进行中(`batchLifecycle.js:64-70` DOING → doing),不是「配置完了」。导/摄「零指派却橙」的后端口径已另立 #7204 修正。补充说明:这条子订单本是 2026-09-06 01:37 已取消的测试单,14 时许被团期房务会话为 #7149 实测用 SQL 临时改成「定制中 / 已付订金」,18:51 已复原为已取消——因此该班期现在已订房回到 0/8、展开区显示「本期暂无子订单」是**正确**的;复现本缺陷请换任一有活跃子订单的团期(见复现步骤)。
---
## 一、背景
### 复现步骤
页面:管理后台 `192.168.100.219:9527/order-v2/batch`(hl-ui 团期看板,`src/views/order-v2/batch/index.vue`)。
1. 刷新页面(确保本会话没进过任何团期详情页)
2. 产品页签选任一**行头 `orderCount>0`** 的团期(2026-09-06 18:55 测试库可用:「测试小蒙马-多档-固定金额」的 12-20 出团「#7158验收班期」、12-27 出团「#7178验收班期」,各 1 个活跃子订单;wx 截图用的「冻干粉发短信给」10-01「没,那你」行当时也是 1 单,但那单已于 18:51 复原为已取消,现在该行是真的没有子订单)
3. 点击行左侧展开箭头
4. **结果**:展开区显示「本期暂无子订单。」,无加载态(而 `GET /v3/admin/order/group-batch/{该行 groupBatchId}/orders` 返回 ≥1 条)
5. 对照:先点「进入团期」进详情页再返回看板展开同一行 → 显示子订单(这是 bug 的偶然绕过路径,不是修复)
### 调用链
1. `PeriodRow.vue:240-245` `onToggle()` → `store.fetchBatchOrders(groupBatchId)`
2. `src/stores/orderV2Batch.js:147-166` `fetchBatchOrders` → `src/api/orderV2GroupBatch.js` `getGroupBatchOrders(id, {includeTravelers: true, includeNeeds: true, includeCancelled: false})` → `GET /v3/admin/order/group-batch/{groupBatchId}/orders`
3. hl-gateway 路由 `/v3/admin/**` → `lb://hl-order-service-v3`
4. hl-order-service-v3 `GroupBatchQueryController.listSubOrders`(`order/groupbatch/controller/admin/GroupBatchQueryController.java:96-`)→ `GroupBatchQueryService.listSubOrders`(`:253-`,缺省剔除 `CANCELLED`)→ 返回 1 条
5. store 把结果写进 `batchOrders`(`:157`),**不写 `currentGroupBatchId`**
6. `PeriodRow.vue:233-235` `orders = currentGroupBatchId === groupBatchId ? batchOrders : []` → `[]`
7. `SubOrderTable.vue:4-7` 空态
### 地面真相(测试服 dev-v3 e9a8dd27,2026-09-06 网关实测)
| 属性 | 值 |
|------|-----|
| 产品 | 冻干粉发短信给 `productId=2044306857534636034`(GROUP) |
| 班期 | product 侧 `productBatchId=2052935476557328386`(2026-10-01 出团、10-03 返团) |
| 团期主订单 | `groupBatchId=2096412454643802114`,`batchStatus=RESOURCE_PREPARING`,`maxRooms=8`,`enrolledRooms=1`,`enrolledPeople=2`,`orderCount=1` |
| 活跃子订单 | `orderId=2096412454488612866` `orderNo=HL20260906093733163`:`orderStatus=CUSTOMIZING`,`payStatus=DEPOSIT_PAID`,`paidAmount=5850.00`,1 成人 1 儿童,`order_main.room_count=1` |
| 同班期其余 13 单 | 全部 `CANCELLED`(`includeCancelled=true` 时返回 14 条) |
| 备注 | 以上是 wx 截图时段(2026-09-06 17:20 前后网关实测)的状态。该单 09:37 建、01:37(UTC 09-05 17:37)已取消,14 时许被团期房务会话为 #7149 实测用 SQL 临时改成 CUSTOMIZING / DEPOSIT_PAID / 5850 并改写房务需求(`roomCount=2` KING,`specialNeeds` 含「#7149 网关实测」),**18:51 已复原为 CANCELLED / UNPAID / 0.00**;复原后该班期 `enrolledRooms=0`、无活跃子订单。它与产品侧「线下占位房数=0」无关 |
---
## 二、变更接口清单
| # | 接口 | 方法 | 网关路径 | 前端函数 | 变更 | 说明 |
|---|------|------|---------|---------|------|------|
| 1 | 团期子订单列表 GB-ADM-003 | GET | `/v3/admin/order/group-batch/{groupBatchId}/orders` | `getGroupBatchOrders()` | 复用不改 | 后端已实测返回 1 条,前端未渲染 |
---
## 三、接口详情
### 1. 团期子订单列表 `GET /v3/admin/order/group-batch/{groupBatchId}/orders`
**VO**: 无请求 VO(路径参数 + 三个可选 query)→ `Result<List<GroupBatchOrderItemRespVO>>`
#### 使用场景
看板行展开(`PeriodRow.onToggle`)与团期详情名单页拉该团期的子订单摘要;缺省只返活跃集(`orderStatus != CANCELLED`)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | path | Long(JSON 字符串) | 是 | 团期主订单 ID | 不是 product 侧 `productBatchId` |
| includeTravelers | query | Boolean | 否 | 缺省 false | true 时附 `travelers[]`(证件号一律不返回) |
| includeNeeds | query | Boolean | 否 | 缺省 false | true 时附 `roomCount / roomType / specialNeeds` |
| includeCancelled | query | Boolean | 否 | 缺省 false | true 时含已取消子订单 |
#### 出参 `Result<List<GroupBatchOrderItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data[].orderId | String(Long) | 子订单 ID |
| data[].orderNo | String | 订单编号 |
| data[].customerName | String | 客户姓名 |
| data[].contactPhone | String | 联系人手机号(脱敏,前 3 后 4) |
| data[].adultCount / childCount / youngChildCount / babyCount | Integer | 成人 / 儿童 / 幼童 / 婴儿数 |
| data[].participantCount | Integer | 出行人总数(四项之和) |
| data[].orderStatus | String | 订单状态码:`PENDING_PAY / CUSTOMIZING / PENDING_DEPARTURE / TRAVELLING / COMPLETED / CANCELLED` |
| data[].orderStatusName | String | 订单状态中文名 |
| data[].payStatus | String | `UNPAID / DEPOSIT_PAID / FULLY_PAID` |
| data[].paidAmount | String(BigDecimal) | 已支付金额(订金 + 尾款) |
| data[].totalPrice | String(BigDecimal) | 本户应收 = orderAmount + 增项 − 优惠(下限 0);取消单返 0 |
| data[].balanceAmount | String(BigDecimal) | 待支付尾款 = 应收 − 已退 − 已付(≥0) |
| data[].estimatedCost | String(BigDecimal) | 下单时点预估成本快照,可为 null |
| data[].contractStatus | String | `GENERATING / SIGNED / VOIDED / RESIGNING / FAILED`;无合同 null |
| data[].insuranceStatus | String | `INSURING / INSURED / CANCELLED / FAILED`;无保险 null |
| data[].hotelRequirementStatus | String | 房需求提报状态 `PENDING / SUBMITTED / REJECTED`(实测也会透出需求状态机原值如 `PENDING_REVIEW`) |
| data[].vehicleRequirementStatus | String | 车需求提报状态 `PENDING / SUBMITTED / REJECTED` |
| data[].roomCount | Integer | 房数(includeNeeds=true;来源 `order_hotel_requirement.days`,缺省 ceil(人数/2))——**与 `order_main.room_count` 是两个字段**,看板「已订房」用的是后者 |
| data[].roomType | String | 房型文本(includeNeeds=true) |
| data[].specialNeeds | String | 特殊需求(includeNeeds=true;缺需求行回落 customer_remark) |
| data[].tierCode | String | 档位码,如 `1A1C` |
| data[].tierName | String | 档位名,如 `1成人1儿童` |
| data[].travelerInfoComplete | Boolean | 出行人资料是否齐全 |
| data[].travelers[] | Array | includeTravelers=true 时返回:`name / type(ADULT/CHILD/YOUNG_CHILD/BABY) / age / birthdayInTrip` |
| data[].consultantName | String | 定制师姓名(创单时固化) |
#### 请求示例
```http
GET /v3/admin/order/group-batch/2096412454643802114/orders?includeTravelers=true&includeNeeds=true HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
实测响应(2026-09-06,`travelers[]` 略):
```json
{
"code": 200,
"message": "成功",
"data": [
{
"orderId": "2096412454488612866",
"orderNo": "HL20260906093733163",
"customerName": "测试团期指引",
"adultCount": 1,
"childCount": 1,
"youngChildCount": 0,
"babyCount": 0,
"participantCount": 2,
"orderStatus": "CUSTOMIZING",
"orderStatusName": "定制中",
"payStatus": "DEPOSIT_PAID",
"paidAmount": "5850.00",
"totalPrice": "5850.00",
"balanceAmount": "0.00",
"estimatedCost": null,
"contractStatus": null,
"insuranceStatus": null,
"hotelRequirementStatus": "PENDING_REVIEW",
"vehicleRequirementStatus": "PENDING",
"roomCount": 2,
"roomType": "KING",
"specialNeeds": "#7149 网关实测 AC-1 招募中",
"tierCode": "1A1C",
"tierName": "1成人1儿童",
"travelerInfoComplete": false,
"consultantName": "admin"
}
],
"success": true
}
```
#### 空数据 / 降级响应
该团期没有活跃子订单时 `data` 为空数组(不是 null);前端只有在 `code=200 && data.length===0` 时才应显示「本期暂无子订单」,请求失败或异常码一律显示错误态。
```json
{
"code": 200,
"message": "成功",
"data": [],
"success": true
}
```
#### 错误响应
```json
{
"code": 589500,
"message": "团期不存在",
"data": null,
"success": false
}
```
其他:`589507` 无操作权限(非团期管理员 / 非本定制师名下)。HTTP 始终 200,按 `code` 判断。
#### 业务边界
- 缺省剔除 `CANCELLED`;`includeCancelled=true` 才含已取消单
- 路径参数是 order 侧 `groupBatchId`,看板行 VO 里的 `groupBatchId` 字段直接可用;不要传 `productBatchId`
- `roomCount`(房务需求)与看板「已订房」(`order_main.room_count` 计数器,1 单 = 1 房)口径不同,不要互相校验
---
## 四、前端修复要点与自测清单
### 修复要点
1. **首选(方案 B)按 `groupBatchId` 键缓存**:`orderV2Batch.js` 把 `batchOrders`(单值数组)改成按 ID 的 map(如 `ordersByBatch = ref({})`),`fetchBatchOrders` 写 `ordersByBatch.value[id] = list`;`PeriodRow.vue:233-238` 的 `orders / ordersLoading` 直接读 `store.ordersByBatch[groupBatchId.value]`,去掉对 `currentGroupBatchId` 的依赖(该字段语义上只服务详情页)。多行同时展开互不覆盖;`PeriodRow.vue:232` 原注释「按 groupBatchId 缓存」即此设计。保留旧的 `batchOrders` 导出(`:202` 一带)直到 grep 确认详情页 / `ChipDetailModal.vue` 没有直接读它。
2. **治标(方案 A)**:只在 `fetchBatchOrders` 请求前写 `currentGroupBatchId.value = id`。一行改动,但 `currentGroupBatchId` 是单值:keep-alive(`index.vue:170-172`)下先展开 X 行、进 Y 详情再返回,X 行会退回空态;多行同时展开互相覆盖。不推荐单独采用。
3. **错误态**:`PeriodRow.vue` 目前不渲染 `store.ordersError`(`orderV2Batch.js:159-162` 失败时置 `batchOrders=[]`),接口失败会伪装成「暂无子订单」;展开区应区分「加载中 / 加载失败(可重试)/ 暂无子订单」,且行上 `orderCount>0`(GB-ADM-001 有该字段)而列表为空时显示「加载失败」而非「暂无」。
4. **让橙色芯片可自证**:`SubOrderTable.vue:10-19` 表头只有联系人 / 人数 / 房型·间数 / 电话 / 应收 / 毛利 / 资料,GB-ADM-003 已返回的 `hotelRequirementStatus / vehicleRequirementStatus` 没有展示;建议加「房需求 / 车需求」状态列,或在芯片上加 tooltip 文案「进行中:N 户待审核」(数据来自 GB-ADM-090~095 逐户明细),否则运营看到橙色仍不知道它对应哪户的什么状态。芯片图例建议:灰=未开始、橙=进行中、绿=已完成、红=异常。
5. **顺手核对**:`ChipDetailModal.vue`(`index.vue:174` 引入)是否也依赖 `currentGroupBatchId`;详情页离开时是否需要清空 `currentGroupBatchId`。
### 自测清单
1. 刷新看板页,直接展开任一 `orderCount>0` 的行(如「测试小蒙马-多档-固定金额 · #7158验收班期」)→ 出现与 GB-ADM-003 返回条数一致的子订单
2. 先展开 A 行再展开 B 行 → 各自显示各自的子订单,收起再展开不串行
3. 展开瞬间出现加载态
4. 断网或把接口改成 500 → 展开区显示「加载失败」而非「本期暂无子订单」
5. 进详情页再返回看板展开 → 仍正确(keep-alive 场景)
6. 真正没有活跃子订单的团期(如把该单取消后)→ 显示「本期暂无子订单」
---
## 五、验证证据
### 网关实测(测试服 2026-09-06 17:20 前后,登录后切 ADMIN 角色;该单 18:51 已复原为已取消,下表是截图时段的证据)
| 请求 | 结果 |
|------|------|
| `GET /v3/admin/order/group-batch/2096412454643802114/orders` | 200 / code 200,1 条(HL20260906093733163) |
| `GET .../orders?includeTravelers=true&includeNeeds=true`(前端缺省参数) | 200 / code 200,1 条,含 roomCount=2 / roomType=KING |
| `GET .../orders?includeCancelled=true` | 200 / code 200,14 条(1 活跃 + 13 CANCELLED) |
| `GET /v3/admin/order/group-batch/2096412454643802114` | enrolledRooms=1、enrolledPeople=2、remainRooms=7、maxRooms=8 |
| `GET /v3/admin/order/group-batch/board?productId=2044306857534636034` 该行 | enrolledRooms=1、orderCount=1、groupBatchId=2096412454643802114 |
### DB 落库(只读)
`hl_order_service_v3.order_main` 中 `product_batch_id=2052935476557328386` 共 14 行:13 行 `order_status=CANCELLED`,1 行 `CUSTOMIZING`(`pay_status=DEPOSIT_PAID`、`paid_amount=5850.00`、`room_count=1`、`group_batch_id=2096412454643802114`);`order_group_batch` 该团期 `enrolled_rooms=1`、`enrolled_people=2`。
---
## 六、影响与不影响范围
- **影响**:hl-ui `src/stores/orderV2Batch.js`、`src/views/order-v2/batch/components/PeriodRow.vue`、`SubOrderTable.vue`(可选加列);团期详情页若直接读 `store.batchOrders` 需同步改用新缓存
- **不影响**:后端 GB-ADM-001 / GB-ADM-003 契约与实现;网关;小程序;订单列表页
---
## 七、关联
- 后端工单 #7204:导/摄芯片零指派却 DOING(橙)的聚合口径修正(wx 2026-09-06 拍板:零指派灰 / 部分指派橙 / 全部指派绿)——与本缺陷无依赖,但同一现场
- 后端团期看板三单:#7188(「第N期」序号透出)、#7189(看板以产品全班期为基底 + 班期范围筛选)、#7190(「出行完毕」状态)——均不改 GB-ADM-003
- 同日前端缺陷:`06_frontend_团期产品新建订单向导创单漏传productBatchId-前端缺陷-管理后台.md`(已由 mmg 修复 03a985b6)
@@ -0,0 +1,281 @@
---
schema: "hl-changelog/v2"
ticket: "7023"
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: "后端标 not_required,前端 mmg grep 复核确认:三端点仅 POST supplies 前端有真实调用(addGroupBatchSupplies,orderV2GroupBatch.js:182→SuppliesPickerModal),PUT quantity/DELETE 前端无封装不可达。POST 调用方 catch 按 code 分支、未知码兜底 err?.message 透后端原文(request.js 统一把 data.message 塞进 err.message),新增 589520/589522 会自带后端「物资清单只能在准备物资阶段配置」等文案浮出,符合「按错误码提示即可」;契约请求/响应结构不变。无需前端改动。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 团期物资: 三端点补阶段门 + 确认留痕 + 合同保险前置门
> **服务**: hl-order-service-v3
> **影响范围**: 管理后台团期物资清单的增/删/改三端点 + 团期状态推进(合同保险闸门)
---
## ⚠️ 关键变化
物资清单的增删改**只能在「准备物资」(`MATERIAL_PREPARING`) 阶段**操作,其余阶段返回 `589520`。
此前无阶段限制。另:四资源就绪后,**合同/保险未出齐则团期停在资源筹备中**,不进物资准备阶段。
前端无需改造,按错误码提示即可(本单纯服务端)。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 新增团期备品 | POST | `/v3/admin/order/group-batch/:groupBatchId/supplies` | 新增阶段门 | 非 MATERIAL_PREPARING 返 589520 |
| 2 | 调整备品数量 | PUT | `/v3/admin/order/group-batch/supplies/:batchSuppliesId/quantity` | 新增阶段门 | 同上;数量非法优先返 589522 |
| 3 | 删除备品行 | DELETE | `/v3/admin/order/group-batch/supplies/:batchSuppliesId` | 新增阶段门 | 同上 |
---
## 三、接口详情
### 1. 新增团期备品 `POST /v3/admin/order/group-batch/:groupBatchId/supplies`
**VO**: `AddSuppliesReqVO`
#### 使用场景
团期详情「物资」Tab 添加备品。只有团期处于「准备物资」阶段才允许。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 团期主订单 ID |
| suppliesResourceId | Body | Long | ❌ | 与 suppliesName 二选一 | 传则从备品库带出名称/分类/计费方式(见 #6986)|
| suppliesName | Body | String | ❌ | 与 suppliesResourceId 二选一 | 纯手填时用 |
| quantity | Body | Integer | ✅ | 正整数 | ≤0 返 589522 |
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data | null | 无数据体,成功仅 code=200 |
#### 请求示例
```json
{ "suppliesResourceId": 1001, "quantity": 5 }
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "success": true }
```
#### 空数据 / 降级响应
无数据体接口,无空数据形态。
#### 错误响应
```json
{ "code": 589520, "message": "物资清单只能在「准备物资」阶段配置", "success": false, "data": null }
```
#### 业务边界
- **阶段门**:仅 `MATERIAL_PREPARING` 放行,其余阶段(招募中/资源筹备中/待出发/…)返 `589520`。
- **确认后仍可改**:`material_confirmed=true` 后不转只读,只要仍在 MATERIAL_PREPARING 就放行(jw 两次明确)。
- **创单固化不受门限**:`freezeFromProduct`(首单懒建固化备品)是系统行为,不走阶段门。
### 2. 调整备品数量 `PUT /v3/admin/order/group-batch/supplies/:batchSuppliesId/quantity`
**VO**: `AdjustSuppliesQuantityReqVO`
#### 使用场景
物资 Tab 改数量。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| batchSuppliesId | Path | Long | ✅ | - | 备品行 ID(反查所属团期判阶段)|
| quantity | Body | Integer | ✅ | 正整数 | ≤0 优先返 589522 |
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data | null | 无数据体 |
#### 请求示例
```json
{ "quantity": 8 }
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "success": true }
```
#### 空数据 / 降级响应
无数据体接口。
#### 错误响应
```json
{ "code": 589522, "message": "备品数量必须为正整数", "success": false, "data": null }
```
#### 业务边界
- **错误码可区分**:数量非法先返 `589522`(参数校验),阶段不符返 `589520`(阶段门),两码不同。
- 阶段门口径同端点 1。
### 3. 删除备品行 `DELETE /v3/admin/order/group-batch/supplies/:batchSuppliesId`
**VO**: `无请求体`
#### 使用场景
物资 Tab 删除一行备品。仅 MATERIAL_PREPARING 阶段可删。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| batchSuppliesId | Path | Long | ✅ | - | 备品行 ID(反查所属团期判阶段)|
#### 出参 `Result<Void>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data | null | 无数据体 |
#### 请求示例
```http
DELETE /v3/admin/order/group-batch/supplies/1932847562341 HTTP/1.1
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "success": true }
```
#### 空数据 / 降级响应
无数据体接口。
#### 错误响应
```json
{ "code": 589520, "message": "物资清单只能在「准备物资」阶段配置", "success": false, "data": null }
```
#### 业务边界
- 阶段门口径同端点 1:仅 MATERIAL_PREPARING 放行,其余阶段返 589520。
- 删除是软删;确认后(material_confirmed=true)仍可删,不转只读。
---
## 四、契约约束与正确调用方式
| 场景 | 结果 |
|------|------|
| ✅ MATERIAL_PREPARING 阶段增删改 | 200 |
| ❌ 招募中 / 资源筹备中 / 待出发 等阶段 | 589520 |
| ❌ 数量 ≤ 0 | 589522(优先于阶段门)|
---
## 五、数据库行为
无表变更、无 Flyway。`markMaterialConfirmed` 后**无条件**记一条 `changeType=DATA` 的确认物资流水
(含操作人与时间),与状态推进的 STATUS 流水分开;双门未满足时也留痕。
---
## 六、边界行为
- 未登录 → 401(网关拦截)
- 团期不存在 → 589500
- 阶段不符 → 589520;数量非法 → 589522
- 创单固化备品 `freezeFromProduct` → 不受阶段门影响
## 六.5、枚举 / 数据字典
### 团期阶段(`GroupBatchStatus`)与物资门
**所属字段**: 服务端判定 | **类型**: `String`
| 阶段 | 物资增删改 |
|------|-----------|
| `RECRUITING` / `RESOURCE_PREPARING` / `PENDING_DEPARTURE` 等 | 拒(589520)|
| `MATERIAL_PREPARING` | 放行 |
## 六.6、修改前后对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 非物资准备态增删改物资 | 允许 | 拒 589520 |
| 四就绪齐但合同保险未出齐 | 进 MATERIAL_PREPARING | 停在 RESOURCE_PREPARING |
| markMaterialConfirmed 留痕 | 依赖状态推进流水 | 无条件先记一条 DATA 流水 |
## 六.7、影响评估
- 破坏向后兼容:否(新增拒绝分支,正常阶段操作不变)
- 前端必须同步上线:否(按错误码提示)
- 存量团期:已在 MATERIAL_PREPARING 及之后的不被新门卡回(新门只对新进入的团期生效)
## 七、不影响范围
- 仅影响:团期物资增删改三端点的阶段限制 + 合同保险前置门
- 零影响:confirmMaterial 双门推进(保留)、创单固化、C 端、其余团期端点
---
## 八、测试环境已验证
✅ **已验证。** 2026-09-07 部署 dev-v3 到测试服并经网关实测(真实管理端鉴权)。
产品 2044306857534636034 第 7 期(`RESOURCE_PREPARING` 态,非物资准备):
| 用例 | 期望 | 实测 |
|------|------|------|
| 非 MATERIAL_PREPARING 打 POST supplies | 589520 阶段门拒绝 | ✅ `589520 物资清单只能在「准备物资」阶段配置` |
码层核实(子代理)AC-1~AC-13 全部成立:三端点阶段门、确认后仍可改、confirmMaterial 双门保留、
markMaterialConfirmed 无条件 DATA 流水、freezeFromProduct 不受门限、589520/589522 可区分、
合同保险前置门(isContractInsuranceReady)、存量团期不回退。
## 九、相关历史 PR
| PR | Issue | 说明 |
|----|-------|------|
| — | #7023 | 团期物资阶段门 + 确认留痕 + 合同保险前置门 |
## 十、相关文档
- 关联 Issue: [wx/HL#7023](https://git.1814.love:8443/wx/HL/issues/7023)
- 口径:AC-TD-13 / AC-TD-15 / AC-TD-16(jw 定案)
## 关联 / 联系人
- **Issue**: [#7023](https://git.1814.love:8443/wx/HL/issues/7023)
- **后端负责人**: @jw
@@ -0,0 +1,307 @@
---
schema: "hl-changelog/v2"
ticket: "7080"
title: "团期人员配置扇出补同步订单侧 staff 状态,guide_status 不再恒为 NULL"
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: "2026-09-07"
status_note: "PR #7115 合入 dev-v3(468df6fa7),2026-09-07 测试服网关实测通过(配导游后 guide 芯片 TODO(0/55)→DONE(55/55),清空回落)。出参结构一个字段没变,只改扇出副作用(guide_status/photographer_status 不再恒 NULL、补 DONE 流水、触发就绪闸门)。前端 mmg 已实查为 not_required:全仓无 guide_status/photographer_status 消费点,也无「团期配过但订单侧未配置」的兼容逻辑或提示文案可撤,无需改码。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 团期: 人员配置扇出补同步订单侧 staff 状态
> **服务**: hl-order-service-v3
> **PR**: #7115
> **Issue**: #7080
> **日期**: 2026-09-04
> **影响范围**: 管理后台团期人员配置保存后,团内各子订单的导游/摄影资源状态与时间线
---
## ⚠️ 关键变化
**出参结构一个字段没变,变的是写库之后的副作用。**
改前:经「团期详情 → 配置导游/摄影」配的人,扇出只写 `order_staff_assignment` 表,
`order_main.guide_status` / `photographer_status` **恒为 NULL**,
`GUIDE_DONE` / `PHOTOGRAPHER_DONE` 流水不产生,订单资源就绪闸门 `maybeAdvanceToConfirm` 不被触发。
只有走订单侧「订单详情 → 人员 → 新增」才会写这些。
改后:扇出末尾按配置位全量同步订单侧状态,两条路口径一致。
**前端要注意的是**:以前拿到 `guide_status=null` 不代表没配人(可能是团期配的),
现在 null 就是真的没人。原先若有「团期配过但订单侧显示未配置」的兼容逻辑或提示文案,可以撤掉。
---
## 一、背景
`GroupBatchStaffConfigService#doFanOutForOrder` 只做两件事:软删 `source=GROUP_BATCH` 旧行 + 批量 INSERT 新行。
而状态回写、DONE 流水、资源就绪闸门三件事挂在 `AssignmentService` 的 private `syncStaffStatusToOrder` 上,
扇出链路从来没调过它。
| 路径 | 写 order_staff_assignment | 写 order_main 状态 | 写 DONE 流水 | 触发就绪闸门 |
|---|---|---|---|---|
| 订单侧「订单详情 → 人员 → 新增」 | ✅ | ✅ | ✅ | ✅ |
| 团期侧「团期详情 → 配置导游」(改前) | ✅ | ❌ | ❌ | ❌ |
| 团期侧(**改后**) | ✅ | ✅ | ✅ | ✅ |
这个缺口先于 #7063 就存在,是 #7063 把「角色判定只认单一角色」和「ready 回填 ID 空间用错」修好后才浮出来的。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 全量保存团期人员配置 | PUT | `/v3/admin/group-batch/:groupBatchId/staff` | 副作用变更 | 扇出后逐子订单同步 `guide_status`/`photographer_status` + DONE 流水 + 就绪闸门 |
---
## 三、接口详情
### 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 | 扇出到的活跃子订单数,**未变** |
> 本次**不改任何出参字段**。要观察效果请查子订单的 `guide_status` / `photographer_status` 与订单时间线。
#### 请求示例
```json
{
"staffList": [
{ "staffId": 1002, "staffRole": "GUIDE", "sortOrder": 0 }
]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"staffList": [
{ "staffId": 1002, "staffRole": "GUIDE", "staffName": "李雪梅", "sortOrder": 0 }
],
"affectedOrderCount": 2
}
}
```
#### 空数据 / 降级响应
`staffList` 传空数组即清空配置,响应照常 200:
```json
{ "code": 200, "data": { "staffList": [], "affectedOrderCount": 2 }, "success": true }
```
清空后各子订单的 `guide_status` / `photographer_status` 会被置回 `null`(该位若无订单侧手工行)。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"success": false,
"data": null
}
```
本次**未新增任何错误码**,错误语义与改前完全一致。
#### 业务边界
- **异步**:扇出是 `@Async`,状态同步在扇出的单订单子事务内完成,不阻塞本接口响应。
即接口返回 200 时,子订单状态可能尚未写完——前端不要在收到响应的同一刻立即读子订单状态做断言。
- **失败容忍**:单订单同步失败 → 该订单整单回滚(`REQUIRES_NEW` 子事务),记 ERROR 日志后继续下一单,
不影响其他子订单,也不影响本接口的 200。
- **清空语义**:按配置位全量同步,清空时把无人的位置回 `null` 并且**不写流水**。
- **与订单侧手工行共存**:状态按「配置位整组」计数(导游位 = GUIDE + LEADER),与 `source` 无关。
团期行被软删后若该订单仍有 `source=ORDER` 的手工导游行,`guide_status` 保持 `DONE`,两边不互相覆盖。
- **鉴权与幂等**:均未变。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload |
|------|---------|
| ✅ 配一名导游 | `{ "staffList": [{ "staffId": 1002, "staffRole": "GUIDE" }] }` |
| ✅ 清空配置 | `{ "staffList": [] }` |
| ❌ 收到 200 后立刻断言子订单状态 | 扇出是异步的,需轮询或稍后再查 |
### 调用方式没有变化
本次是纯副作用修复,**前端不需要改任何请求**。
---
## 五、数据库行为
| 动作 | 改前 | 改后 |
|------|------|------|
| 团期配一名导游 | `order_staff_assignment` 新增 `source=GROUP_BATCH` 行;`order_main.guide_status` 保持 `NULL` | 同样新增行;**`order_main.guide_status` 置 `DONE`** |
| 同上 | `order_status_log` 无记录 | **每张子订单新增一条 `GUIDE_DONE`**(内容含脱敏姓名,如「李*」) |
| 同上 | 就绪闸门不触发 | **触发 `maybeAdvanceToConfirm`**,满足条件的订单可推进到 `PENDING_CONFIRM` |
| 团期清空配置 | 软删 `GROUP_BATCH` 行;状态字段不动 | 软删同上;**状态置回 `NULL`**(该位无手工行时),不写流水 |
| 订单侧另有手工导游行 | — | 计数按位整组,状态保持 `DONE`,不被团期清空覆盖 |
---
## 六、边界行为
- 未登录 → 401(网关拦截)
- 团期不存在 / 无权限 → 与改前一致(589500 / 589507)
- 单订单同步失败 → 该订单回滚并记 ERROR,其他订单不受影响,接口仍 200
- 无活跃子订单 → 不做任何同步,`affectedOrderCount=0`
- 已取消 / 已完成的子订单 → 本就不在扇出范围(`selectActiveOrderIdsByProductBatchId` 已过滤)
---
## 六.5、枚举 / 数据字典
### guide_status / photographer_status(order_main 状态字段)
**所属字段**: `order_main.guide_status`、`order_main.photographer_status` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `DONE` | 已配置 | 该配置位至少有一人(不分 source) |
| `null` | 未配置 | 该配置位无人。**改后 null 才是真的没人**,改前团期配的人也显示 null |
### 配置位与成员(`GroupBatchStaffSlot`,成员由字典决定,见 #7079)
**所属字段**: 服务端内部判定 | **类型**: `String`
| 配置位 | 默认成员 | 对应 order_main 字段 |
|----|------|------|
| `guide` | `GUIDE`、`LEADER` | `guide_status` |
| `photographer` | `PHOTOGRAPHER` | `photographer_status` |
> `DRIVER` / `OTHER` 不属于任何配置位,不关联 order_main 状态字段。
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `order_main.guide_status`(经团期配置) | 恒 `NULL` | 有人 `DONE` / 无人 `NULL` |
| `order_main.photographer_status`(经团期配置) | 恒 `NULL` | 有人 `DONE` / 无人 `NULL` |
| 接口出参 | — | **完全未变** |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 团期配人 → 订单时间线 | 无记录 | 每子订单一条 `GUIDE_DONE` / `PHOTOGRAPHER_DONE` |
| 团期配人 → 资源就绪闸门 | 不触发 | 触发 `maybeAdvanceToConfirm` |
| 团期清空 → 订单状态 | 不动(本来就是 NULL) | 置回 `NULL`(该位无手工行时) |
| 订单侧 addStaff / editStaff / deleteStaff | — | **行为完全不变**(实现搬到公共组件,断言未改) |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。出参与入参均未变,只是原本恒为 NULL 的字段开始有值。
- **前端是否必须同步上线**: 否。
- **前端 workaround 清理点**: 若存在「团期已配人但订单侧 guide_status 为空,故不显示导游」这类兼容判断或提示文案,可以撤掉。
## 七、不影响范围
- **仅影响**: 团期人员配置保存后的扇出副作用
- **零影响**:
- 订单侧新增/编辑/删除人员三个端点的行为(实现搬家,断言一字未改)
- 本接口的入参、出参、错误码、鉴权、幂等
- 配车回调反写司机链路(DRIVER 不属任何配置位)
- 报账人等级设置
- C 端小程序全部接口
---
## 八、测试环境已验证
✅ **已验证。** 2026-09-07 部署 dev-v3 到测试服并经网关实测(`https://api.test.1814.love:9443`,真实管理端鉴权)。
产品 2044306857534636034 第 7 期(gbId 2096412454643802114,**55 户活跃子订单**):
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| AC-1 | 经团期配置一名导游(staffId 1002)后全部子订单 guide 状态 DONE | guide 芯片 `DONE(55/55)` | ✅ 配置前 `TODO(0/55)` → 扇出后 `DONE(55/55)`,`affectedOrderCount=55` |
| AC-5 | 清空配置(空 staffList)后状态回落 | guide 芯片回 `TODO(0)` | ✅ 撤回后经中间态 `DOING(54)` 逐单回退至 `TODO(0)`,现场已复原 |
改前对比:经团期配置扇出只写 `order_staff_assignment` 表、不碰订单侧状态,guide 芯片恒 `TODO`——
本次实测 DONE(55/55) 即修复生效的直接证据。
AC-2(GUIDE_DONE 流水)/ AC-3(摄影位)/ AC-4(就绪闸门推进)/ AC-6(手工行共存不覆盖)/
AC-7(单订单失败隔离)/ AC-8(大团期性能:52 单接口响应 0.11s、扇出落库 0.47s、均摊 9ms)
由本地端到端实测 + 单测覆盖(见工单 #7080 验收总结 comment)。
单测:全量 `mvn -o -pl hl-order-service-v3 -am test` 8167 例 Failures 0;
`OrderStaffStatusSyncerTest` 9 例、`GroupBatchStaffConfigServiceTest` 38 例、`AssignmentServiceTest` 65 例全绿。
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #7085 | #7079 | 配置位成员改由字典决定 | ✅ 有效 |
| #7076 | #7063 | 导游位下游按配置位归组(第 1 波) | ✅ 有效 |
| **本 PR #7115** | **#7080** | 补上扇出链路缺失的状态同步(#7063 AC-3 在团期路径上不成立的真因) | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#7080](https://git.1814.love:8443/wx/HL/issues/7080)
- 关联 PR: [wx/HL#7115](https://git.1814.love:8443/wx/HL/pulls/7115)
- 前序: [wx/HL#7063](https://git.1814.love:8443/wx/HL/issues/7063)(本单从其测试环境验收中分离)
## 关联 / 联系人
### 链接
- **Issue**: [#7080](https://git.1814.love:8443/wx/HL/issues/7080)
- **PR**: [#7115](https://git.1814.love:8443/wx/HL/pulls/7115)
- **Merge commit**: [468df6fa7](https://git.1814.love:8443/wx/HL/commit/468df6fa7)
### 联系人
- **后端负责人**: @jw
@@ -0,0 +1,390 @@
---
schema: "hl-changelog/v2"
ticket: "7095"
title: "团期转订单:同产品其他期的子订单跨期转入本期"
consumer: "admin"
author: "jw(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "cc07a40d"
target_release: ""
verified_at: "2026-09-08"
status_note: "PR #7103 合入 dev-v3(6c1124db5),后续修复 #7270(订单侧 timeline)、#7278(容量守卫 0=不限)均已合入,2026-09-07 部署测试服过网关实测(候选查询/成功转期/容量守卫拒绝全通过)。前端待接:团期详情底部「转订单」弹窗接真——候选下拉替掉本地 mock 改调 GET transfer-candidates(手机号仅整串精确匹配,加密列),转期调 POST transfer-in(body fromGroupBatchId,转期原因选填,勾选通知仅影响 warnings 文案不实发)。决策:入 backlog 排在 #7067 U3-U7 与 #7244 之后统一汇总审派发。本条保持 pending。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 团期: 转订单——同产品其他期的子订单跨期转入本期
> **服务**: hl-order-service-v3
> **PR**: #7103
> **Issue**: #7095
> **日期**: 2026-09-04
> **影响范围**: 管理后台团期详情底部操作条「转订单」弹窗
---
## ⚠️ 关键变化
原型上的「转订单」按钮此前**没有后端可调**(全仓零命中),候选下拉是前端本地 mock。本次两个端点补齐。
三条前端必读:
1. **手机号搜索只支持整串精确匹配**,输前几位搜不到——`customer_phone` 是 AES 加密列,LIKE 在密文上无意义。
2. **「转期原因」是选填**。原型弹窗没有这个输入框,后端不强制,前端零改动也能上线(不传则记「管理员手动转期」)。
3. **通知客户本期不实发**。勾选「转入后通知客户」只影响响应 `warnings` 里的提示文案,后端不发公众号/短信(模板 ID 未提供)。
---
## 一、背景
转订单 = 把**同一产品其他期**的一个子订单(= 一户)转到本期:客户无需重新下单,
**订金不变、尾款按本期价重算、不可跨产品转**。与已上线的「退单户」`POST .../withdraw` 是两件事:
| 维度 | 退单户(已有) | 转订单(本次) |
|---|---|---|
| 退不退钱 | 退该户定金 | **不退**,钱跟着人走 |
| 审批 | 走(定制师审) | **不走** |
| 该户去向 | 离团进退款流程 | 转入目标期,订单继续有效 |
| 团期计数 | 已报名户/人数回落 | 源期回落 + 目标期增加,总数守恒 |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 转订单(子订单跨期转入) | POST | `/v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/transfer-in` | 新增 | 把源期一个子订单转入本期,尾款按本期价重算 |
| 2 | 转订单候选子订单列表 | GET | `/v3/admin/order/group-batch/:groupBatchId/transfer-candidates` | 新增 | 弹窗搜索下拉的候选池,替掉前端本地 mock |
---
## 三、接口详情
### 1. 转订单(子订单跨期转入) `POST /v3/admin/order/group-batch/:groupBatchId/sub-order/:orderId/transfer-in`
**VO**: `TransferSubOrderReqVO` / `TransferSubOrderRespVO`
#### 使用场景
团期详情底部操作条点「转订单」→ 弹窗里搜到源期的某个子订单 → 点「转入本期并通知」时调用。
path 上的 `groupBatchId` 是**转入**期(当前打开的这一期),源期在 body 的 `fromGroupBatchId`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 转入期(本期)团期主订单 ID |
| orderId | Path | Long | ✅ | - | 待转入的子订单 ID(取自候选列表的 `orderId`) |
| fromGroupBatchId | Body | Long | ✅ | 非空 | 源期团期主订单 ID(取自候选列表的 `fromGroupBatchId`,**不是产品侧班期 ID**) |
| reason | Body | String | ❌ | ≤512 字 | 转期原因;不传后端记「管理员手动转期」 |
| notify | Body | Boolean | ❌ | 默认 true | 对应弹窗复选框;本期只落标志位并在 warnings 提示,**不实发通知** |
#### 出参 `Result<TransferSubOrderRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| orderId | String | 被转子订单 ID(雪花,序列化为字符串) |
| fromBatchNo | String | 源期团期号 |
| toBatchNo | String | 目标期团期号 |
| oldOrderAmount | BigDecimal | 转期前应收 |
| newOrderAmount | BigDecimal | 转期后应收(按目标期报价重算) |
| paidAmount | BigDecimal | 已付金额(转期不动,原样跟随) |
| newBalanceDue | BigDecimal | 转期后待收尾款 = 新应收 − 已付 |
| warnings | String[] | 非阻断提示,需人工跟进的事项;无则为空数组 |
#### 请求示例
```json
{ "fromGroupBatchId": 1932847562341, "reason": "客户改期,转到第 7 期", "notify": true }
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"orderId": "1955001234567890",
"fromBatchNo": "GT-26-0005",
"toBatchNo": "GT-26-0007",
"oldOrderAmount": 8000.00,
"newOrderAmount": 8600.00,
"paidAmount": 3000.00,
"newBalanceDue": 5600.00,
"warnings": ["需人工经公众号 + 短信通知客户团期变更与新行程"]
},
"success": true
}
```
#### 空数据 / 降级响应
本接口是写操作,无空数据形态。产品域报价 / 库存调用降级时**不静默成功**,一律按错误响应返回并整事务回滚。
`warnings` 可能为空数组(该户无合同保险、且 `notify=false`):
```json
{ "code": 200, "data": { "warnings": [] }, "success": true }
```
#### 错误响应
```json
{
"code": 589528,
"message": "目标团期价低于该户已付金额,需先走退款流程",
"success": false,
"data": null
}
```
八类拒绝:
| code | 含义 |
|------|------|
| 589500 | 团期不存在(源期或目标期) |
| 589501 | 该订单当前状态不可转(只放行待支付 / 待出行) |
| 589510 | 源期或目标期状态不满足(须为招募中 / 资源筹备中) |
| 589512 | 子订单不属于所填源期 |
| 589524 | 转出与转入是同一团期 |
| 589525 | 跨产品转(只能同产品不同期) |
| 589526 | 目标期名额或房间数不足 |
| 589527 | 获取目标期报价失败 |
| 589528 | 目标期价低于该户已付金额 |
#### 业务边界
- **鉴权**:管理后台端点,须带网关注入的 `X-Admin-Id`;与既有团期动作端点(成团 / 流团 / 退单户)同口径。
- **幂等**:同一 `orderId` 5 秒窗口内重复提交只成功一次(双击防重)。
- **并发**:按团期 ID 数值升序对两期加分布式锁,两个管理员对拉互转不会死锁。
- **失败零写入**:任一守卫不过或产品域调用失败,整事务回滚——两期名额、两期已报名计数、订单本身**分文未动**。
- **钱不动**:`paid_amount` 全程不变,本接口不产生任何退款单或收款单;差额体现在待收尾款上。
- **兼容**:不传 `reason` / `notify` 均可,老前端零改动可调。
---
### 2. 转订单候选子订单列表 `GET /v3/admin/order/group-batch/:groupBatchId/transfer-candidates`
**VO**: `TransferCandidateVO`
#### 使用场景
转订单弹窗里「搜索要转入的订单」输入框的数据源,替掉原型阶段的前端本地 mock 数据。
返回的每一行可直接渲染成原型那种候选行:客户名 + 期号徽标 + 订单号 + 人数。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| groupBatchId | Path | Long | ✅ | - | 转入期(本期)团期主订单 ID |
| keyword | Query | String | ❌ | - | 订单号 / 客户名 / 期号 → 模糊;**手机号 → 必须整串(11 位),前缀搜不到** |
#### 出参 `Result<List<TransferCandidateVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| orderId | String | 子订单 ID,转入时原样回传为 path 的 `orderId` |
| orderNo | String | 订单号,如 `GT-26-0085` |
| customerName | String | 客户姓名 |
| peopleCount | Integer | 该户人数(成人+儿童+小童+婴儿) |
| paidAmount | BigDecimal | 该户已付金额 |
| fromGroupBatchId | String | 源期团期主订单 ID,转入时原样回传为 body 的 `fromGroupBatchId` |
| batchNo | String | 源期团期号 |
| batchName | String | 源期期名,原型徽标「第 5 期」取此列 |
| departDate | LocalDate | 源期出发日期 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/1932847562341/transfer-candidates?keyword=%E8%B5%B5%E6%95%8F HTTP/1.1
X-Admin-Id: 1
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": [
{
"orderId": "1955001234567890",
"orderNo": "GT-26-0085",
"customerName": "赵敏",
"peopleCount": 4,
"paidAmount": 3000.00,
"fromGroupBatchId": "1932847562341",
"batchNo": "GT-26-0005",
"batchName": "第 5 期",
"departDate": "2026-10-01"
}
],
"success": true
}
```
#### 空数据 / 降级响应
无候选(同产品没有其他可转期、或关键字无命中、或本期自身状态已不可转)时返回空数组,不报错:
```json
{ "code": 200, "data": [], "success": true }
```
#### 错误响应
```json
{
"code": 589500,
"message": "团期不存在",
"success": false,
"data": null
}
```
#### 业务边界
- **候选池口径**:同 `productId` 的**其他**期(排除本期自身),且源期状态 ∈ 招募中 / 资源筹备中,
订单状态 ∈ 待支付 / 待出行——与转入接口的守卫完全同源,**列表里出现的都是能转的**。
- **本期不可转时返回空**:本期已进物资准备及以后,直接返回 `[]`,前端应据此禁用弹窗而不是靠调用结果试探。
- **不分页**:同产品可转期数 × 每期户数量级很小(原型「满 9 户满团」),一次返回全量供前端本地筛。
- **手机号是加密列**:整串走加密等值匹配,非 11 位数字的关键字走订单号 / 客户名 / 期号三路模糊。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload |
|------|---------|
| ✅ 最小请求 | `{ "fromGroupBatchId": 1932847562341 }` |
| ✅ 带原因不通知 | `{ "fromGroupBatchId": 1932847562341, "reason": "客户改期", "notify": false }` |
| ❌ 缺源期 | `{ "reason": "客户改期" }` → 非 200 业务码,message 含「源团期 ID 不能为空」 |
| ❌ 传产品侧班期 ID 当源期 | `{ "fromGroupBatchId": 92001 }` → 589500 团期不存在 |
| ❌ 源期 = 本期 | `{ "fromGroupBatchId": <path 上同一个值> }` → 589524 |
### 两个 ID 不要混用
`fromGroupBatchId` 与 path 上的 `groupBatchId` **同一口径**,都是团期主订单 ID
(候选列表返回的 `fromGroupBatchId` 直接回传即可)。不要传产品侧班期 ID。
---
## 五、数据库行为
| 动作 | 落库效果 |
|------|----------|
| 转期成功 | `order_main.product_batch_id` 改为目标期班期 ID;`order_amount` 改为目标期报价;`depart_date` / `return_date` / `trip_days` / `trip_nights` 同步刷成目标期值 |
| 团号 | `order_main.team_no` **不变**(订金支付时生成的团号跟订单走) |
| 已付 | `order_main.paid_amount` **不变** |
| 流水 | `group_batch_transfer` 新增一行(新表),记录两期 ID、人数房数、新旧应收、已付快照、原因、操作人 |
| 计数 | 源期 `enrolled_people/enrolled_rooms` 减、目标期加,总数守恒;产品域两期 `enrolled_count/booked_rooms` 同步 |
| 时间线 | 源期一条「子订单转出」、目标期一条「子订单转入」(`order_group_batch_status_log`,DATA 类) |
| 失败 | 任一步失败整事务回滚,以上全部不发生 |
---
## 六、边界行为
- 未登录 / 缺 `X-Admin-Id` → 401(网关拦截)
- 团期或子订单不存在 → 589500 / 订单域 not found,不 500
- 产品域报价或名额调用失败 → 589527 / 名额失败码 + 整事务回滚,不产生半搬状态
- 时间线写失败 → 降级 WARN,**不影响转期成功**
- 候选列表下游无数据 → 返回 `[]`,不 500 不阻断弹窗
---
## 六.5、枚举 / 数据字典
### 可转的订单状态(`com.hulalv.order.core.enums.OrderStatus`)
**所属字段**: 服务端守卫用,不在出参中 | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `PENDING_PAY` | 待支付 | 允许转期 |
| `PENDING_DEPARTURE` | 待出行 | 允许转期 |
| `TRAVELLING` | 出行中 | 拒绝(589501) |
| `COMPLETED` | 已完成 | 拒绝(589501) |
| `CANCELLED` | 已取消 | 拒绝(589501) |
### 可转的团期状态(`com.hulalv.order.groupbatch.enums.GroupBatchStatus`)
**所属字段**: 服务端守卫用,不在出参中 | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `RECRUITING` | 招募中 | 源期 / 目标期均允许 |
| `RESOURCE_PREPARING` | 资源准备中 | 源期 / 目标期均允许 |
| `MATERIAL_PREPARING` 及以后 | 物资准备中 / 待出发 / 出行中 / 核团中 / 已结算 / 已流团 | 任一方处于该区间即拒(589510) |
---
## 七、不影响范围
- **仅影响**: 管理后台团期详情的「转订单」弹窗
- **零影响**:
- 退单户 `POST .../sub-order/:orderId/withdraw`(本次未改一行)
- 创单与支付链路(不产生退款单 / 收款单,`paid_amount` 不动)
- C 端小程序全部接口
- 团期人员 / 物资 / 财务等其余团期端点
- 存量数据(新表为空表,不迁移历史)
---
## 八、测试环境已验证
✅ **已验证。** 2026-09-07 部署 dev-v3 到测试服并经网关实测(`https://api.test.1814.love:9443`,真实管理端鉴权)。
产品 2056947670512971778 的两个同产品期(源期 gbId 2096510069465088002 / 目标期 2096495107078328322),转入一户 2 人 1 房:
| # | 用例 | 期望 | 实测 |
|---|---|---|---|
| 1 | 候选查询(目标期视角) | 列出源期该户 | ✅ 返回 orderNo HL20260906160526197、2 人、fromGroupBatchId 源期 |
| 2 | 成功转期 | code 200 + 金额三件套 | ✅ oldOrderAmount 4000 / newOrderAmount 4000 / paidAmount 0 / newBalanceDue 4000 + warnings 通知提示 |
| 3 | 计数守恒 | 源期 −2/−1、目标期 +2/+1 | ✅ 源期 people/rooms 2/1 → 0/0,目标期 2/1 → 4/2 |
| 4 | 订单归属改到目标期 | 转走后源期候选不再有它 | ✅ 目标期候选查询转后为 0 条(该户已在本期) |
| 5 | **订单侧 timeline**(#7270 补) | 一条 GROUP_BATCH_TRANSFER | ✅ order status-log 事件序列 [CREATE, GROUP_BATCH_TRANSFER],content「由 {源期号} 转入 {目标期号},应收 4000.00 → 4000.00」 |
| 6 | 容量守卫拒绝路径 | 目标期满时 589526 | ✅ 修容量口径前实测触发 589526(目标期 maxParticipants=0 曾被误判满,已由 #7278 修正「0=不限」) |
候选查询四路 keyword(订单号/客户名/期号 LIKE + 手机号加密等值)由单测
`GroupBatchTransferServiceTest.phoneKeywordGoesEncryptedEqualsBranch` 等覆盖;
八类拒绝路径、时间线降级、库存补偿由该测试类 30 例覆盖。
单测:全量 `mvn -o -pl hl-order-service-v3 -am test` 8782 例 Failures 0(含 #7270 timeline、#7278 容量修复)。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #7103 | #7095 | 首次落地 GB-ADM-071 转订单两个端点 | ✅ |
| #7257 | #7250/#7095 | 修 dev-v3 测试编译中断(并发签名冲突) | ✅ |
| #7270 | #7095 | 补写订单侧 timeline(验收要点 5 漏实现) | ✅ |
| **#7278** | **#7095** | 容量守卫「0=不限」口径修正(网关实测暴露) | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#7095](https://git.1814.love:8443/wx/HL/issues/7095)
- 关联 PR: [wx/HL#7103](https://git.1814.love:8443/wx/HL/pulls/7103)
- 后续计划: 通知实发(模板 ID 到位后)、审批流、差额自动退款——均见工单 #7095 §7「明确不做」
## 关联 / 联系人
### 链接
- **Issue**: [#7095](https://git.1814.love:8443/wx/HL/issues/7095)
- **PR**: [#7103](https://git.1814.love:8443/wx/HL/pulls/7103)
- **Merge commit**: [6c1124db5](https://git.1814.love:8443/wx/HL/commit/6c1124db5)
### 联系人
- **后端负责人**: @jw
@@ -0,0 +1,471 @@
---
schema: "hl-changelog/v2"
ticket: "7105"
title: "团期合同保险面板与逐户出具"
consumer: "admin"
author: "jw(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "838e77da"
target_release: ""
verified_at: "2026-09-08"
status_note: "新增团期合同保险面板与逐户手动开/作废重开/批量催签四端点,后端已部署 TEST 并实测。注意:出具门依赖房车导摄四项 ready,而 hotel_ready 线上无写入方(#4132 未完成),故面板 issuable 恒 false、手动开/作废重开恒 589548;面板读与批量催签不受影响现在就能用。前端待接:团期详情新增合同保险 Tab(汇总计数+逐户表格+三态徽标用后端 contractStateText/insuranceStateText 不自映射),手动开/作废重开弹窗(target+可选户+逐户结果),批量催签,出具类按钮按 issuable 置灰;权限读 group-batch:view、写 group-batch:contract:issue(需先部署用户服务)。决策:整个 Tab 入 backlog 排在 #7067 U3-U7 闭环后统一汇总审派发,接入时按 issuable 置灰即可、不因 #4132 单独挂起。本条保持 pending。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 团期「合同保险」面板:逐户出具、作废重开、批量催签
> **影响范围**:管理后台「团期详情 → 合同保险」Tab。
> 当前状态:后端已部署 TEST 并实测;前端待接入。
> **请先读下面的「当前不可用的部分」再排期。**
## ⚠️ 当前不可用的部分(重要)
出具的开闸条件是**房 / 车 / 导 / 摄四项资源全部就绪**。这四项里:
| 项 | 线上有没有写入方 |
| --- | --- |
| 导游 ready | ✅ 成团时若该团不需要导游即自动置位,需要时由人员配置置位 |
| 摄影 ready | ✅ 同上 |
| 车辆 ready | ✅ 车队服务整团配车完成后回填 |
| **酒店 ready** | ❌ **没有**。回填它的那条链路挂在一个从未被发出的事件上,见 HL#4132 |
结果:**现网任何团期的四项都集不齐**,所以
- 面板的 `issuable` **恒为 false**
- 手动开 / 作废重开 **恒返 589548**
- 团期单的合同与保险**不会自动出具**
**前端可以照本文档接入,但在 HL#4132 落地前,出具相关的按钮点下去只会拿到 589548。**
面板读取与批量催签**不受此限制**,现在就能用。
这一条是产品链路的既有缺口,不是本次交付的缺陷,但它决定了本功能何时真正可用,故写在最前面。
## 一、背景
此前团期没有合同保险的集中视图,管理员要逐户点进订单才能看到谁的合同出了、谁签了、谁的保险还没投;
开错了也只能逐户处理,催签只有单张接口。本次补齐面板与三个批量动作。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
| --- | --- | --- | --- | --- | --- |
| 1 | 合同保险面板 | GET | `/v3/admin/order/group-batch/{groupBatchId}/contracts` | 新增接口 | 汇总计数 + 逐户三态明细;权限码 `group-batch:view` |
| 2 | 手动开合同/保险 | POST | `/v3/admin/order/group-batch/{groupBatchId}/contracts/issue` | 新增接口 | 逐户补开;权限码 `group-batch:contract:issue` |
| 3 | 作废重开 | POST | `/v3/admin/order/group-batch/{groupBatchId}/contracts/reissue` | 新增接口 | 先作废后重开;权限码 `group-batch:contract:issue` |
| 4 | 批量催签合同 | POST | `/v3/admin/order/group-batch/{groupBatchId}/contracts/remind-sign` | 新增接口 | 只对已出未签的户发短信;权限码 `group-batch:contract:issue` |
## 三、接口详情
### 1. 合同保险面板 `GET /v3/admin/order/group-batch/{groupBatchId}/contracts`
**VO**: `GroupBatchContractBoardVO`
#### 使用场景
进入团期详情的「合同保险」Tab 时调用,一次拿到整团的汇总计数与逐户明细。
整页只发 3 次底层查询,不随户数放大。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| groupBatchId | path | Long | 是 | 正整数 | 团期 ID |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| issuable | Boolean | 当前是否允许出具(四项已配齐)。**现网恒为 false,见文首说明** |
| batchStatus | String | 团期状态 |
| totalCount | Integer | 活跃子订单户数(已取消的户不计) |
| contractIssuedCount | Integer | 合同已出户数 |
| contractSignedCount | Integer | 合同已签户数 |
| insuranceIssuedCount | Integer | 保险已出户数 |
| contractWrongCount | Integer | 合同开错户数 |
| insuranceWrongCount | Integer | 保险开错户数 |
| items[].contractState | String | 三态:NOT_ISSUED / ISSUED / WRONG |
| items[].contractStateText | String | 三态中文名,服务端给出,前端直接显示 |
| items[].awaitingSign | Boolean | 已出未签,即催签的目标 |
| items[].contractUrl | String | 合同文件地址,未出为 null |
| items[].policyNo | String | 保单号,未出为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/2096412454643802114/contracts
Authorization: Bearer <token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"batchId": "2096412454643802114",
"issuable": false,
"batchStatus": "RESOURCE_PREPARING",
"totalCount": 55,
"contractIssuedCount": 0,
"contractSignedCount": 0,
"insuranceIssuedCount": 0,
"contractWrongCount": 0,
"insuranceWrongCount": 0,
"items": [
{
"orderId": "2096633951715033089",
"orderNo": "HL20260907001741975",
"contactName": "蒋岚妍",
"contractState": "NOT_ISSUED",
"contractStateText": "未出",
"contractStatus": null,
"contractPlanName": null,
"contractSignedAt": null,
"contractUrl": null,
"awaitingSign": false,
"insuranceState": "NOT_ISSUED",
"insuranceStateText": "未出",
"insuranceStatus": null,
"insuranceProductName": null,
"policyNo": null
}
]
},
"success": true
}
```
#### 空数据 / 降级响应
团期尚无子订单时 `totalCount` 为 0、`items` 为空数组,各计数器均为 0,页面展示空态。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"data": null,
"success": false
}
```
#### 业务边界
- **已取消的户不出现在面板里**,也不计入任何计数器。
- 三态不落库,全部由既有的合同状态与保险状态派生;后端给出中文名,前端不要自行映射。
- 读面板走只读权限,不需要出具权限。
### 2. 手动开合同/保险 `POST /v3/admin/order/group-batch/{groupBatchId}/contracts/issue`
**VO**: `GroupBatchContractIssueReqVO`
#### 使用场景
四项资源配齐后,为漏开的户补开合同与旅行意外险。正常情况下系统会自动出具,
本接口是补救通路。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| groupBatchId | path | Long | 是 | 正整数 | 团期 ID |
| target | body | String | 是 | CONTRACT / INSURANCE / BOTH | 本次要开什么 |
| orderIds | body | String[] | 否 | 子订单 ID 列表 | 不传表示整团所有活跃户 |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| totalCount | Integer | 本次处理户数 |
| successCount | Integer | 成功户数 |
| failCount | Integer | 失败户数 |
| skipCount | Integer | 跳过户数(已出具无需重复开) |
| results[].outcome | String | SUCCESS / FAILED / SKIPPED |
| results[].message | String | 失败或跳过的原因,成功为 null |
#### 请求示例
```json
{
"target": "BOTH",
"orderIds": ["2096633951715033089", "2096633958908264449"]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"totalCount": 2,
"successCount": 1,
"failCount": 1,
"skipCount": 0,
"results": [
{ "orderId": "2096633951715033089", "target": "CONTRACT", "outcome": "SUCCESS", "message": null },
{ "orderId": "2096633958908264449", "target": "INSURANCE", "outcome": "FAILED", "message": "保险下单失败,请稍后重试" }
]
},
"success": true
}
```
#### 空数据 / 降级响应
指定的户都已出具时全部记 SKIPPED,`successCount` 为 0,属正常情况不是错误。
#### 错误响应
```json
{
"code": 589548,
"message": "房/车/导/摄四项配齐后才能出具合同与保险",
"data": null,
"success": false
}
```
#### 业务边界
- **逐户独立事务**:单户失败不回滚已成功的户,也不中断后续户,前端按 `results` 逐行展示。
- **四项未配齐整单拒绝**,返回 589548 且零写入——不是部分成功。
- 五秒内重复提交会被幂等窗口拦下,返回「出具处理中,请勿重复提交」。
- 同一团期的出具动作串行执行,并发点击不会重复开。
### 3. 作废重开 `POST /v3/admin/order/group-batch/{groupBatchId}/contracts/reissue`
**VO**: `GroupBatchContractIssueReqVO`
#### 使用场景
合同方案选错、保险投保信息有误等「开错」情形:先作废原件,再按同一条链路重新开具。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| groupBatchId | path | Long | 是 | 正整数 | 团期 ID |
| target | body | String | 是 | CONTRACT / INSURANCE / BOTH | 本次要重开什么 |
| orderIds | body | String[] | 否 | 子订单 ID 列表 | 不传表示整团所有活跃户 |
| reason | body | String | 否 | 最长 512 字 | 作废原因,写入操作记录 |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| totalCount | Integer | 本次处理户数 |
| successCount | Integer | 作废并重开成功的户数 |
| failCount | Integer | 失败户数(含作废失败与重开失败,`message` 区分) |
| skipCount | Integer | 跳过户数(该户没有可作废的单据) |
| results[].orderId | String | 子订单 ID |
| results[].target | String | CONTRACT / INSURANCE |
| results[].outcome | String | SUCCESS / FAILED / SKIPPED |
| results[].message | String | 失败或跳过的原因,成功为 null |
#### 请求示例
```json
{
"target": "CONTRACT",
"orderIds": ["2096633951715033089"],
"reason": "合同方案选错"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"totalCount": 1,
"successCount": 1,
"failCount": 0,
"skipCount": 0,
"results": [
{ "orderId": "2096633951715033089", "target": "CONTRACT", "outcome": "SUCCESS", "message": null }
]
},
"success": true
}
```
#### 空数据 / 降级响应
指定的户没有可作废的单据时记 SKIPPED,不产生任何写入。
#### 错误响应
```json
{
"code": 589550,
"message": "无可用合同方案,无法出具合同",
"data": null,
"success": false
}
```
#### 业务边界
- **作废失败不进重开**:该户直接记 FAILED,绝不会出现「原件作废了但新件没开出来」的空档。
- 与「手动开」共用同一道四项配齐门,未配齐同样返 589548。
- 幂等窗口与串行锁同「手动开」。
### 4. 批量催签合同 `POST /v3/admin/order/group-batch/{groupBatchId}/contracts/remind-sign`
**VO**: `GroupBatchContractRemindReqVO`
#### 使用场景
合同已出但客户迟迟不签,管理员一次性给这些户重发签署短信。
**本接口不受四项配齐门限制,现在就能用。**
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| groupBatchId | path | Long | 是 | 正整数 | 团期 ID |
| orderIds | body | String[] | 否 | 子订单 ID 列表 | 不传表示整团所有活跃户 |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| totalCount | Integer | 本次处理户数 |
| successCount | Integer | 实际发出短信的户数 |
| skipCount | Integer | 跳过户数 |
| results[].message | String | 跳过原因 |
#### 请求示例
```json
{
"orderIds": ["2096633951715033089", "2096633958908264449"]
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"totalCount": 2,
"successCount": 0,
"failCount": 0,
"skipCount": 2,
"results": [
{ "orderId": "2096633951715033089", "target": "CONTRACT", "outcome": "SKIPPED", "message": "该户合同未出具或已签署,无需催签" },
{ "orderId": "2096633958908264449", "target": "CONTRACT", "outcome": "SKIPPED", "message": "该户合同未出具或已签署,无需催签" }
]
},
"success": true
}
```
#### 空数据 / 降级响应
无「已出未签」的户时全部 SKIPPED,`successCount` 为 0,不发任何短信。这是正常结果。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"data": null,
"success": false
}
```
#### 业务边界
- **只对「已出未签」的户发**:未出具的、已签署的一律跳过并说明原因。
- 单户发送失败不影响其他户,逐户记 FAILED。
- 只要真发出了至少一条,就写一条团期操作记录。
## 四、契约约束与正确调用方式
- **出具按钮的可用性以面板返回的 `issuable` 为准**,不要前端自行按团期状态推断。
- **三个写动作共用一个权限码** `group-batch:contract:issue`,读面板走 `group-batch:view`。
- `orderIds` 不传即整团,传了就只处理这几户;两种用法都返回逐户结果。
- **逐户结果必须逐行展示**:这三个接口都可能部分成功,只看 `code=200` 会误导操作者。
- 三态文案用后端返回的 `contractStateText` / `insuranceStateText`,不要前端自己映射枚举。
- 出具与重开有五秒幂等窗口,前端做防抖即可。
## 五、数据库行为
- 面板读取**零写入**。
- 手动开 / 重开**逐户独立事务**:每户单独提交,失败的户不回滚成功的户。
- 四项未配齐时在任何写操作之前拒绝,**零写入**。
- 重开时先作废后开具,作废失败即停在该户,不会留下「已作废未重开」的中间态。
- 出具、重开、催签各写一条团期操作记录;催签仅在真发出短信时才写。
- 无权限、幂等拦截、参数非法一律零写入。
## 六、边界行为
- 已取消的户不参与任何动作,也不出现在面板。
- 指定的户不属于本团期时按跳过处理,不整单失败。
- 同团期的出具动作串行,避免并发重复开具。
## 六.7、影响评估
- **自动出具链路新增了同一道门**:团期单在四项配齐前不再自动出具合同与保险,
未配齐时静默跳过(不建待办、不发失败通知)。散客单不受影响。
- **上线依赖部署顺序**:`group-batch:contract:issue` 权限码由用户服务的迁移注册并授予
管理员与超级管理员,**必须先部署用户服务再部署订单服务**,否则三个写动作除超级管理员外全返 589507。
- **本功能当前实际不可用**,原因见文首:酒店就绪标志线上无写入方(HL#4132)。
- 面板读与批量催签不受上述限制。
## 七、不影响范围
- 散客单(非团期单)的合同保险出具完全不变。
- 单张合同的出具、签署、作废接口不变,本次只加团期级的批量入口。
- 小程序端不受影响。
- 团期成团、名额调整、流团审批等其他动作不受影响。
## 八、测试环境已验证
TEST 环境已部署用户服务与订单服务并实测:
- 面板读取:55 户的团期返回 55 行逐户明细,计数器与 `issuable` 齐全。
- 空团期读面板:户数 0、明细空数组,不报错。
- 四项未配齐时手动开 → 589548,零写入。
- 四项未配齐时作废重开 → 589548,与手动开同一道门。
- 出具目标非法时同样先被四项配齐门拦下(门先于目标校验)。
- 批量催签指定两户 → 均跳过并给出「该户合同未出具或已签署,无需催签」,零副作用。
- 权限码已生效:读面板通过、三个写动作走到业务校验而非 589507,
说明权限已注册并授予管理员。
### 尚未验证
- **出具成功的正向路径未在网关侧实测**:需要四项配齐的团期,而酒店就绪标志线上无写入方(HL#4132),
该状态在测试环境造不出来。出具成功、逐户独立事务、作废失败不进重开等行为由单元测试覆盖(60 例全绿),
但**未经真实环境验证**,前端联调时请以实际返回为准。
- 出具目标非法的错误码 589549 因被四项配齐门先拦,同样未在真实环境命中。
## 十、相关文档
- 工单:HL#7105
- 合并:HL PR#7243
- 后续修正:HL#7248 / PR#7249——出具门原先按团期状态判定,与「合同全签才能进入物资准备」
这道既有门形成死锁,已改为直接判四项就绪标志。本文档描述的是修正后的口径。
- 阻塞项:HL#4132——团期基准变更事件未发出,导致酒店就绪标志无写入方
## 关联 / 联系人
- 后端:jw
- 前端待接:合同保险 Tab(汇总计数 + 逐户表格 + 三态徽标)、
手动开与作废重开弹窗(目标选择、可选择户、逐户结果展示)、批量催签、
出具类按钮按 `issuable` 置灰并提示原因
@@ -0,0 +1,225 @@
---
schema: "hl-changelog/v2"
ticket: "7174"
title: "定制需求管理后台接口迁移 v3 路径"
consumer: "admin"
author: "yst"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "8c5574c4"
target_release: "v2.1"
verified_at: "2026-09-08"
status_note: "前端已交付并验证(commit 8c5574c4):api/order.js 五端点 /order/customize→/v3/admin/order/customize(per-request baseURL 空串覆盖防拼 /admin/v3/,沿用 REFUND_V3 先例),accept/reply/complete 由 PUT 改 POST;ReplyModal payload reply→replyContent、productId 显式 String() 防雪花数值化;新建 orderCustomizeV3.spec 5 例锁路径/POST/第三参 baseURL/雪花字符串透传;旧 v2 路径 customize 域零残留(残留 /order/customizer* 系定制师分配端点非本域);checkpoint 全绿(Vitest 全量+生产构建)。"
updated_at: "2026-09-08"
base: "dev-v3"
---
# 定制需求管理后台接口迁移 v3 路径
管理后台「定制需求」(私人定制需求单)相关接口整体由 order-v2 迁往 order-v3,**仅路径前缀变化**,请求方法、出入参字段、业务语义与 v2 **完全一致**,前端只需替换 base 路径。
- 旧路径前缀(已下线):`/admin/order/customize`
- 新路径前缀(order-v3):`/v3/admin/order/customize`
## 二、变更接口清单
| # | 接口 | 方法 | 旧路径(已 404) | 新路径(v3) | 变更类型 |
|---:|---|---|---|---|---|
| 1 | 定制需求分页 | GET | `/admin/order/customize/page` | `/v3/admin/order/customize/page` | 路径前缀 |
| 2 | 定制需求详情 | GET | `/admin/order/customize/{requestId}` | `/v3/admin/order/customize/{requestId}` | 路径前缀 |
| 3 | 接受定制需求 | POST | `/admin/order/customize/{requestId}/accept` | `/v3/admin/order/customize/{requestId}/accept` | 路径前缀 |
| 4 | 回复定制需求 | POST | `/admin/order/customize/{requestId}/reply` | `/v3/admin/order/customize/{requestId}/reply` | 路径前缀 |
| 5 | 标记完成 | POST | `/admin/order/customize/{requestId}/complete` | `/v3/admin/order/customize/{requestId}/complete` | 路径前缀 |
## 三、接口详情
> 5 个接口入参/出参字段与 v2 完全一致,仅 base 路径由 `/admin/order/customize` 改为 `/v3/admin/order/customize`。以下以分页与回复两个典型接口为例,其余接口契约不变。
### 1. 定制需求分页 `GET /v3/admin/order/customize/page`
**VO**: `CustomizeRequestPageReqVO / PageResult<CustomizeRequestRespVO>`
#### 使用场景
管理后台定制需求列表页,按状态筛选分页查询。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `page` | Query | Number | 是 | ≥1 | 页码 |
| `pageSize` | Query | Number | 是 | 1-100 | 每页条数 |
| `status` | Query | String | 否 | 见状态枚举 | 按状态筛选,省略查全部 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.records[].id` | String | 定制需求 ID(requestId,Long 转字符串防精度丢失) |
| `data.records[].destination` | String | 目的地 |
| `data.records[].travelCount` | String | 出行人数描述(v2 契约字段名,保持) |
| `data.records[].budgetRange` | String | 预算区间(v2 契约字段名,保持) |
| `data.records[].status` | String | 状态码,见状态枚举 |
| `data.records[].statusLabel` | String | 状态中文名 |
| `data.records[].contactName` | String | 联系人 |
| `data.records[].contactPhone` | String | 联系电话(脱敏返回) |
| `data.records[].createTime` | String | 创建时间 |
| `data.total` / `page` / `pageSize` | Number | 分页元信息 |
#### 请求示例
```http
GET /v3/admin/order/customize/page?page=1&pageSize=20&status=PENDING
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [
{
"id": "2094278854020399106",
"destination": "呼伦贝尔",
"travelCount": "2 大 1 小",
"budgetRange": "5000-10000",
"status": "PENDING",
"statusLabel": "待处理",
"contactName": "张三",
"contactPhone": "138****1111",
"createTime": "2026-09-06 10:00:00"
}
],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 降级响应
无匹配时 `records=[]`、`total=0`,HTTP 200,前端正常渲染空列表。
#### 错误响应
未登录 / 登录过期:
```json
{"code":401,"message":"未登录或登录已过期","success":false,"data":null}
```
### 2. 回复定制需求 `POST /v3/admin/order/customize/{requestId}/reply`
**VO**: `CustomizeReplyReqVO / CustomizeRequestRespVO`
#### 使用场景
定制师回复客户需求,可关联已搭建的产品。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `requestId` | Path | String | 是 | 正整数 ID 字符串 | 目标定制需求 |
| `replyContent` | Body | String | 是 | 非空 | 回复内容 |
| `productId` | Body | String | 否 | 正整数 ID 字符串 | 关联产品 ID(可空) |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.id` | String | 定制需求 ID |
| `data.status` | String | 回复后为 `REPLIED` |
| `data.replyContent` | String | 回复内容 |
#### 请求示例
```json
{
"replyContent": "已为您定制呼伦贝尔 5 日亲子行程,详见关联产品。",
"productId": "2094279000000000001"
}
```
#### 错误响应
当前状态不允许回复 / 需求不存在:
```json
{"code":588102,"message":"当前状态不允许接受: {0}","success":false,"data":null}
```
```json
{"code":588101,"message":"定制需求不存在","success":false,"data":null}
```
#### 业务边界
- 状态机:PENDING(待处理)→ PROCESSING(处理中,accept)→ REPLIED(已回复,reply)→ CONVERTED(已转化,complete)/ CANCELLED(已取消)。
- 越权操作 / 非法状态转换返回 588 段错误码(见下)。
## 六.5、枚举 / 数据字典
### `status`(定制需求状态,代码枚举)
**所属字段**: `CustomizeRequestPageReqVO.status / CustomizeRequestRespVO.status` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `PENDING` | 待处理 | 用户提交后初始态 |
| `PROCESSING` | 处理中 | 定制师已接受 |
| `REPLIED` | 已回复 | 定制师已回复方案 |
| `CONVERTED` | 已转化 | 已关联产品/成单 |
| `CANCELLED` | 已取消 | 用户取消 |
## 六.6、修改前后对比
| 项目 | 修改前(order-v2) | 修改后(order-v3) |
|---|---|---|
| base 路径 | `/admin/order/customize` | `/v3/admin/order/customize` |
| 请求方法 | GET/POST | 不变 |
| 出入参字段 | travelCount/budgetRange/remark/replyContent 等 | **完全一致**(字段名保留 v2 契约) |
| 错误码段 | 582001-582005(v2) | 588101-588106(v3 新段) |
| 旧路径状态 | 可用 | **已下线,调用返回 404 接口不存在** |
## 六.7、影响评估
- **是否破坏向后兼容**:是(路径变化),前端必须把 base 路径由 `/admin/order/customize` 替换为 `/v3/admin/order/customize`;**字段零改动**,替换路径即可。
- **前端是否必须同步上线**:是。旧 v2 路径已下线,不切将 404。
- **前端 workaround 清理点**:删除对旧 `/admin/order/customize/**` 的调用,统一指向 `/v3/admin/order/customize/**`。
- **错误码注意**:v3 用新错误码段 588101-588106(v2 的 58200x 已废弃),若前端有按错误码做提示映射需同步更新。
## 七、不影响范围
- 小程序端(C 端)定制需求提交/查询链路:走 product-v2 聚合层,已在 PR #7198 完成内部 Feign 切流,对外 `/mp/custom/**` 路径不变,**小程序前端零改动**。
- 不修改权限、字典、网关路由以外行为;旧路径下线无数据迁移副作用(存量数据走独立迁移脚本,测试环境无数据)。
## 八、测试环境已验证
- TEST 网关:`/v3/admin/order/customize/page` 无 admin 头返 401、带 admin 头返 200 分页结构(`records/total/page/pageSize`)。
- 反向探活:旧 v2 路径 `/admin/order/customize/page`、`/internal/mp/customize/list` 均返回 `404 接口不存在`,确认旧实现已下线。
## 当前状态
- 后端:已部署并已验证。
- 前端:待处理(需切换 base 路径)。
## 十、相关文档
- Issue:[#7174](https://git.1814.love:8443/wx/HL/issues/7174)(Epic [#7171](https://git.1814.love:8443/wx/HL/issues/7171))
- 后端 PR:[#7183](https://git.1814.love:8443/wx/HL/pulls/7183)(v3 建域)/ [#7198](https://git.1814.love:8443/wx/HL/pulls/7198)(切流)/ [#7214](https://git.1814.love:8443/wx/HL/pulls/7214)(v2 下线)
## 关联 / 联系人
- **Epic**: [#7171](https://git.1814.love:8443/wx/HL/issues/7171)
- **Issue**: [#7174](https://git.1814.love:8443/wx/HL/issues/7174)
- **后端负责人**: @yst
- **当前状态**: 后端已就绪,前端待切换路径。
@@ -0,0 +1,733 @@
---
schema: "hl-changelog/v2"
ticket: "7189"
title: "团期看板基底改为产品全班期(含未建团行)+ 新增班期范围 scope 筛选默认未结束 + 产品页签期数改 product 侧计数"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "2eb27845"
target_release: ""
verified_at: "2026-09-08"
updated_at: "2026-09-08"
base: "dev-v3"
status_note: "前端已交付并验证(commit 2eb27845, 汇总工单项2/项4):scope 入 store filter 单一事实源默认 ONGOING,fetchSummary/fetchBatchPage/onExport 三处同传,SearchForm 加班期范围下拉即选即查,点后段桶且 scope 非 ALL 自动切 ALL,pickProduct/onResetFilter 打回 ONGOING;未建团行 groupBatchId==null 不展开/无进入团期只留新增子订单深链,孤儿行加班期已删灰标签,index.vue 行 key 补 productBatchId 修撞 key。"
---
# 团期模块:看板以产品全班期为基底 + 班期范围 scope 筛选 + 页签期数
## ⚠️ 关键变化
- **列表基底变了(传 `productId` 时)**:GB-ADM-001 分页与 GB-ADM-009 统计条改以「产品侧全部班期 ∪ 订单侧团期」为基底,返回三类行:**命中行**(有团期)、**未建团行**(`groupBatchId=null`,产品建了班期但还没人下单)、**孤儿行**(`productBatchRemoved=true`,产品侧班期已删但团期还有活跃子订单)。改前只查 `order_group_batch`,产品建了 8 期看板只显 1 期,运营点不到「新增子订单」。
- **新增筛选 `scope`**(四端点同名同义):`ONGOING` 未结束(返团日 ≥ 今天或未填)/ `FINISHED` 已结束 / `ALL` 全部。**缺省值按 `productId` 定**:有 `productId` → `ONGOING`;无 `productId` → `ALL`(老调用行集不变);board 恒默认 `ALL`。前端范围下拉默认「未结束」,**summary / export 必须与列表同传 scope**,否则统计条与列表对不上账。
- **范围 ∩ 桶是严格交集**:默认 `ONGOING` 下点后段桶 `TRIP_FINISHED` / `AUDITING` / `CHECKED`(返团日必已过)会得到空列表;前端点这三个页签时**自动把范围切成「全部」**(或提示)。
- **传 `productId` 时排序改为出发日升序**(改前 create_time 倒序),分页在内存完成,`pageSize` 仍 ≤ 100。
- 未建团行:`batchStatus=RECRUITING`(落 `RECRUIT` 桶)、`orderCount=0`、金额 0、`chips` 六项 `TODO`;`enrolledRooms` = 产品侧占房数(**含线下占位**)、`remainRooms` = `maxRooms - 占房`(不限房 → null);新增 `productBatchStatus / productBatchStatusLabel`(产品侧售卖状态:报名中 / 即将满额 / 已满额 / 已结束 / 取消中 / 已取消,与团期九态是两套口径)。前端对 `groupBatchId=null` 的行只放「新增子订单」,不展开子订单、不做任何团期操作。
- 命中行 `batchNo / batchName / batchLabel / departDate / endDate / enrollDeadline` 改取 product 实时值(改名改期即时生效),孤儿行回落订单快照。
- **子订单为 0 的行 `chips` 六项固定 `TODO`,不再为 null**(含 productId 缺省路径)。
- GB-ADM-000 产品页签 `batchCount` 改为 product 侧未删班期数(与传该 `productId` + `scope=ALL` 的列表行数一致),改前是订单侧团期数。
---
## 一、背景
### 现象
wx 2026-09-06 看测试服「团期订单」看板:产品「冻干粉发短信给」建了 8 期,看板只显 1 期(10-01,唯一有团期的班期),其余班期没有入口下单;且没有「未结束 / 已结束」筛选,历史班期与在售班期混在一起。
### 调用链
1. hl-ui `src/stores/orderV2Batch.js` `getGroupBatchPage()` → `GET /v3/admin/order/group-batch`(GB-ADM-001)→ order-v3 `GroupBatchQueryService.listPage`:`productId` 有值 → `GroupBatchMergedRowsService.listMergedRows`(Feign `GET /internal/product/group/{productId}/detail` 取产品全班期 + `order_group_batch` 该产品团期 → 合并三类行 → 内存筛选 / 出发日升序 / 内存分页);`productId` 缺省 → 原 DB 分页 + `end_date` 条件
2. 统计条 `GET /v3/admin/order/group-batch/summary`(GB-ADM-009)→ `GroupBatchBoardStatsService.summary` 同一合并基底分桶
3. 导出 `GET /v3/admin/order/group-batch/export`(GB-ADM-008)→ 仍以 `order_group_batch` 为基底(不含未建团行),`scope` 按 product 实时返团日过滤
4. 产品页签 `GET /v3/admin/order/group-batch/products`(GB-ADM-000)→ Feign `GET /internal/product/group/all` 的 `batchCount`(product 侧 `countByProductIds` 一次 in 投影)
### 地面真相(测试服 dev-v3 ac0ffb02dfc3aa6d1edbba2788ba2542a9cb686b,2026-09-07 00:47-00:49,产品 2044306857534636034「冻干粉发短信给」,product 侧 8 期:06-04 / 07-03 / 07-10 / 07-17 / 07-24 / 07-31 已返团,10-01 有团期,12-01 未建团)
| 接口 | 结果 |
|---|---|
| 分页 `scope=ALL` | 8 行出发日升序;10-01 行 `groupBatchId` 非空 `batchLabel="7"`;其余 7 行 `groupBatchId=null` `batchStatus=RECRUITING` `chips` 六项 `TODO` |
| 分页默认(=ONGOING) | 2 行(10-01、12-01);`scope=FINISHED` 6 行 |
| summary 默认 | `RECRUIT=1 FORMED=1 其余 0 total=2`;`scope=ALL` → `total=8 RECRUIT=7 FORMED=1` |
| products | 该产品 `batchCount=8` |
| board | 8 行(默认 ALL),`scope=ONGOING` 2 行;改前/改后 JSON 逐字段 diff:键集合无增减(`batchLabel` 已由 #7188 先行加入),仅 10-01 行 orderCount/enrolledRooms/enrolledPeople/remainRooms 因期间新增 4 单变化 |
| 06-04 期(线下占位 9 房 / 限 11 房,未建团) | `enrolledRooms=9 remainRooms=2` |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期看板分页(GB-ADM-001) | GET | `/v3/admin/order/group-batch` | 入参新增 + 响应新增字段 + 基底/排序变化 | 新增 `scope`;`productId` 有值时基底改合并三类行、出发日升序;`records[]` 新增 `productBatchStatus / productBatchStatusLabel / productBatchRemoved` |
| 2 | 团期看板统计条(GB-ADM-009) | GET | `/v3/admin/order/group-batch/summary` | 入参新增 + 基底变化 | 新增 `scope`;与 GB-ADM-001 同基底,未建团行计入 `RECRUIT` |
| 3 | 团期看板导出(GB-ADM-008) | GET | `/v3/admin/order/group-batch/export` | 入参新增 | 新增 `scope`;基底不变(不含未建团行);`productId` 场景新增 589515 |
| 4 | 团期看板合并层列表 | GET | `/v3/admin/order/group-batch/board` | 入参新增(可选) | 新增 `scope`,缺省 `ALL`;产品班期全删时不再返回空(孤儿行照常输出) |
| 5 | 团期产品页签(GB-ADM-000) | GET | `/v3/admin/order/group-batch/products` | 响应字段语义变化 | `batchCount` 由订单侧团期数改为 product 侧未删班期数 |
---
## 三、接口详情
### 1. 团期看板分页 `GET /v3/admin/order/group-batch`
**VO**: `GroupBatchListReqVO → Result<PageResult<GroupBatchPageItemRespVO>>`
#### 使用场景
团期看板列表;传 `productId` 时显示该产品全部班期(含未建团行),范围下拉默认「未结束」。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | query | Long(字符串) | 否 | 雪花 ID | 有值 → 基底 = 产品全班期 ∪ 订单团期;缺省 → 只查订单侧团期(行集与改前一致) |
| scope | query | String | 否 | ONGOING / FINISHED / ALL;非法值按缺省处理 | **新增**。班期范围。缺省:`productId` 有值 → ONGOING,缺省 → ALL |
| opsStage | query | String | 否 | RECRUIT / FORMED / PENDING_TRIP / TRAVELLING / TRIP_FINISHED / AUDITING / CHECKED / DISBANDED | 桶筛选;与 scope 取交集;未建团行按 RECRUITING 落 RECRUIT |
| batchStatus | query | String | 否 | 团期九态 | 未建团行按 RECRUITING 匹配 |
| month | query | String | 否 | yyyy-MM | 出发月份(未建团行按 product 侧出发日) |
| keyword | query | String | 否 | 已转义 | 班期编号 / 名称模糊(命中行 / 未建团行按 product 实时名) |
| deadlineFrom / deadlineTo | query | String | 否 | yyyy-MM-dd | 报名截止日区间 |
| page | query | Integer | 否 | 默认 1 | 页码 |
| pageSize | query | Integer | 否 | 默认 20,最大 100 | 每页条数 |
#### 出参 `Result<PageResult<GroupBatchPageItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.records[] | Array | 团期行(`records / total / page / pageSize`);传 `productId` 时按出发日升序 |
| data.records[].groupBatchId | String(Long) | **未建团行为 null**,前端据此只放「新增子订单」 |
| data.records[].productBatchId / productId | String | 三类行都有值 |
| data.records[].batchNo / batchName / batchLabel | String | 命中行 / 未建团行取 product 实时值;孤儿行取订单快照 |
| data.records[].batchStatus / batchStatusName | String | 团期九态;未建团行固定 `RECRUITING` / 招募中 |
| data.records[].productBatchStatus | String | **新增**。product 侧售卖状态 code:ENROLLING / NEARLY_FULL / FULL / FINISHED / CANCELLING / CANCELLED;孤儿行 null |
| data.records[].productBatchStatusLabel | String | **新增**。上项中文:报名中 / 即将满额 / 已满额 / 已结束 / 取消中 / 已取消 |
| data.records[].productBatchRemoved | Boolean | **新增**。孤儿行 true(product 侧班期已删) |
| data.records[].departDate / endDate / enrollDeadline | String(yyyy-MM-dd) | 命中行 / 未建团行 product 实时;孤儿行快照 |
| data.records[].maxRooms / enrolledRooms / remainRooms | Integer | 未建团行:`enrolledRooms` = product 占房数(含线下占位),`remainRooms` = maxRooms − 占房(不限房 null) |
| data.records[].orderCount / receivableAmount / receivedAmount | Integer / String | 未建团行 0 / "0" / "0"(实测金额串为 "0") |
| data.records[].chips | Object | hotel / vehicle / guide / photo / insurance / contract 六项(键名 `photo` 不是 photographer);**子订单为 0 的行六项固定 `TODO`(不再为 null)** |
| 其余字段 | — | 不变 |
#### 请求示例
```http
GET /v3/admin/order/group-batch?productId=2044306857534636034&scope=ALL&pageSize=50 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
实测(2026-09-07,只列本单相关字段;8 行中取命中行 10-01 与未建团行 12-01):
```json
{
"code": 200,
"message": "成功",
"data": {
"records": [
{
"groupBatchId": "2096412454643802114",
"productBatchId": "2052935476557328386",
"productId": "2044306857534636034",
"batchNo": "Q202610012052935476548939777",
"batchName": " 没,那你",
"batchLabel": "7",
"batchStatus": "RESOURCE_PREPARING",
"batchStatusName": "资源准备中",
"productBatchStatus": "ENROLLING",
"productBatchStatusLabel": "报名中",
"productBatchRemoved": false,
"departDate": "2026-10-01",
"endDate": "2026-10-03",
"enrollDeadline": "2026-09-30",
"maxRooms": 8,
"enrolledRooms": 4,
"remainRooms": 4,
"orderCount": 4,
"receivableAmount": "23400.00",
"receivedAmount": "4000.00",
"chips": {
"hotel": "DOING",
"vehicle": "TODO",
"guide": "TODO",
"photo": "TODO",
"contract": "TODO",
"insurance": "TODO"
}
},
{
"groupBatchId": null,
"productBatchId": "2096631555760807938",
"productId": "2044306857534636034",
"batchNo": "Q202612012096631555752419329",
"batchName": "QA-7189-1201",
"batchLabel": "8",
"batchStatus": "RECRUITING",
"batchStatusName": "招募中",
"productBatchStatus": "ENROLLING",
"productBatchStatusLabel": "报名中",
"productBatchRemoved": false,
"departDate": "2026-12-01",
"endDate": "2026-12-03",
"enrollDeadline": "2026-11-30",
"maxRooms": 4,
"enrolledRooms": 0,
"remainRooms": 4,
"orderCount": 0,
"receivableAmount": "0",
"receivedAmount": "0",
"chips": {
"hotel": "TODO",
"vehicle": "TODO",
"guide": "TODO",
"photo": "TODO",
"contract": "TODO",
"insurance": "TODO"
}
}
],
"total": 8,
"page": 1,
"pageSize": 50
},
"success": true
}
```
#### 空数据 / 降级响应
传 `productId` 且 product 侧无班期、订单侧也无团期 → `records=[]`;默认 `ONGOING` 下点后段桶(TRIP_FINISHED / AUDITING / CHECKED)交集为空也返回 `records=[]`,前端应自动切 `scope=ALL`。
```json
{
"code": 200,
"message": "成功",
"data": { "records": [], "total": 0, "page": 1, "pageSize": 20 },
"success": true
}
```
#### 错误响应
```json
{
"code": 589515,
"message": "获取团期产品列表失败,请稍后重试",
"data": null,
"success": false
}
```
`productId` 有值时依赖产品域(Feign 取全班期),产品域不可用返回 589515;`productId` 缺省不依赖产品域。另:`589507` 无操作权限。HTTP 始终 200,按 `code` 判断。
#### 业务边界
- 权限 `group-batch:view`
- `scope` 判定用 product 实时返团日(命中 / 未建团行);孤儿行用快照;返团日未填视为未结束
- 传 `productId` 时排序固定出发日升序(无出发日排最后),分页在内存完成
- 未建团行 `groupBatchId=null`:不能展开子订单、不能做任何团期操作,只能「新增子订单」(深链预填 `productId` + `productBatchId`)
### 2. 团期看板统计条 `GET /v3/admin/order/group-batch/summary`
**VO**: `Result<GroupBatchSummaryVO>`(查询参数 productId / scope / month / keyword,无请求 VO)
#### 使用场景
看板顶部桶计数;**必须与列表同传 `productId / scope / month / keyword`**。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | query | Long(字符串) | 否 | 雪花 ID | 同 GB-ADM-001 |
| scope | query | String | 否 | ONGOING / FINISHED / ALL | **新增**。缺省规则同 GB-ADM-001 |
| month / keyword | query | String | 否 | 同 GB-ADM-001 | 逐字同义 |
#### 出参 `Result<GroupBatchSummaryVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data.buckets | Object | 固定 8 键(RECRUIT / FORMED / PENDING_TRIP / TRAVELLING / TRIP_FINISHED / AUDITING / CHECKED / DISBANDED);传 `productId` 时未建团行计入 RECRUIT |
| data.total | Integer | 8 桶之和 = 同筛选下 GB-ADM-001 的 total |
| data.subOrderCount | Integer | 有效子订单合计(未建团行贡献 0) |
#### 请求示例
```http
GET /v3/admin/order/group-batch/summary?productId=2044306857534636034 HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
实测(默认 ONGOING):
```json
{
"code": 200,
"message": "成功",
"data": {
"total": 2,
"buckets": {
"RECRUIT": 1,
"FORMED": 1,
"PENDING_TRIP": 0,
"TRAVELLING": 0,
"TRIP_FINISHED": 0,
"AUDITING": 0,
"CHECKED": 0,
"DISBANDED": 0
},
"subOrderCount": 4
},
"success": true
}
```
#### 空数据 / 降级响应
无命中时 8 键全 0、`total=0`,不省略键。
```json
{
"code": 200,
"message": "成功",
"data": { "buckets": { "RECRUIT": 0, "FORMED": 0, "PENDING_TRIP": 0, "TRAVELLING": 0, "TRIP_FINISHED": 0, "AUDITING": 0, "CHECKED": 0, "DISBANDED": 0 }, "total": 0, "subOrderCount": 0 },
"success": true
}
```
#### 错误响应
```json
{
"code": 589515,
"message": "获取团期产品列表失败,请稍后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 桶计数不受 opsStage 影响(本接口不接受 opsStage)
- 传 `productId` 时与 GB-ADM-001 共用同一合并基底与筛选,逐桶 = 列表按该桶筛选的 total
### 3. 团期看板导出 `GET /v3/admin/order/group-batch/export`
**VO**: `无请求 VO(查询参数 productId / scope / month / keyword / opsStage) → CSV 文件流 text/csv`
#### 使用场景
导出当前列表;与列表同传 `scope`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| scope | query | String | 否 | ONGOING / FINISHED / ALL | **新增**。缺省规则同 GB-ADM-001;按 product 实时返团日过滤 |
| productId / month / keyword / opsStage | query | — | 否 | 不变 | 不变 |
#### 出参 `CSV 文件流`
| 字段 | 类型 | 说明 |
|------|------|------|
| (响应体) | text/csv | BOM + 表头 `团期号,期号,日期,出团日,满团名额,已售,剩余,状态,子订单数,整团应收`,列与改前一致 |
| 行集 | — | **不含未建团行**(只导有团期的行);行数 = 同 scope 下 GB-ADM-001 中 `groupBatchId` 非空的行数(例外:产品侧班期已删且 0 活跃单的残留团期会出现在导出、不出现在列表) |
| 日期列 | — | 行内「日期 / 出团日」仍是订单快照;`scope` 的未结束判定用 product 实时返团日(AC-14 实测:改期后行进入导出,日期列仍显快照) |
#### 请求示例
```http
GET /v3/admin/order/group-batch/export?productId=2044306857534636034&scope=ALL HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
文件流(Content-Type: text/csv; Content-Disposition: attachment),JSON 仅示意:
```json
{
"code": 200,
"message": "文件流(text/csv),实际响应体为下方 CSV"
}
```
实测(2026-09-07):`scope=ALL` 表头 + 1 行(10-01 团期);默认 ONGOING 1 行;`scope=FINISHED` 0 行。
```text
团期号,期号,日期,出团日,满团名额,已售,剩余,状态,子订单数,整团应收
Q202610012052935476548939777,没,那你,2026-10-01~2026-10-03,2026-10-01,8,4,4,已成团,4,23400.00
```
#### 空数据 / 降级响应
无命中只返回表头一行;命中 > 2000 行返回 `589517`。
```json
{
"code": 589517,
"message": "导出行数超过上限,请缩小筛选范围",
"data": null,
"success": false
}
```
#### 错误响应
`productId` 有值且 `scope≠ALL` 时会调产品域取实时返团日,产品域不可用返回 589515(改前导出不依赖产品域)。
```json
{
"code": 589515,
"message": "获取团期产品列表失败,请稍后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 权限 `group-batch:export`
- 导出基底与列表基底不同(不含未建团行),前端导出按钮旁如需提示请用「仅导出已建团期」
### 4. 团期看板合并层列表 `GET /v3/admin/order/group-batch/board`
**VO**: `Result<List<GroupBatchBoardItemRespVO>>`(查询参数 productId 必填、scope 可选)
#### 使用场景
以产品全部班期为基底的看板(hl-ui 当前未调用);本单只加可选 `scope`,响应结构不变。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | query | Long(字符串) | 是 | 雪花 ID | 不变 |
| scope | query | String | 否 | ONGOING / FINISHED / ALL | **新增**。缺省 `ALL`(保持既有行集) |
#### 出参 `Result<List<GroupBatchBoardItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data[] | Array | 行集:命中 / 未建团 / 孤儿三类行,出发日升序;本单结构不变 |
| data[].groupBatchId | String(Long) | 未建团行 null(不变) |
| data[].productBatchRemoved | Boolean | 孤儿行 true;**行为变化**:产品班期全部删除时不再提前返回空,仍有活跃子订单的孤儿行照常输出 |
| data[].batchLabel / batchName / batchNo / departureDate / endDate | String | 命中 / 未建团行 product 实时,孤儿行快照(不变) |
| 其余字段 | — | 不变 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/board?productId=2044306857534636034&scope=ONGOING HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
实测默认(ALL)8 行、`scope=ONGOING` 2 行;改前/改后逐字段 diff 见「地面真相」。
```json
{
"code": 200,
"message": "成功",
"data": [
{
"productBatchId": "2052935476557328386",
"groupBatchId": "2096412454643802114",
"departureDate": "2026-10-01",
"endDate": "2026-10-03",
"batchName": " 没,那你",
"batchLabel": "7",
"batchStatus": "RESOURCE_PREPARING",
"productBatchRemoved": false,
"orderCount": 4,
"enrolledRooms": 4,
"remainRooms": 4
},
{
"productBatchId": "2096631555760807938",
"groupBatchId": null,
"departureDate": "2026-12-01",
"endDate": "2026-12-03",
"batchName": "QA-7189-1201",
"batchLabel": "8",
"batchStatus": "RECRUITING",
"productBatchRemoved": false,
"orderCount": 0,
"enrolledRooms": 0,
"remainRooms": 4
}
],
"success": true
}
```
#### 空数据 / 降级响应
产品无班期且无团期 → `data=[]`。
```json
{
"code": 200,
"message": "成功",
"data": [],
"success": true
}
```
#### 错误响应
```json
{
"code": 589515,
"message": "获取团期产品列表失败,请稍后重试",
"data": null,
"success": false
}
```
另:`589507` 无操作权限。
#### 业务边界
- 与 GB-ADM-001 共用 `GroupBatchMergedRowsService` 合并逻辑,三类行判定一致
### 5. 团期产品页签 `GET /v3/admin/order/group-batch/products`
**VO**: `Result<List<GroupBatchProductItemRespVO>>`
#### 使用场景
看板左侧产品页签「N 期」徽标。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| (无) | — | — | — | — | 无查询参数,返回当前账号可见的全部团期产品 |
#### 出参 `Result<List<GroupBatchProductItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| data[].batchCount | Integer | **语义变化**:改前 = 订单侧团期数(只有下过单的班期才算),改后 = product 侧未删班期数,与传该 `productId` + `scope=ALL` 的列表行数一致 |
| 其余字段 | — | 不变 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/products HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <token>
```
#### 响应示例
实测(只列该产品):
```json
{
"code": 200,
"message": "成功",
"data": [
{
"productId": "2044306857534636034",
"productName": "冻干粉发短信给",
"productNo": "G260415004",
"category": "family",
"subtitle": "发短信给对方搞定",
"coverImageUrl": "https://hlgl-test.oss-cn-beijing.aliyuncs.com/test/material/2026/03/07/ed278d1da74c3827cb06b7f0cf6091fe.jpg",
"lineId": "2044248925572919297",
"lineName": "阿斯蒂芬撒点",
"tripDays": 3,
"tripNights": 2,
"status": "PUBLISHED",
"sortOrder": 0,
"batchCount": 8,
"regionText": null
}
],
"success": true
}
```
#### 空数据 / 降级响应
无团期产品 → `data=[]`;产品无班期 → `batchCount=0`。
```json
{
"code": 200,
"message": "成功",
"data": [],
"success": true
}
```
#### 错误响应
```json
{
"code": 589515,
"message": "获取团期产品列表失败,请稍后重试",
"data": null,
"success": false
}
```
#### 业务边界
- 徽标直接显示 `batchCount`,不要用列表 total 反推(列表默认 ONGOING 只含未结束)
---
## 四、契约约束与正确调用方式
> 四个 GET 接口均只读;本节写**前端消费规则**。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload / 处理 |
|------|---------|
| ✅ 看板默认加载 | 列表 `?productId=X`(不传 scope = ONGOING)+ 统计条 `?productId=X` 同样不传 |
| ✅ 用户切范围 | 列表 / 统计条 / 导出三处**同时**带 `scope=FINISHED` 或 `ALL` |
| ✅ 点后段桶 | `opsStage=TRIP_FINISHED / AUDITING / CHECKED` 时把范围切成 `scope=ALL` 再请求 |
| ✅ 未建团行 | `groupBatchId == null` → 只渲染「新增子订单」(深链带 `productId` + `productBatchId`),隐藏展开 / 操作列 |
| ✅ 孤儿行 | `productBatchRemoved == true` → 标「班期已删」,仍可看子订单 |
| ✅ 状态展示 | 团期态用 `batchStatus`;售卖态用 `productBatchStatusLabel`(两套口径,不要互相推导) |
| ❌ 只给列表传 scope、统计条不传 | 统计条会按默认 ONGOING,与列表对不上账 |
| ❌ 用列表 total 当页签期数 | 默认 ONGOING 不含已结束期;徽标用 `batchCount` |
| ❌ 对 `groupBatchId=null` 的行调详情 / 子订单 / 团期操作 | 后端 589500 团期不存在 |
| ❌ 把 `chips` 为 `TODO` 的未建团行当「待办」 | 未建团行没有子订单,六项 `TODO` 只是零户占位 |
### 切换状态时的必要动作
无写接口。范围切换 / 页签切换后**同时**刷新列表与统计条(同一组参数)。
---
## 五、数据库行为
无表变更、无 Flyway。读路径变化:
| 时点 | 读取 |
|------|------|
| GB-ADM-001 / 009 传 `productId` | Feign `GET /internal/product/group/{productId}/detail`(product 全班期,含 `occupiedRooms / manualOrderCount`)+ `order_group_batch` 该产品团期 → 内存合并 |
| GB-ADM-001 / 009 / 008 缺省 `productId` | `order_group_batch` 单表;`scope` 落 `end_date >= 今天 OR end_date IS NULL` / `end_date < 今天` |
| GB-ADM-000 | Feign `GET /internal/product/group/all` 的 `batchCount`(product 侧 `group_tour_batch` 按 productId 一次 in 投影计数,软删不计) |
写路径不变(建团 / 下单刷新快照见 #7188)。
---
## 六、边界行为
- 未登录 → 网关 401;无权限 → `589507`
- 传 `productId` 时产品域不可用 → `589515`(列表 / 统计条 / 导出三处;缺省 `productId` 不依赖产品域)
- `scope` 非法值(如 `scope=xxx`)→ 按缺省规则处理,不报错
- 返团日未填的班期 / 团期 → 视为未结束(ONGOING 含、FINISHED 不含)
- 默认 ONGOING 下 `opsStage` 取后段桶 → 空列表(交集),不报错
- 产品班期全删但团期仍有活跃子订单 → 列表 / board 都输出孤儿行;0 活跃单的残留团期不输出(导出仍会导出它)
- 合并行超过 500 记 WARN(护栏,不截断)
## 六.5、枚举 / 数据字典
| 枚举 | 值 | 说明 |
|------|----|------|
| scope | ONGOING / FINISHED / ALL | 未结束(返团日 ≥ 今天或未填)/ 已结束(返团日 < 今天)/ 全部 |
| productBatchStatus | ENROLLING / NEARLY_FULL / FULL / FINISHED / CANCELLING / CANCELLED | 报名中 / 即将满额 / 已满额 / 已结束 / 取消中 / 已取消(product 侧售卖态) |
| batchStatus(未建团行) | RECRUITING | 固定值,落 RECRUIT 桶 |
## 六.6、修改前后对比
### 字段级对比
| 字段 | 修改前 | 修改后 |
|------|--------|--------|
| GB-ADM-001 / 009 / 008 / board `scope` | 无 | 新增(缺省按 productId:有 → ONGOING,无 → ALL;board → ALL) |
| GB-ADM-001 `records[].productBatchStatus / productBatchStatusLabel / productBatchRemoved` | 无 | 新增 |
| GB-ADM-001 `records[].groupBatchId` | 恒非空 | 未建团行 null |
| GB-ADM-001 `records[].chips`(0 子订单行) | null | 六项 `TODO` |
| GB-ADM-001 `records[].batchNo / batchName / 日期`(命中行) | 订单快照 | product 实时 |
| GB-ADM-000 `data[].batchCount` | 订单侧团期数 | product 侧未删班期数 |
| product `GET /internal/product/group/all` `batchCount`、`/group/{productId}/detail` `batches[].occupiedRooms / manualOrderCount` | 无 | 新增(内部接口,前端不直接调) |
### 行为级对比
| 场景 | 修改前 | 修改后 |
|------|--------|--------|
| 传 productId 的列表 | 只显有团期的班期(8 期显 1 期) | 显全部班期(8 行),未建团行可「新增子订单」 |
| 传 productId 的排序 | create_time 倒序 | 出发日升序 |
| 默认范围 | 无范围概念,全部 | 传 productId 默认只显未结束;缺省 productId 仍全部 |
| 统计条 | 只统计有团期的 | 与列表同基底,未建团行计入 RECRUIT |
| 产品班期全删 | board 返回空(有客人的团期被藏) | 孤儿行照常输出 |
| 页签期数 | 下过单的班期数 | 产品班期数 |
## 六.7、影响评估
- 向后兼容:缺省 `productId` 的调用行集与排序不变;传 `productId` 的老调用**行数与排序会变**(多出未建团行、按出发日升序、默认只含未结束),hl-ui 看板必须按第四节处理 `groupBatchId=null`
- 前端是否必须同步上线:**是**(否则看板会出现不可操作的未建团行、点后段桶得空列表)
- 数据:无表变更;依赖产品域可用性(589515)
---
## 七、不影响范围
- **仅影响**: 团期看板列表 / 统计条 / 导出 / board / 产品页签
- **零影响**:
- 团期详情 GB-ADM-002、子订单 GB-ADM-003、芯片明细 GB-ADM-092/093(仍按 groupBatchId 查)
- 创单 / 报价 / 状态机 / 按日推进 job(#7190,仍 PAUSED)
- 小程序、product 管理端班期列表
---
## 八、测试环境已验证
真实接口输出(测试服 api.test.1814.love,2026-09-07 00:47-00:49,登录后切 ADMIN 角色):
```
GET /v3/admin/order/group-batch?productId=2044306857534636034&scope=ALL&pageSize=50 → 200, total=8,出发日升序 06-04..12-01;10-01 行 groupBatchId=2096412454643802114 batchLabel="7";其余 7 行 groupBatchId=null batchStatus=RECRUITING orderCount=0 receivableAmount="0" chips 六项 TODO productBatchStatus 有值 ✓
GET /v3/admin/order/group-batch?productId=2044306857534636034&pageSize=50 → 200, 默认 ONGOING total=2(10-01、12-01)✓;scope=FINISHED total=6 ✓
GET /v3/admin/order/group-batch/summary?productId=2044306857534636034 → RECRUIT=1 FORMED=1 其余 0 total=2 subOrderCount=4 ✓;scope=ALL → total=8 RECRUIT=7 FORMED=1 ✓
GET /v3/admin/order/group-batch?productId=…&scope=ALL&opsStage=RECRUIT → total=7 = summary(ALL).RECRUIT ✓;默认范围 opsStage=RECRUIT → total=1(12-01)= summary.RECRUIT ✓
GET /v3/admin/order/group-batch?productId=…&scope=ALL&month=2026-10 → total=1(10-01)✓;keyword=没 → total=1(10-01)✓
GET /v3/admin/order/group-batch/products → 该产品 batchCount=8 ✓
GET /v3/admin/order/group-batch?pageSize=100(缺省 productId) → 改前/改后 20 行 groupBatchId 集合与顺序完全一致 ✓;scope=ONGOING 全部 endDate ≥ 今天 ✓;行内差异仅 chips(0 单行 null→六项 TODO,16 行)与 10-01 行计数(期间他人新下 4 单)
GET /v3/admin/order/group-batch/board?productId=2044306857534636034 → 8 行,改前/改后逐字段 diff:键集合无增减,仅 10-01 行 orderCount/enrolledRooms/enrolledPeople/remainRooms 因新增 4 单变化 ✓;scope=ONGOING → 2 行 ✓
GET /v3/admin/order/group-batch/export?productId=…&scope=ALL|默认|FINISHED → 数据行 1 / 1 / 0 = 列表同 scope 下 groupBatchId 非空行数 ✓
GET /v3/admin/order/group-batch?productId=2061640294608134146&scope=ALL → 造数 maxRooms=0+manual=3 期 enrolledRooms=3 remainRooms=null ✓;maxRooms=5+manual=2 期 enrolledRooms=2 remainRooms=3 ✓;P1 06-04 期(线下 9/11)enrolledRooms=9 remainRooms=2 ✓
AC-14:PUT /admin/product/item/2056944943066132481/schedule 把班期 2089667211995115522 出发日 09-05→09-13(返团日昨天→下周),不下单:分页默认 total 8→9 且含团期 2089667627927437313(departDate=2026-09-13 endDate=2026-09-14)✓、summary FORMED 5→6 ✓、export 数据行 6→7 含该班期号 ✓;随后改回 09-05,三处恢复 8 / 5 / 6 ✓
```
验证产品:`productId=2044306857534636034`(冻干粉发短信给,8 期)、AC-13 载体 `2061640294608134146`(副本,造 maxRooms=0/manual=3 与 maxRooms=5/manual=2 两期)、AC-14 载体 `2056944943066132481` 班期 `2089667211995115522`(返团日昨天 → 改出发日到下周 → 三处出现 → 改回)。单测:order-v3 全量 `JAVA_TOOL_OPTIONS=-Xmx3g mvn -pl hl-order-service-v3 test` 8658/0/0(Skipped 7,ArchTest 六道门禁全绿)、product-v2 全量 1556/0/0;触点定向 ConverterTest 49 / QueryServiceTest 37 / ConsoleQueryServiceTest 27 / BoardStatsServiceTest 21 / MergedRowsServiceTest 18 / MapperIT 17(Testcontainers)/ ScopeResolverTest 9 / QueryControllerTest 8 / BoardStatsControllerTest 7 = 234/0/0。部署:Deploy Panel 00:47-00:49 product-v2 与 order-v3 滚动完成,分支 dev-v3。
---
## 十、相关文档
- 关联 Issue: [wx/HL#7189](https://git.1814.love:8443/wx/HL/issues/7189)
- 关联 PR: [wx/HL#7219](https://git.1814.love:8443/wx/HL/pulls/7219)
- 同现场: #7188(batchLabel「第N期」)、#7190(TRIP_FINISHED 九态 / 八桶)、#7204(导/摄芯片零指派口径)
- 前端缺陷 changelog: `06_frontend_团期看板展开行子订单列表恒空-前端缺陷-管理后台.md`
## 关联 / 联系人
### 链接
- **Issue**: [#7189](https://git.1814.love:8443/wx/HL/issues/7189)
- **PR**: [#7219](https://git.1814.love:8443/wx/HL/pulls/7219)
- **Merge commit**: [ac0ffb02](https://git.1814.love:8443/wx/HL/commit/ac0ffb02dfc3aa6d1edbba2788ba2542a9cb686b)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,530 @@
---
schema: "hl-changelog/v2"
ticket: "7196"
title: "团期流团改为申请与审批"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "20905206"
target_release: ""
verified_at: "2026-09-08"
status_note: "流团由「点完就退款、不可撤销」改为「先申请、批准后才执行」。发起流团端点语义变更且理由改必填,新增审批中心列表/详情/同意/拒绝四端点+权限校验+出行后阶段限制。后端已部署 TEST 实测 R1-R10 全过。前端已识别为待接入项(团期运营域),按「先完成任务清单」排在 #7067 U2-U7 与上一批团期增量(#7188/7189/7190/7210 等)之后,本条保持 pending。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 团期流团:改为「申请 + 审批」,批准后才退定金
> **影响范围**:管理后台「团期详情 → 发起流团」与「订单管理 → 审批中心」。
> 当前状态:后端已部署 TEST 并实测;前端待接入(**发起流团端点语义已变更**)。
## ⚠️ 关键变化
1. **流团不再是点完就执行**。原先点一下就把团期置为已取消、批量退各户定金、释放配车占用,
**不可撤销**;现在只提交一条待审批的申请,**团期、子订单、名额、钱四项零变动**,
**批准后才真正执行**。
2. **发起流团端点语义变更**:`POST .../disband` 由「立即流团」改为「提交流团申请」,
且**流团理由由可空改为必填**(原先允许不带请求体直接流团)。
3. **新增四个端点**:审批中心列表、申请详情、**同意**、**拒绝**(拒绝原因必填)。
4. **新增权限校验**:发起流团需要团期运营写权限(此前该接口无任何权限校验)。
5. **新增阶段限制**:出行后不可发起流团。
## 一、背景
流团意味着整期解散并退还各户定金,是不可逆的重动作,此前却没有任何审批与权限把关,
点一下就生效。原型明确要求走公司管理审批:团期管理员发起 → 抄送定制师知会 →
负责人确认,**批准后**才自动原路退定金。本次把这条链路补齐。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
| --- | --- | --- | --- | --- | --- |
| 1 | 发起流团申请 | POST | `/v3/admin/order/group-batch/{groupBatchId}/disband` | 修改接口 | 由「立即流团」改为「提交申请」;理由改为必填;新增权限与阶段限制 |
| 2 | 审批中心列表 | GET | `/v3/admin/order/group-batch/approvals/page` | 新增接口 | 流团与退单户两类共用,可按类型筛选 |
| 3 | 流团申请详情 | GET | `/v3/admin/order/group-batch/disband/{approvalId}` | 新增接口 | 审批链、快照、批复信息 |
| 4 | 同意流团 | POST | `/v3/admin/order/group-batch/disband/{approvalId}/approve` | 新增接口 | 批准后才真正执行流团 |
| 5 | 拒绝流团 | POST | `/v3/admin/order/group-batch/disband/{approvalId}/reject` | 新增接口 | 只改申请单状态,团期继续正常招募 |
## 三、接口详情
### 1. 发起流团申请 `POST /v3/admin/order/group-batch/{groupBatchId}/disband`
**VO**: `DisbandSubmitReqVO`
#### 使用场景
团期管理员在团期详情点「发起流团(走审批)」,填写流团理由后提交。
提交只建一条待审批的申请单,**团期状态、子订单、名额、钱一律不动**,等审批结果。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| groupBatchId | path | Long | 是 | 正整数 | 团期 ID |
| reason | body | String | 是 | 非空白,最长 512 字 | 流团理由。**本次由可空改为必填** |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| approvalId | String | 审批单 ID |
| approvalStatus | String | 固定 PENDING |
| affectedOrderCount | Integer | 影响子订单户数(提交时快照) |
| estimatedRefundAmount | BigDecimal | 退还定金合计(提交时快照,仅供展示) |
| applicantName | String | 发起人姓名 |
| ccUserNames | String[] | 抄送定制师姓名列表(去重) |
#### 请求示例
```json
{
"reason": "临近出团仍未达最低成团数(6 户)"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"approvalId": "2096779558547087362",
"groupBatchId": "2096779382436651009",
"batchNo": "GT-26-0004",
"approvalStatus": "PENDING",
"reason": "临近出团仍未达最低成团数(6 户)",
"affectedOrderCount": 3,
"estimatedRefundAmount": 32900.00,
"applicantName": "卜丽颖",
"ccUserNames": ["李雯", "王浩"]
},
"success": true
}
```
#### 空数据 / 降级响应
本期尚无子订单时也可发起:影响户数为 0、退还定金为 0、抄送名单为空数组,属正常情况。
#### 错误响应
```json
{
"code": 589543,
"message": "该团期已有流团申请在审批中,请勿重复提交",
"data": null,
"success": false
}
```
#### 业务边界
- **提交零副作用**:团期状态、子订单、名额、钱四项均不变动。
- **同期至多一条在途申请**,重复提交返回 589543。
- **出行后不可发起**,返回 589544;可发起的阶段为招募中、资源准备中、物料准备中、待出发。
- 需要团期运营写权限,无权限返回 589507。
- 五秒内重复点击会先被幂等窗口拦下,返回提交中提示,属预期。
### 2. 审批中心列表 `GET /v3/admin/order/group-batch/approvals/page`
**VO**: `GroupBatchApprovalItemRespVO`
#### 使用场景
审批中心页面加载时调用,一次拿到流团与退单户两类申请,可按类型与状态筛选。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| bizType | query | String | 否 | DISBAND / WITHDRAW | 业务类型;不传表示两类都要 |
| approvalStatus | query | String | 否 | PENDING / APPROVED / REJECTED | 审批状态;不传表示全部 |
| groupBatchId | query | Long | 否 | 正整数 | 按团期筛选 |
| pageNum | query | Integer | 否 | 从 1 开始 | 页码,默认 1 |
| pageSize | query | Integer | 否 | 1-100 | 每页条数,默认 20 |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| bizType | String | DISBAND 流团 / WITHDRAW 退单户 |
| bizTypeText | String | 类型中文名,服务端给出 |
| approvalStatus | String | 审批状态 |
| batchNo | String | 团期号 |
| orderId | String | 被退子订单 ID;流团为 null |
| affectedOrderCount | Integer | 影响户数;退单户为 null |
| estimatedRefundAmount | BigDecimal | 预计退款额 |
| applicantName | String | 发起人 |
| approvedByName | String | 批复人;未批复为 null |
#### 请求示例
```http
GET /v3/admin/order/group-batch/approvals/page?bizType=DISBAND&approvalStatus=PENDING&pageNum=1&pageSize=20
Authorization: Bearer <token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"list": [
{
"approvalId": "2096779558547087362",
"bizType": "DISBAND",
"bizTypeText": "流团",
"batchNo": "GT-26-0004",
"approvalStatus": "PENDING",
"orderId": null,
"affectedOrderCount": 3,
"estimatedRefundAmount": 32900.00,
"applicantName": "卜丽颖",
"approvedByName": null
}
],
"total": 4,
"page": 1,
"pageSize": 20
},
"success": true
}
```
#### 空数据 / 降级响应
无待办时返回空列表且 total 为 0,页面展示「暂无审批事项」。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限",
"data": null,
"success": false
}
```
#### 业务边界
- 两类申请共用一套列表;差异只在 `orderId`(退单户有值)与 `affectedOrderCount`(流团有值)。
- 按提交时间倒序;每页最多 100 条。
### 3. 流团申请详情 `GET /v3/admin/order/group-batch/disband/{approvalId}`
**VO**: `DisbandApprovalRespVO`
#### 使用场景
审批人在审批中心点开一条流团申请,或团期详情的「流团审批中 / 已被驳回」横幅取详情。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| approvalId | path | Long | 是 | 正整数 | 审批单 ID |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| approvalStatus | String | PENDING / APPROVED / REJECTED |
| reason | String | 流团理由 |
| affectedOrderCount | Integer | 影响户数(提交时快照) |
| estimatedRefundAmount | BigDecimal | 退还定金合计(提交时快照) |
| actualRefundAmount | BigDecimal | **实际退款合计**,批准执行后按真实取消结果回填 |
| applicantName | String | 发起人 |
| ccUserNames | String[] | 抄送定制师 |
| approvedByName | String | 批复人 |
| approveRemark | String | 批复备注 / 拒绝原因 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/disband/2096779558547087362
Authorization: Bearer <token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"approvalId": "2096779558547087362",
"approvalStatus": "APPROVED",
"reason": "临近出团仍未达最低成团数(6 户)",
"affectedOrderCount": 3,
"estimatedRefundAmount": 32900.00,
"actualRefundAmount": 32900.00,
"applicantName": "卜丽颖",
"ccUserNames": ["李雯", "王浩"],
"approvedByName": "刘涛",
"approveRemark": "确认无法成团,同意流团"
},
"success": true
}
```
#### 空数据 / 降级响应
未批复时 `approvedByName` / `approveRemark` / `actualRefundAmount` 均为 null,前端按「审批中」渲染。
#### 错误响应
```json
{
"code": 589545,
"message": "流团申请不存在",
"data": null,
"success": false
}
```
#### 业务边界
- 只认流团类申请;拿退单户的单号来查会返回 589545,两类端点互不串用。
### 4. 同意流团 `POST /v3/admin/order/group-batch/disband/{approvalId}/approve`
**VO**: `ApproveDisbandReqVO`
#### 使用场景
审批人在审批中心点「同意」。**只有走到这一步,流团才真正执行**:
团期置为已取消、按规则退各户定金、释放配车占用。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| approvalId | path | Long | 是 | 正整数 | 审批单 ID |
| remark | body | String | 否 | 最长 512 字 | 批复备注,**可选** |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| approvalStatus | String | 变为 APPROVED |
| actualRefundAmount | BigDecimal | 实际退款合计,按执行后的真实取消结果回填 |
| approvedByName | String | 批复人姓名 |
| approveRemark | String | 批复备注 |
#### 请求示例
```json
{
"remark": "确认无法成团,同意流团"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"approvalId": "2096779558547087362",
"approvalStatus": "APPROVED",
"actualRefundAmount": 32900.00,
"approvedByName": "刘涛",
"approveRemark": "确认无法成团,同意流团"
},
"success": true
}
```
#### 空数据 / 降级响应
本期无有效子订单时实际退款合计为 0,流团照常完成。
#### 错误响应
```json
{
"code": 589546,
"message": "该流团申请已处理,不可重复操作",
"data": null,
"success": false
}
```
#### 业务边界
- **只有待审批的申请可被处理**;已同意 / 已拒绝的再点返回 589546。
- **并发下只有一人成功**:两名审批人同时点,另一人拿 589546,且**绝不会重复执行流团**。
- 需要管理员角色,否则返回 589547。
- **实际退款合计按执行后的真实取消结果统计**,不等于提交时的预计金额,
两者不一致的常见原因见「不影响范围」的说明。
### 5. 拒绝流团 `POST /v3/admin/order/group-batch/disband/{approvalId}/reject`
**VO**: `RejectDisbandReqVO`
#### 使用场景
审批人认为该期还能继续招募,点「拒绝」并写明原因。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
| --- | --- | --- | --- | --- | --- |
| approvalId | path | Long | 是 | 正整数 | 审批单 ID |
| remark | body | String | 是 | 非空白,最长 512 字 | **拒绝原因必填** |
#### 出参
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| approvalStatus | String | 变为 REJECTED |
| approvedByName | String | 批复人姓名 |
| approveRemark | String | 拒绝原因 |
#### 请求示例
```json
{
"remark": "已有两户确认报名,继续招募"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"approvalId": "2096779558547087362",
"approvalStatus": "REJECTED",
"approvedByName": "刘涛",
"approveRemark": "已有两户确认报名,继续招募"
},
"success": true
}
```
#### 空数据 / 降级响应
拒绝不产生任何业务数据,响应中与执行相关的字段(实际退款合计)保持为空。
#### 错误响应
```json
{
"code": 400,
"message": "拒绝原因不能为空",
"data": null,
"success": false
}
```
#### 业务边界
- **拒绝只改申请单状态**:团期、子订单、名额、钱四项一律不动,团期继续正常招募。
- 拒绝后**可以再次发起**流团申请。
- 拒绝原因会同时写进团期操作记录,在团期详情里可直接看到。
## 四、契约约束与正确调用方式
- **发起流团必须带理由**。原先允许不带请求体直接流团的调用方式**已失效**,会返回参数校验错误。
- **提交 ≠ 已流团**。提交后团期仍在原状态,前端应把按钮切成「流团审批中」并置灰,
同时展示「去审批中心」的入口;不要在提交成功后就把团期显示成已取消。
- **同意与拒绝是两个端点**:同意的备注可选,拒绝的原因必填,前端拒绝弹窗需做必填校验。
- **审批中心一个列表拿两类**,用 `bizType` 区分与筛选,不需要分别请求再合并。
- **五秒幂等窗口**:连点会先返回「提交中,请勿重复提交」,这不是业务错误,前端做防抖即可。
- 展示金额时请区分两个字段:`estimatedRefundAmount` 是提交时的预计值(用于弹窗展示),
`actualRefundAmount` 是批准执行后的实际结果(用于结果页与对账)。
## 五、数据库行为
- 发起流团**只新增一条待审批的申请单**,记录理由、影响户数、退还定金合计、
发起人与抄送定制师名单;**不改团期、不改子订单、不动名额与钱**。
- 同意时**先把申请单落为已通过,再执行流团**;若该单已被他人处理,则整笔拒绝且**不执行流团**。
- 执行流团后**回读实际取消结果**再回填实际退款合计——不拿「预计金额」冒充实退。
若存在已付款但未被取消的户,服务端会打出告警日志,便于人工捞出处理。
- 拒绝**只改申请单状态与批复信息**,另写一条团期操作记录(含拒绝原因)。
- 发起、拒绝、以及流团执行本身各写一条团期操作记录,均可查到操作人与时间。
- 被拒绝或被拒收的请求(重复提交、阶段不允许、已处理、无权限)**一律零写入**。
## 六、边界行为
- 同期至多一条在途申请;驳回后可重新发起,判重只拦在审批中的那条。
- 出行后不可发起流团;已流团的团期再次发起同样被拦。
- 并发审批由串行锁与状态校验双重保证,同一条申请只会被处理一次,流团只会执行一次。
- 本期无有效子订单时也可发起与批准,影响户数与金额均为 0。
## 六.6、修改前后对比
| 项 | 修改前 | 修改后 |
| --- | --- | --- |
| 发起流团的效果 | **点完即执行**:团期取消、批量退定金、释放配车,不可撤销 | **只建申请单**,四项零变动,批准后才执行 |
| 流团理由 | 可空,甚至可以不带请求体 | **必填**,为空拒绝 |
| 权限 | **无任何校验** | 需要团期运营写权限 |
| 阶段限制 | 只要不是已结算 / 已取消都能流 | **出行后不可发起** |
| 审批 | 无 | 同意 / 拒绝两个操作,拒绝原因必填 |
| 审批可见性 | 无 | 审批中心可见,流团与退单户两类共用列表 |
| 返回值 | 空 | 返回申请单快照(含影响户数、退还定金、抄送名单) |
## 六.7、影响评估
- **发起流团端点不兼容**:语义由「立即执行」变为「提交申请」,且理由必填。
前端必须同步改造:提交后不能再当作已流团处理,需展示审批中状态;
看板上旧的「确认流团」二次确认入口(文案写「点完即退款、不可撤销」)**应下线或改走同一条流**,
否则与审批语义冲突。
- **权限是行为变更**:此前任何登录管理员都能流团,现在需要团期运营写权限;
该权限已在既有工单中建立并授予管理员与超级管理员,无需额外配置。
- **阶段限制是行为收紧**:出行后不再允许流团。
- 新增四个端点均为增量,不影响任何既有接口。
- **审批人范围**:本期由管理员审批。原型里写的「部门负责人」角色目前系统中不存在,
待该角色落地后可再收窄。
## 七、不影响范围
- 退单户(单户退团)的申请与审批流程完全不变,仅新增了一个可同时查看两类的列表。
- 团期成团、调整满团名额、需求审核等其他动作不受影响。
- 小程序端不受影响,改动全部在管理后台侧。
- **本次不改动退款的执行口径**。需要特别说明一个既有限制:
流团执行时,**只有尚未付款的户会被取消**,已付定金的户会被跳过、既不取消也不退款,
与「自动原路退定金」的承诺不符。这是本次改造之前就存在的问题,本单未修,
但已让实际退款合计如实反映真实结果、并对被跳过的已付款户打出告警,便于人工兜底。
该问题建议单独排期修复。
- **批准后的客户通知(公众号 / 短信)本期不做**,仍需人工通知客户;抄送定制师目前只记录名单、不推送消息。
## 八、测试环境已验证
TEST 环境已部署订单服务并实测通过:
- 发起流团申请成功,返回影响户数与退还定金快照,**团期状态保持不变**。
- 流团理由为空被拒绝。
- 重复发起被拒绝(同期至多一条在途)。
- 拒绝时原因为空被拒绝;正常拒绝后**团期状态不变**,可再次发起。
- 已处理的申请再次同意或拒绝均被拒绝。
- **同意后团期才变为已取消**,未付款子订单同步取消,实际退款合计按真实结果回填。
- 已流团的团期再次发起被阶段限制拦下。
- 审批中心列表返回两类共 7 条,按类型筛选分别为流团 4 条、退单户 3 条,类型中文名正确。
- 申请详情回读齐全:快照、抄送名单、批复人与批复备注。
## 十、相关文档
- 工单:HL#7196
- 合并:HL PR#7201(已合入 dev-v3)
- 关联:HL#7100(团期退单户审批)——本单复用其审批单模型与审批范式;
HL#7158 / HL#7178——复用其建立的团期运营写权限与阶段限制写法
## 关联 / 联系人
- 后端:jw
- 前端待接:发起流团弹窗(必填理由、审批链展示、提交后切「审批中」)、
团期详情的审批中与已驳回两条横幅、审批中心列表与同意/拒绝操作(拒绝原因必填)、
看板旧「确认流团」入口下线或改走审批流
@@ -0,0 +1,549 @@
---
schema: "hl-changelog/v2"
ticket: "7203"
title: "车务显式声明「整段不用车」:声明 / 撤销 / 回显三个端点"
consumer: "admin"
author: "wx(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "7d21f35a"
target_release: ""
verified_at: "2026-09-07"
status_note: "前端已随 #7067 U5 一并交付并验证(commit 7d21f35a):新增 useNoVehicleDeclaration 承接声明/撤销/回显三端点,落向导第②步;声明四入参(orderId/requestId ≤64 幂等/expectedRequirementVersion/reason ≤200 非空)齐;声明侧 605905(触发刷新)/605916/605917/605918(换键)/605922、撤销侧 605035/605047/605919/605920(提示+GET 一次)/605921/605923/605924 分流,三码(605921 已核实未接受可重派/605923 已被订单侧接受不可重派需对账/605924 不确定需对账)文案互不相同绝不合并;REVOKED 时 syncStatus=null 不展示同步状态、撤销仅 SYNCED 可点(模板 v-if+composable 双门禁);canDeclare=assign 模式+canAssign+零非 canceled/exception 派车行+gate.declared!==true(null 不硬拦)+无生效声明。fleet+api 737 用例全绿+checkpoint 全绿(含 Vitest 全量+生产构建)。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 车务派单:显式声明「整段不用车」(管理后台)
> **服务**: hl-fleet-service (8087)
> **PR**: #7260
> **Issue**: #7203
> **日期**: 2026-09-07
> **影响范围**: 管理后台车务派单向导——需要新增「整段不用车」的声明入口、撤销入口与状态回显
---
## ⚠️ 关键变化
**纯新增,不改任何既有接口的请求或响应。** 但有三件事必须在做交互前先定下来,否则会做出错的 UI:
- **`syncStatus` 不是订单状态**。`SYNCED` 只表示「快照送到 order-v3 了」,**不代表订单车控一定是完成态**——
order-v3 可能按自己的规则忽略了这次回调(订单已取消、版本落后)。要展示订单状态请读订单侧的字段。
- **`declarationStatus=REVOKED` 时 `syncStatus` 为 `null`**,此时**不要展示同步状态**。
- **撤销按钮不是任何时候都能点**:只有 `syncStatus=SYNCED` 才可点。`PENDING` 会返回 605920(提示稍后重试),
`FAILED` 会分流到三个错误码(见错误码表),对应三种应对方式。**只有 605921 分支不需要先撤销**,其他两码必须走运维对账。
---
## 一、背景
#7067 去槽位化后,「某天不配车 = 该日没有这条日行」,占位行整体退役。于是「车务看完订单后决定整段
一辆车都不派」这件事,在车管侧表现为**该需求下零条派车行**——而这个状态与「还没开始排车」在数据上
完全无法区分。快照生成门禁因此不生成任何方案快照,订单侧收不到回调,用车需求永久卡在「处理中」。
| 维度 | 「还没排车」 | 「决定整段不用车」 |
|------|--------------|--------------------|
| 派车行数量 | 0 | 0 |
| 需求是否已被车务处理 | 否 | 是 |
| 订单车控应该流转到 | 保持处理中 | 完成 |
两者在库里逐字相同,**不可能靠零行推断**——按零行自动推断的话,任何一个刚进车务、还没人碰的订单
都会被误判成「不用车」并推成完成。所以本单新增一个需求级的显式动作来承载这个语义。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 声明整段不用车 | POST | `/admin/fleet/assignments/requirements/{requirementId}/no-vehicle` | 新增 | 车务显式声明本需求整段不派车 |
| 2 | 撤销不用车声明 | DELETE | `/admin/fleet/assignments/requirements/{requirementId}/no-vehicle` | 新增 | 把需求拉回处理中以便重新排车 |
| 3 | 回显不用车声明 | GET | `/admin/fleet/assignments/requirements/{requirementId}/no-vehicle` | 新增 | 刷新页面后回显声明状态与原因 |
三个端点都在 `/admin/fleet/**` 通配路由下,网关无需新增配置。权限与其它车务写口一致:
`VEHICLE_MANAGER` / `SUPER_ADMIN`。
---
## 三、接口详情
### 1. 声明整段不用车 `POST /admin/fleet/assignments/requirements/{requirementId}/no-vehicle`
**VO**: `NoVehicleDeclarationReqVO` → `Result<NoVehicleDeclarationRespVO>`
#### 使用场景
派单向导第②步。车务确认这一单整段都不需要派车(客人自驾、当地已安排车辆、行程改为不含用车),
点「整段不用车」并填写原因。声明成功后订单侧的用车需求流转到完成,订单可以继续走后续流程。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| requirementId | path | Long | 是 | 雪花 ID | 当前生效用车需求 ID |
| orderId | body | Long | 是 | 与 `requirementId` 做归属校验 | 订单 ID,不匹配按需求过期拒绝(605905) |
| requestId | body | String | 是 | ≤64 字符,非空 | 幂等键。同键同参重放返回同一结果且只产生一次快照;同键异参返回 605918 |
| expectedRequirementVersion | body | Integer | 是 | 须等于当前需求版本 | 不匹配返回 605905 |
| reason | body | String | 是 | ≤200 字符,非空 | 不用车原因,透传给订单侧作为 `noVehicleReason` |
#### 出参 `Result<NoVehicleDeclarationRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| requirementId | String | 用车需求 ID(雪花,序列化为字符串) |
| declarationStatus | String | 本地声明结果:`DECLARED` / `REVOKED` |
| syncStatus | String | 与订单侧的同步结果:`PENDING` / `SYNCED` / `FAILED`;`REVOKED` 时为 `null` |
| snapshotRevision | Long | 本次声明产生的方案快照版本(小整数,JSON number) |
| reason | String | 不用车原因 |
| revokeReason | String | 撤销原因;未撤销时为 `null` |
| declaredAt | LocalDateTime | 声明时间 |
| revokedAt | LocalDateTime | 撤销时间;未撤销时为 `null` |
#### 请求示例
```json
POST /admin/fleet/assignments/requirements/2096644145371033602/no-vehicle
{
"orderId": 2096643451159195650,
"requestId": "no-vehicle-20260907-001",
"expectedRequirementVersion": 1,
"reason": "客人自驾,全程不需要用车"
}
```
#### 响应示例
```json
{
"code": 200,
"success": true,
"data": {
"requirementId": "2096644145371033602",
"declarationStatus": "DECLARED",
"syncStatus": "SYNCED",
"snapshotRevision": 15,
"reason": "客人自驾,全程不需要用车",
"revokeReason": null,
"declaredAt": "2026-09-07 13:20:11",
"revokedAt": null
}
}
```
#### 空数据 / 降级响应
本端点不存在空数据形态:要么成功返回声明记录,要么按下表报错。
订单上下文拉取失败时不降级(fail-closed)——大交通是否声明接送机这件事一旦读成 `null` 就会误放行,
所以拉取失败直接报错而不是当作「无声明」。
#### 错误响应
```json
{
"code": 605916,
"success": false,
"message": "该用车需求下存在派车记录,不能声明整段不用车;只是部分日期不用车请走按天取消",
"data": null
}
```
| 码 | 触发 | 前端处置 |
|---|---|---|
| 605905 | 需求版本过期 / 订单已取消 / 需求非当前生效版本 | 刷新页面重取需求后重试 |
| 605047 | 行程已结束,只读 | 禁用入口 |
| 605916 | 该需求下存在任一非 canceled 派车行(含 `completed`、`exception`) | 提示「已有派车记录,只是部分日期不用车请走按天取消」 |
| 605917 | 大交通已声明需要接机或送机 | 提示「大交通声明了接送机,请先由定制师清掉接送机要求」 |
| 605918 | 同一 `requestId` 用不同参数重复提交 | 换新 `requestId` 重来 |
| 605922 | 该需求已有生效的不用车声明 | 提示「已有声明,改原因请先撤销再重新声明」 |
#### 业务边界
- **鉴权**:网关对 `/admin/fleet/**` 强制 `VEHICLE_MANAGER` / `SUPER_ADMIN`。
- **幂等**:持久化请求指纹(`(requirementId, requestId)` 唯一键 + 请求 sha256)。
同键同参重放返回同一 `snapshotRevision` 且只产生一次快照;同键异参 605918。
并发同键由数据库唯一键仲裁,落败方按指纹判定重放还是冲突——不是先查后插,没有并发窗口。
- **零写入保证**:任何一条校验不过都在写库之前抛出,不会留下半条声明记录或半条快照事件。
- **状态**:只允许在「当前生效需求 + 行程未结束 + 零非 canceled 派车行 + 大交通无接送机声明 +
当前无生效声明」时声明。
- **原因不可原地改**:要改 `reason` 必须先撤销再重新声明(否则返回 605922)。
这条是刻意的——原来的实现是「回显既有声明并回 200」,那会把车务改过的新原因静默丢弃,
前端却以为保存成功了。
---
### 2. 撤销不用车声明 `DELETE /admin/fleet/assignments/requirements/{requirementId}/no-vehicle`
**VO**: `NoVehicleDeclarationRevokeReqVO` → `Result<NoVehicleDeclarationRespVO>`
#### 使用场景
车务改主意了,要给这一单重新排车。撤销把用车需求从完成拉回处理中,之后正常走第②步排车。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| requirementId | path | Long | 是 | 雪花 ID | 当前生效用车需求 ID |
| orderId | body | Long | 是 | 与 `requirementId` 做归属校验 | 订单 ID |
| requestId | body | String | 是 | ≤64 字符,非空 | 撤销侧**独立**的幂等键,与声明侧的 `requestId` 互不干扰 |
| reason | body | String | 否 | ≤200 字符 | 撤销原因 |
#### 出参 `Result<NoVehicleDeclarationRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| requirementId | String | 用车需求 ID(雪花,序列化为字符串) |
| declarationStatus | String | 本地声明结果:`DECLARED` / `REVOKED` |
| syncStatus | String | 与订单侧的同步结果:`PENDING` / `SYNCED` / `FAILED`;`REVOKED` 时为 `null` |
| snapshotRevision | Long | 声明产生的方案快照版本(小整数,JSON number);撤销不产生新版本,回显仍是声明那一版 |
| reason | String | 不用车原因 |
| revokeReason | String | 撤销原因;未撤销时为 `null` |
| declaredAt | LocalDateTime | 声明时间 |
| revokedAt | LocalDateTime | 撤销时间;未撤销时为 `null` |
撤销成功时固定为 `declarationStatus=REVOKED`、`syncStatus=null`,
`snapshotRevision` 回显的仍是声明那一版(撤销不产生新版本)。
#### 请求示例
```json
DELETE /admin/fleet/assignments/requirements/2096644145371033602/no-vehicle
{
"orderId": 2096643451159195650,
"requestId": "no-vehicle-revoke-20260907-001",
"reason": "客人改主意,还是要用车"
}
```
#### 响应示例
```json
{
"code": 200,
"success": true,
"data": {
"requirementId": "2096644145371033602",
"declarationStatus": "REVOKED",
"syncStatus": null,
"snapshotRevision": 15,
"reason": "客人自驾,全程不需要用车",
"revokeReason": "客人改主意,还是要用车",
"declaredAt": "2026-09-07 13:20:11",
"revokedAt": "2026-09-07 13:41:02"
}
}
```
#### 空数据 / 降级响应
该需求当前没有生效声明时不返回空数据,而是报 605919。
#### 错误响应
```json
{
"code": 605920,
"success": false,
"message": "不用车声明正在同步到订单,请稍后再撤销",
"data": null
}
```
| 码 | 触发 | 前端处置 |
|---|---|---|
| 605035 | 需求级生命周期开关未启用,撤销所依赖的链路不会被消费 | 提示联系运维开启配置 |
| 605047 | 行程已结束 | 禁用入口 |
| 605919 | 该需求当前不存在生效的不用车声明 | 刷新回显后重试 |
| 605920 | 声明快照尚未成功投递(`syncStatus=PENDING`) | 提示「同步中,请稍后再撤销」,可轮询 GET |
| 605921 | 声明快照投递已隔离,但**经回查确认** order-v3 侧尚未接受该声明 | 提示「已核实订单侧未接受,可直接重新排车」;声明记录请联系运维重投后再撤销 |
| 605923 | 声明快照投递已隔离,但订单侧已接受该声明(需求已 DONE 且零非取消派车行) | **不能**重新排车(会被 605906 拒),联系运维对账处置隔离事件后再撤销 |
| 605924 | 声明快照投递已隔离,且无法确认订单侧是否已接受(需求状态为空 / PENDING / PENDING_REVIEW / REJECTED_*,或需求已 DONE 但本地还有非取消派车行) | 联系运维对账后再撤销 |
#### 业务边界
- **必须先同步成功才能撤销**(605920 / 605921 / 605923 / 605924)。这条是 fail-closed,不是过度谨慎:
若允许在快照还在投递时撤销,会出现「接口回了 200 REVOKED,但订单侧随后仍被那条在途快照推成完成」的
终态错乱——车管侧显示已撤销、订单侧显示已完成,且此后再撤销会被 605919 拒死。
- **幂等**:撤销侧有自己的唯一键 `(requirementId, revokeRequestId)` 与自己的请求指纹,
指纹绑定被撤销声明的身份,因此「撤销 D1 → 重新声明 D2 → 复用同一 `revokeRequestId`」会返回 605918
而不是静默假成功。
- **撤销不产生新的快照版本**:靠需求重开链路把需求拉回处理中,没有可发布的方案形态。
迟到的旧版本回调会被订单侧的版本比较判为过期忽略,不会覆盖撤销后重新排出来的方案。
- **零写入保证**:门禁不过时不写声明状态、不写重开事件。
---
### 3. 回显不用车声明 `GET /admin/fleet/assignments/requirements/{requirementId}/no-vehicle`
**VO**: `NoVehicleDeclarationRespVO` → `Result<NoVehicleDeclarationRespVO>`
#### 使用场景
进入派单向导或刷新页面时回显:这一单是不是已经声明过整段不用车、原因是什么、同步到哪一步了。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| requirementId | path | Long | 是 | 雪花 ID | 当前生效用车需求 ID。本端点无查询参数、无请求体 |
#### 出参 `Result<NoVehicleDeclarationRespVO>`
| 字段 | 类型 | 说明 |
|---|---|---|
| requirementId | String | 用车需求 ID(雪花,序列化为字符串) |
| declarationStatus | String | 本地声明结果:`DECLARED` / `REVOKED` |
| syncStatus | String | 与订单侧的同步结果:`PENDING` / `SYNCED` / `FAILED`;`REVOKED` 时为 `null` |
| snapshotRevision | Long | 声明产生的方案快照版本(小整数,JSON number);撤销不产生新版本,回显仍是声明那一版 |
| reason | String | 不用车原因 |
| revokeReason | String | 撤销原因;未撤销时为 `null` |
| declaredAt | LocalDateTime | 声明时间 |
| revokedAt | LocalDateTime | 撤销时间;未撤销时为 `null` |
从未声明过时 `data` 为 `null`(不是 404、不是错误码)。
#### 请求示例
```http
GET /admin/fleet/assignments/requirements/2096644145371033602/no-vehicle
Authorization: Bearer {token}
```
#### 响应示例
已声明:
```json
{
"code": 200,
"success": true,
"data": {
"requirementId": "2096644145371033602",
"declarationStatus": "DECLARED",
"syncStatus": "SYNCED",
"snapshotRevision": 15,
"reason": "客人自驾,全程不需要用车",
"revokeReason": null,
"declaredAt": "2026-09-07 13:20:11",
"revokedAt": null
}
}
```
#### 空数据 / 降级响应
从未声明过:
```json
{ "code": 200, "success": true, "data": null }
```
已撤销(`syncStatus` 为 `null`,两个原因都保留):
```json
{
"code": 200,
"success": true,
"data": {
"requirementId": "2096644145371033602",
"declarationStatus": "REVOKED",
"syncStatus": null,
"snapshotRevision": 15,
"reason": "客人自驾,全程不需要用车",
"revokeReason": "客人改主意,还是要用车",
"declaredAt": "2026-09-07 13:20:11",
"revokedAt": "2026-09-07 13:41:02"
}
}
```
#### 错误响应
本端点不产生任何业务错误码——需求不存在或从未声明一律回 `data=null`。
只会出现网关层对 `/admin/fleet/**` 的统一鉴权失败响应:
```json
{
"code": 401,
"success": false,
"message": "未登录或登录已过期",
"data": null
}
```
#### 业务边界
- **`syncStatus` 是读时实时重算的**,不是落库快照:投递成功或被隔离都发生在写事务之外,
直接回落库列会让页面永远显示「同步中」。
- **已撤销时 `syncStatus` 为 `null`**:该维度对撤销态不适用,前端不要展示同步状态。
- 只读,不加锁、不产生任何副作用。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | ❌ 错误 | ✅ 正确 |
|---|---|---|
| 改不用车原因 | 换个 `requestId` 重新 POST(会 605922) | 先 DELETE 撤销,再用新 `requestId` POST |
| 重试超时的声明请求 | 换新 `requestId` 重发(会产生第二条声明或 605922) | 原样重发同一个 `requestId` 和同样的参数,返回同一结果 |
| 只是某几天不用车 | 调本接口(会 605916) | 走按天取消 / 逐日方案里不提交那几天 |
| 判断订单是否已完成 | 看 `syncStatus=SYNCED` | 读订单侧的车控状态字段 |
### 切换状态时的必要动作
- 声明成功 → 需要刷新订单侧状态展示(用车需求会流转到完成)。
- 撤销成功 → 需求回到处理中、恢复可排车;前端应把第②步排车入口重新放开。
- 撤销被 605920 / 605921 / 605923 / 605924 拒绝 → 三种隔离码给车务的下一步互斥,前端**不得**把它们合并成一句提示,应按各码说明逐项区分处理。
---
## 五、数据库行为
新增表 `fleet_no_vehicle_declaration`(迁移 `V20260907_002`):
| 关注点 | 说明 |
|---|---|
| 主键 | `declaration_id`,雪花 |
| 声明幂等 | 唯一键 `uk_no_vehicle_declaration_requirement_request(requirement_id, request_id)` + `request_sha256` 指纹 |
| 撤销幂等 | 唯一键 `uk_no_vehicle_declaration_revoke_request(requirement_id, revoke_request_id)` + `revoke_request_sha256` 指纹 |
| 状态 | `declaration_status`(DECLARED / REVOKED)、`sync_status`(PENDING / SYNCED / FAILED) |
| 与快照的关联 | `snapshot_event_id`、`snapshot_revision` |
声明与快照事件在同一个事务内落库;投递走既有的事务外发件箱链路,事务内不做任何远程调用。
---
## 六、边界行为
- **同步失败(`FAILED`)时的处置取决于订单侧是否已接受**。这是本轮二轮复审推翻的核心断言:
隔离不等于订单侧没收到。远端已提交成功但回执丢失时,发送端重试耗尽后同样会进隔离,但订单侧已是 DONE。
直接排车会被 605906 拒。故撤销接口回查订单侧实况后分流三码:
- 605921(已核实未接受)→ 可直接重新排车,声明记录联系运维重投后再撤销
- 605923/605924(已接受或不确定)→ 必须联系运维对账处置隔离事件后再撤销。
- **接送机与不用车互斥**。大交通声明了接机或送机时不允许声明整段不用车(605917)——接送机本身就是用车,
允许的话等于给 #7067 的接送机门禁开后门。要真的整段不用车,先由定制师改大交通声明清掉接送机要求。
- **已发生的用车事实不可被覆盖**。`completed`(已跑完)与 `exception`(未处理异常)同样触发 605916,
因为订单侧写入零车方案前会先清掉旧的派车快照,放过去就会覆盖掉已发生的用车与费用记录。
- **只读端点不产生副作用**,可以放心轮询。
---
## 六.5、枚举 / 数据字典
### `declarationStatus`(`com.hulalv.fleet.assignment.novehicle.enums.NoVehicleDeclarationStatusEnum`)
| 值 | 中文 | 说明 |
|---|---|---|
| `DECLARED` | 已声明 | 当前生效的「整段不用车」声明 |
| `REVOKED` | 已撤销 | 声明已被撤销,需求已(或正在)回到处理中 |
### `syncStatus`(`com.hulalv.fleet.assignment.novehicle.enums.NoVehicleSyncStatusEnum`)
| 值 | 中文 | 说明 |
|---|---|---|
| `PENDING` | 投递中 | 快照事件在途(含首次投递与重试退避) |
| `SYNCED` | 已投递 | 快照已成功投递——订单侧已受理,或已按其规则忽略(订单已取消、版本落后)。不代表订单车控是完成态 |
| `FAILED` | 投递已隔离 | 重试预算耗尽,需运维重投;不代表 order-v3 没收到,订单侧是否已接受以撤销接口返回码为准 |
| `null` | 不适用 | 仅出现在 `declarationStatus=REVOKED` 时 |
---
## 七、不影响范围
- **订单侧回调契约一个字没改**:本单只是让车管在新场景下也发出那份早已被支持的「无需用车」零单元格形态。
- 常规排车链路的快照门禁判据未动:零派车行且未声明时,行为与以前逐字相同——不发任何快照、不流转订单状态。
- #7067 的接送机门禁语义未动。
- 派单看板、矩阵、候选查询、司机短链均未改。
- 网关路由未改。
---
## 八、测试环境已验证
全部在测试服网关(`https://api.test.1814.love:9443`)真实往返,逐条核对了订单侧落库值,不是只看 HTTP 200。
后端 HEAD `02d7e59be`(PR #7260 的 squash 提交),fleet 双实例滚动部署完成。
```
POST /admin/fleet/assignments/requirements/{id}/no-vehicle → 200 DECLARED + snapshotRevision ✓
DELETE /admin/fleet/assignments/requirements/{id}/no-vehicle → 200 REVOKED + syncStatus=null ✓
GET /admin/fleet/assignments/requirements/{id}/no-vehicle → 三态(null / DECLARED / REVOKED)✓
```
### 主链路
声明成功后订单侧 `order_vehicle_requirement` 逐字核对:
`status=DONE`、`assignment_snapshot_revision` 与响应的 `snapshotRevision` 一致、
`assignment_completion_disposition=NO_VEHICLE_REQUIRED`、`assignment_count=0`、
`assignment_no_vehicle_reason` 等于提交的原因。
撤销后 `status` 回到 `PROCESSING`,随即走正常的第②步排车 `POST /admin/fleet/assignments/batch`
返回 `finalPlanPublished=true`,证明撤销后确实恢复可排车。
### 拒绝分支(每条都带真实响应)
| 验证点 | 结果 |
|---|---|
| 五种派车行状态各拒一次(`assigned` / `unassigned` / `holding` / `completed` / `exception`) | 全部 605916 ✓ |
| `completed` 那次拒绝前后,订单侧 `status`/`revision`/`disposition`/`update_time` 与车管侧车费三列逐字未变 | ✓ 零写入 |
| 大交通声明 ARRIVAL 接机 → 声明 | 605917 ✓ |
| 大交通声明 DEPARTURE 送机 → 声明 | 605917 ✓ |
| 清掉接送机声明后再声明 | 200 DECLARED ✓(正反两个方向都有证据) |
| 需求版本传旧值 | 605905 ✓ |
| 行程已结束的需求声明 | 605047 ✓ |
| 撤销时快照仍在投递(`syncStatus=PENDING`) | 605920 ✓,声明状态未变、未写出重开事件 |
| 撤销时快照已隔离(旧口径)| 605921 ✓ 注:该行是二轮复审前的验证记录。新版本按订单侧实况分流为 605921/605923/605924(见错误响应表) |
| 需求级生命周期开关关闭时撤销 | 605035 ✓,声明状态未变 |
### 幂等(四种情形)
- 同 `requestId` 同参重放声明 → 返回同一 `snapshotRevision`,该需求下快照事件**始终只有 1 条** ✓
- 同 `requestId` 异参(换 `reason`) → 605918 ✓
- 已有生效声明 + 新 `requestId` → 605922 ✓
- 撤销侧同样验过:同键同参重放返回完全相同结果(含 `revokedAt`)、异参 605918 ✓
- **投递未完成时重启服务能自愈**:构造「声明已提交、快照事件仍 PENDING、订单侧仍 PROCESSING」的切片后重启 fleet 实例,
**全程不人工触发重投**,事件在下一拍定时任务被自然消费,订单侧变为
`status=DONE / revision=3 / disposition=NO_VEHICLE_REQUIRED / count=0`,且该需求下快照事件没有因重启多产出 ✓
### 并发与迟到消息
- **并发「撤销 + 重新排车」**:同一秒并发发出两个请求,撤销成功(200 REVOKED)、排车被 605906 拒绝,
车管侧 0 条派车行、订单侧回到 `PROCESSING`——落在合法终态,没有出现「声明已撤销但订单侧仍是零车方案」
这类交叉污染。随后再排车成功,两侧 `revision` / `disposition` / `count` 完全对得上 ✓
- **迟到的旧版本回调**:撤销并重新排车(订单侧已到 `revision=3 / disposition=ASSIGNED`)之后,
把声明那一版(`revision=1`)的事件重投一次,订单侧
`status` / `assignment_snapshot_revision` / `disposition` **一个字都没回退** ✓
### 单测与门禁
- `mvn -pl hl-fleet-service test`:**4073 / 0 failures / 0 errors / 4 skipped**,架构守护门禁 13/13 绿
- `mvn -pl hl-fleet-service spotless:check` 单独跑绿
- `mvn -pl hl-order-service-v3 test -Dtest=com.hulalv.order.requirement.**`:605/0/0
(含两条新增消费侧用例:零单元格回调正确流转、迟到旧版本被判过期忽略)
- 建表迁移用 testcontainers 真 MySQL8 跑过,两个唯一键的行为逐条断言(多个 NULL 可共存、重复键撞唯一约束)
验证订单:`2096825710311067650` / `2096827371423236098` / `2096828488429293570` / `2096829348983046145`
(本次为验收新建,产品 `2056944461216100353`)
---
## 十、相关文档
- 关联 Issue: [wx/HL#7203](https://git.1814.love:8443/wx/HL/issues/7203)
- 前置改造: [wx/HL#7067](https://git.1814.love:8443/wx/HL/issues/7067)(去槽位化,本单的成因)
## 关联 / 联系人
### 链接
- **Issue**: [#7203](https://git.1814.love:8443/wx/HL/issues/7203)
- **PR**: [#7260](https://git.1814.love:8443/wx/HL/pulls/7260)
### 联系人
- **后端负责人**: @wx
@@ -0,0 +1,691 @@
---
schema: "hl-changelog/v2"
ticket: "7210"
title: "团期管理员确认 / 打回改造(M1/M2)"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "96ef0c07"
target_release: ""
verified_at: "2026-09-08"
status_note: "前端已交付并验证(commit 96ef0c07):团级 confirm-check/confirm/reject 三封装+新建「查看需求」RequirementTab(demand:confirm 权限挂载、进入与点确认前各调预检、ready!==true 置灰+missing 缺失清单、confirm 展示 dispatched/skipped 并刷新);按户打回破坏性必填(orderIds 必填非空逐元素 String 透传、reason NForm rules 必填+maxlength 500、resourceType 恒传 ALL);逐单打回/分房四端点封装+两卡按钮(demand:confirm 显隐)+新建 Reject/DispatchRequirementModal;rework 逐项(NIGHTS_MISMATCH/DAY_NUMBER_INVALID 分支+expectedNights/actualNights、housekeeper 驳回入口 present 即显+disabledReason 置灰直显、逐单打回后刷团级 requirementConfirmed 缓存、冻结期重提在位);修复顶层挂 store 致不挂 Pinia spec 失败回归(改回调内惰性获取);对抗 review PASS,checkpoint 全绿(Vitest 全量+生产构建)。"
updated_at: "2026-09-08"
base: "dev-v3"
---
# 团期模块:管理员确认 / 打回改造(M1/M2)
> **服务**: `hl-order-service-v3`、`hl-user-service`(权限种子)
> **Issue**: #7210
> **PR**: #7220、#7222、#7242(复审返工)
> **日期**: 2026-09-07
> **影响范围**: 管理后台团期详情「查看需求」Tab,逐单详情页面打回/分房入口
## ⚠️ 关键变化
**三条核心改造**:
1. **M1 整体确认需求**:缺失校验 → 置标记 → 逐户 PENDING_REVIEW→PENDING + 房控同步 → afterCommit 通知房务。缺失时硬拒绝 589533,零写入;成功后返回已放行户与已有户两份清单。新增预检端点 `confirm-check`。
2. **M2 按户打回需求**:逐户 CAS 打回定制师、清 claimer、同步房控、开待办、发站内信。**破坏性变更**:body 从可选改必填(`orderIds` 必填非空、`reason` 必填 ≤512、`resourceType` 新增可选)。
3. **逐单打回放宽与权限统一**:源状态由 `{PENDING_REVIEW}` 放宽为 `{PENDING_REVIEW, PENDING}`;已分房守卫改报 589535(批量与逐单入口都**先判已分房再判源状态**:房务认领后需求为 PROCESSING,已分房户一律 589535「请先由房务调整配房后再打回」,不会落成 589534)。逐单 reject/dispatch 四入口新增权限守卫 `group-batch:demand:confirm` (589507)。
4. **复审返工(PR #7242)**:① 权限判定改按网关注入的**当前角色**(token 的 `X-Admin-Role`)而不是库里 `current_role_id`——短信登录得到的临时定制师身份即使库角色是 ADMIN,也对 confirm / confirm-check / reject 与四个逐单入口一律 589507(前端可据此提示「请用持码角色登录」);撤权 / 授权最迟 10 分钟生效(角色权限码缓存)。② 逐单打回(房 / 车)改经团级编排:同时清团级 `requirement_confirmed` 并写团级时间线 `BATCH_REQUIREMENT_REJECT`(extra 含本户 orderId),与整体确认 / 按户打回共用团级锁;请求 / 响应 / 错误码不变,前端团期详情在逐单打回成功后需**刷新团级「已确认」状态**。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 需求缺失预检(新增) | GET | `/v3/admin/order/group-batch/{groupBatchId}/requirement/confirm-check` | 新增接口 | 整体确认前的缺失清单 |
| 2 | 整体确认需求 | POST | `/v3/admin/order/group-batch/{groupBatchId}/requirement/confirm` | 响应 VO 改造 | 返回 `GroupBatchRequirementConfirmRespVO` 替代 `Void` |
| 3 | 按户打回需求(破坏性) | POST | `/v3/admin/order/group-batch/{groupBatchId}/requirement/reject` | 请求体必填改造+响应 VO | `orderIds/reason` 必填;响应返回 `GroupBatchRequirementRejectRespVO` |
| 4 | 子订单打回房需求 | POST | `/v3/admin/order/{id}/hotel-requirement/reject` | 权限新增+源状态放宽 | 接 `group-batch:demand:confirm`;对 PENDING 团单放开;已分房改报 589535 |
| 5 | 子订单打回车需求 | POST | `/v3/admin/order/{id}/vehicle-requirement/reject` | 权限新增+源状态放宽 | 接 `group-batch:demand:confirm`;源状态放宽 |
| 6 | 子订单分房(房) | POST | `/v3/admin/order/{id}/hotel-requirement/dispatch` | 权限新增 | 接 `group-batch:demand:confirm` |
| 7 | 子订单分房(车) | POST | `/v3/admin/order/{id}/vehicle-requirement/dispatch` | 权限新增 | 接 `group-batch:demand:confirm` |
---
## 三、接口详情
### 1. 需求缺失预检 `GET /v3/admin/order/group-batch/{groupBatchId}/requirement/confirm-check`
**VO**: `GroupBatchRequirementCheckRespVO`
#### 使用场景
团期管理员在「查看需求」Tab 进入与点「确认」前调用,根据 `ready` 置灰确认按钮、根据 `missing` 展示缺失户。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `groupBatchId` | Path | Long(String) | ✓ | 团期主订单 ID | `order_group_batch.group_batch_id` |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.groupBatchId` | Long(String) | 团期 ID |
| `data.batchStatus` | String | 团期当前状态 |
| `data.ready` | Boolean | missing 为空且 batchStatus∈{RESOURCE_PREPARING,MATERIAL_PREPARING} 时 true |
| `data.missing[]` | List<MissingItem> | 缺失户清单(按 orderId 升序) |
#### 请求示例
```json
GET /v3/admin/order/group-batch/1867000000001/requirement/confirm-check
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"groupBatchId": "1867000000001",
"batchStatus": "RESOURCE_PREPARING",
"ready": false,
"missing": [
{
"orderId": "60123456789001",
"orderNo": "HL2609010001",
"consultantId": "10001",
"consultantName": "李四",
"reason": "NOT_SUBMITTED"
}
]
}
}
```
#### 空数据 / 降级响应
所有户齐备时 `ready=true`,`missing=[]`。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限",
"success": false,
"data": null
}
```
#### 业务边界条目
- **缺失判定**:NOT_SUBMITTED(needs_hotel=1 无 active 房需求行);ROOM_CATEGORY_MISSING(非自订晚某段首方案缺房型或房数<1);INVALID_REQUIREMENT(days 为空 / 缺住宿日期 / 非自订晚 segments 或 rooms 为空 / JSON 损坏)
- **权限**:需 `group-batch:demand:confirm`
- **零副作用**:纯读接口
---
### 2. 整体确认需求 `POST /v3/admin/order/group-batch/{groupBatchId}/requirement/confirm`
**VO**: `GroupBatchRequirementConfirmRespVO`
#### 使用场景
团期管理员确认全团需求齐备,允许房务向酒店下单。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `groupBatchId` | Path | Long(String) | ✓ | 团期主订单 ID | - |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.requirementConfirmed` | Boolean | 固定 true |
| `data.dispatchedOrderIds` | List<Long(String)> | 本次放行的子订单 ID |
| `data.skippedOrderIds` | List<Long(String)> | 本次未动的子订单 ID |
#### 请求示例
```json
POST /v3/admin/order/group-batch/1867000000001/requirement/confirm
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"groupBatchId": "1867000000001",
"requirementConfirmed": true,
"dispatchedOrderIds": ["60123456789001"],
"skippedOrderIds": ["60123456789002"]
}
}
```
#### 空数据 / 降级响应
dispatchedOrderIds 与 skippedOrderIds 中至少一个非空。
#### 错误响应
```json
{
"code": 589533,
"message": "仍有 2 户未提交需求",
"success": false,
"data": null
}
```
#### 业务边界条目
- 团期必须为 RESOURCE_PREPARING 或 MATERIAL_PREPARING,否则 589501
- 任一户缺失→589533 零写入
- 权限需 group-batch:demand:confirm
---
### 3. 按户打回需求 `POST /v3/admin/order/group-batch/{groupBatchId}/requirement/reject`
**VO**: `RejectRequirementReqVO → GroupBatchRequirementRejectRespVO`
#### 使用场景
团期管理员多选户打回,逐户退回定制师。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `groupBatchId` | Path | Long(String) | ✓ | 团期主订单 ID | - |
| `orderIds` | Body | List<Long> | ✓ | @NotEmpty | **破坏性变更**:改前可选现必填 |
| `reason` | Body | String | ✓ | @NotBlank @Size(max=512) | **破坏性变更** |
| `resourceType` | Body | String | 否 | 默认 ALL | **新增** |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.requirementConfirmed` | Boolean | 固定 false |
| `data.rejected[]` | List<RejectedItem> | 被打回的需求条目 |
#### 请求示例
```json
POST /v3/admin/order/group-batch/1867000000001/requirement/reject
{
"orderIds": [60123456789001],
"reason": "房间需求与套餐不匹配",
"resourceType": "ALL"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"groupBatchId": "1867000000001",
"requirementConfirmed": false,
"rejected": [
{
"orderId": "60123456789001",
"resourceType": "HOTEL",
"requirementId": "90011223344"
}
]
}
}
```
#### 空数据 / 降级响应
rejected 列表大小等于成功打回的数量。
#### 错误响应
```json
{
"code": 400,
"message": "请至少选择一个要打回的子订单",
"success": false,
"data": null
}
```
#### 业务边界条目
- 全量前置校验,任一违规则整单零副作用
- 权限需 group-batch:demand:confirm
---
### 4. 子订单打回房需求 `POST /v3/admin/order/{id}/hotel-requirement/reject`
**VO**: `RejectReqVO → Void`
#### 使用场景
团期管理员逐单打回定制师的房需求。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | Path | Long(String) | ✓ | 子订单 ID | - |
| `returnRemark` | Body | String | ✓ | @NotBlank | 打回原因 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回内容 |
#### 请求示例
```json
POST /v3/admin/order/60123456789001/hotel-requirement/reject
{
"returnRemark": "房型不符"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": null
}
```
#### 空数据 / 降级响应
无。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限",
"success": false,
"data": null
}
```
#### 业务边界条目
- 新增权限 group-batch:demand:confirm
- 源状态放宽 {PENDING_REVIEW, PENDING}
- 已分房改报 589535
- 复审返工(PR #7242):打回成功同时清团级 `requirement_confirmed=0` 并写团级时间线 `BATCH_REQUIREMENT_REJECT`(extra.orderIds=[本户]),与 confirm / reject 共用团级锁——**前端在逐单打回成功后须刷新团期「已确认」标记与时间线**;普通单仍 582083;「非团单」判定收紧为「解析不到所属团期行」(有 productBatchId 但团期行已删的脏数据由可打回变为 582083,错误码不变);权限改按 token 当前角色判
---
### 5. 子订单打回车需求 `POST /v3/admin/order/{id}/vehicle-requirement/reject`
**VO**: `RejectReqVO → Void`
#### 使用场景
团期管理员逐单打回定制师的用车需求。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | Path | Long(String) | ✓ | 子订单 ID | - |
| `returnRemark` | Body | String | ✓ | @NotBlank | 打回原因 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回内容 |
#### 请求示例
```json
POST /v3/admin/order/60123456789001/vehicle-requirement/reject
{
"returnRemark": "车型调整"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": null
}
```
#### 空数据 / 降级响应
无。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限",
"success": false,
"data": null
}
```
#### 业务边界条目
- 新增权限 group-batch:demand:confirm
- 源状态放宽 {PENDING_REVIEW, PENDING}
- 复审返工(PR #7242):同房侧,打回成功清团级 `requirement_confirmed=0` + 团级时间线 + 共锁;普通单仍 582083;权限改按 token 当前角色判
---
### 6. 子订单分房(房) `POST /v3/admin/order/{id}/hotel-requirement/dispatch`
**VO**: `DispatchReqVO → Void`
#### 使用场景
房务将确认的房需求派给酒店。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | Path | Long(String) | ✓ | 子订单 ID | - |
| `dispatchRemark` | Body | String | 否 | - | 分房备注 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回内容 |
#### 请求示例
```json
POST /v3/admin/order/60123456789001/hotel-requirement/dispatch
{
"dispatchRemark": "已向酒店下单"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": null
}
```
#### 空数据 / 降级响应
无。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限",
"success": false,
"data": null
}
```
#### 业务边界条目
- 新增权限 group-batch:demand:confirm
- 其余行为不变
---
### 7. 子订单分房(车) `POST /v3/admin/order/{id}/vehicle-requirement/dispatch`
**VO**: `DispatchReqVO → Void`
#### 使用场景
车务将确认的用车需求派给供应商。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | Path | Long(String) | ✓ | 子订单 ID | - |
| `dispatchRemark` | Body | String | 否 | - | 分房备注 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| data | null | 无返回内容 |
#### 请求示例
```json
POST /v3/admin/order/60123456789001/vehicle-requirement/dispatch
{
"dispatchRemark": "已通知车队"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": null
}
```
#### 空数据 / 降级响应
无。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限",
"success": false,
"data": null
}
```
#### 业务边界条目
- 新增权限 group-batch:demand:confirm
- 其余行为不变
---
## 四、契约约束与正确调用方式
### 正确 / 错误 payload 对照
| 场景 | payload | 预期 |
|---|---|---|
| ✅ 确认:所有户齐备 | `POST /confirm`(无 body) | 200,放行所有 PENDING_REVIEW 户 |
| ❌ 确认:某户缺需求 | `POST /confirm`(无 body) | 589533,零写入 |
| ✅ 打回:必填字段齐全 | `{ "orderIds": […], "reason": "…" }` | 200,逐户打回 |
| ❌ 打回:缺 orderIds | `{ "reason": "…" }` | 400 |
| ❌ 打回:orderIds 空数组 | `{ "orderIds": [], "reason": "…" }` | 400 |
### 调用约束
- 确认前必调 confirm-check 展示缺失清单、置灰按钮
- 打回弹窗改传 orderIds 数组 + reason
- 逐单入口四个均需 group-batch:demand:confirm,无权限返 589507
---
## 五、数据库行为
**order-v3**:
- 需求表:CAS 打回行 status/is_active/return_remark/returned_at/returned_by
- 订单表:同步 room_control_status 为 PENDING / REJECTED_TO_CONSULTANT
- 团期表:requirement_confirmed → 1(M1)/ 0(M2)
- 时间线表:写 REQUIREMENT_DISPATCHED / BATCH_REQUIREMENT_REJECT 两条
**hl-user-service**:
- Flyway DML 种子 V20260906_006(INSERT 权限码 + ADMIN/SUPER_ADMIN 关联)
---
## 六、边界行为
- 团期查不到 → 589500
- M1 仅认 RESOURCE_PREPARING/MATERIAL_PREPARING;M2 不限但冻结期有例外
- 并发互斥:同一团期 confirm/reject 最多一个进入;5s 内连点被 Idempotent 拒
- 幂等:M1 重复调用第二次 dispatchedOrderIds 为空
- 权限缓存:Redis TTL 24h,Flyway 后需清缓存或重登
---
## 六.5、枚举 / 数据字典
### 缺失原因
| 值 | 中文 | 触发 |
|---|---|---|
| NOT_SUBMITTED | 未提交房需求 | needs_hotel=1 无 active 行 |
| ROOM_CATEGORY_MISSING | 房型或房数缺失 | 非自订晚某段首候选缺字段 |
| INVALID_REQUIREMENT | 需求数据不完整 | days 为空 / 缺住宿日期 / 非自订晚 segments 或 rooms 为空 / JSON 损坏 |
---
## 六.6、修改前后对比
### 字段级对比
| 接口 | 字段 | 改前 | 改后 |
|---|---|---|---|
| confirm 响应 | 类型 | Result<Void> | Result<GroupBatchRequirementConfirmRespVO> |
| reject 请求 | orderIds | 无 | **必填** @NotEmpty |
| reject 请求 | reason | 可选 | **必填** @NotBlank @Size(max=512) |
| reject 请求 | resourceType | 无 | **新增** HOTEL/VEHICLE/ALL 默认 ALL |
| reject 响应 | 类型 | Result<Void> | Result<GroupBatchRequirementRejectRespVO> |
| hotel-requirement/reject | 源状态 | {PENDING_REVIEW} | **放宽** {PENDING_REVIEW, PENDING} |
| hotel-requirement/reject | 已分房错误码 | 582086 | **改为** 589535 |
### 行为级对比
| 行为 | 改前 | 改后 |
|---|---|---|
| 整体确认 | 仅置标记,无缺失校验 | 缺失校验→置标记→逐户放行→通知房务 |
| 按户打回 | 置团级标记不改户状态 | 逐户 CAS 打回、房控同步、待办、站内信 |
| 逐单打回(房 / 车) | 只改需求行,团级 `requirement_confirmed` 不动、无团级时间线、不共锁 | 复审返工:同时清团级标记 + 团级时间线(extra 含本户)+ 与 confirm / reject 共锁 |
| 团期权限判定(含既有六码) | 按库 `current_role_id`(短信登录临时身份可借库角色权限) | 复审返工:按 token 当前角色 `X-Admin-Role`;SUPER_ADMIN 恒放行;角色权限码缓存 10 分钟 |
---
## 六.7、影响评估
- **破坏向后兼容**:**是** —— reject 必填参数改变;旧客户端传空 body 改为 400
- **前端是否必须同步上线**:**是** —— 破坏性变更,前后端需同一版本发布
- **前端需清理的分支**:打回弹窗改传 orderIds;确认前必调 confirm-check;reject 响应改为带 rejected[]
---
## 七、不影响范围
- 订单创建/修改
- 抢单池列表与房务认领流程本身(放行后的团单需求按既有规则以 PENDING 进池;本单只是不做抢单池 SSE 广播,团单整团汇总进池形态归 H 系列工单)
- 权限体系(仅新增一个权限码)
- 网关路由(/v3/admin/** 通配不变)
- 数据库表结构(order-v3 无新表无列变更;hl-user-service 仅 DML)
---
## 八、测试环境已验证
- 原验收(2026-09-07,团 A `2096412454643802114`):AC-2/3/4/5/6/7/9/10/11/12/14/15/20/21/22 网关实测通过(含 50 户整体确认、confirm × reject 并发串行、冻结期闭环、589535 优先于 589534)。
- 复审返工(PR #7242,order-v3 `d482d08f9` / user-service `d482d08f9`):AC-24:`admin`(库角色 ADMIN 持码)短信登录得临时 CUSTOMIZER token,打 confirm-check / confirm / reject 与四个逐单入口共 7 次全部 589507,前后 DB 快照(团期标记 + 四户订单 + 房车需求 + 时间线计数)逐字节相同;密码重登后短信 token 立即失效(单会话顶号),ADMIN 200、SUPER_ADMIN 200、不持码 CUSTOMIZER 589507。AC-26:整团确认 `requirement_confirmed=1` 后逐单打回房需求 → 200、需求 `PENDING→REJECTED_TO_CONSULTANT / is_active=0`、`requirement_confirmed=0`、时间线新增 `BATCH_REQUIREMENT_REJECT` 且 `extra.orderIds=["2096633951715033089"]`;恢复确认后逐单打回车需求同样生效(`extra.orderIds=["2096633972502003714"]`)且房侧行未受影响;普通单房 / 车逐单打回均 582083、零写入、无残留时间线。6/6 检查通过,证据 `verify_7210_rework_evidence.json`。
---
## 九、复审返工(2026-09-07,PR #7242)
| 项 | 问题 | 修正 | 前端影响 |
|---|---|---|---|
| P1 权限 | 守卫经 `hasPermission(adminId)` 按库角色判权,短信登录的临时定制师身份借库角色(ADMIN)通过团期管理员动作 | 新增内部端点 `GET /internal/user/admin/role-has-permission`,守卫改按 token 当前角色判;覆盖 confirm / confirm-check / reject 与四个逐单入口及既有六个团期权限码 | 无契约变化。临时定制师身份对这些入口一律 589507,可引导切回持码角色;撤权最迟 10 分钟生效 |
| P2 逐单打回 | `hotel-requirement/reject`、`vehicle-requirement/reject` 只改需求行,整团确认后逐单打回会留下「该户未提交、整团已确认」 | 逐单打回改经团级编排:清 `requirement_confirmed` + 团级时间线 `BATCH_REQUIREMENT_REJECT` + 共锁 | 无契约变化。逐单打回成功后刷新团期详情的「已确认」标记与时间线 |
| P2 附带收紧 | 「非团单」旧判法只看 `productBatchId` 是否为空 | 改为「能否解析出所属团期行」 | 有 `productBatchId` 但团期行已删的脏数据由「可打回」变为 582083,错误码与文案不变 |
**部署顺序强约束**:`hl-user-service` 必须先于 `hl-order-service-v3` 上线。守卫改打新内部端点 `role-has-permission`,user-service 未部署时 Feign 404 降级 false,会让全部团期权限码(含看板 / 芯片 / 导出 / 成团 / 财务)对所有人返 589507,超管也不例外。
---
## 十、相关文档
### 第二轮复审返工要点(2026-09-07)
本次对外前端变更汇总(已同步 #7149 文件):
| 项 | 变更 | 前端影响 |
|---|---|---|
| ① 打回原因长度 | 512 → 500(五个接口统一) | 打回 / 分房备注输入框 `maxlength="500"`;501+ 字返 400 |
| ② confirm-check reason | 新增 `NIGHTS_MISMATCH`、`DAY_NUMBER_INVALID` 与对应字段 `expectedNights`、`actualNights` | 若穷举 switch/case,补两个分支;直接透传后端 message |
| ③ 房务置灰口径 | 改按订单整体判已分房;新增 `disabledReason` 字段「本单尚有挂在历史需求版本上的配房未清理」 | 按 disabledReason 文案直接显示,无需按码分支 |
| ④ 需求改版 | 房务已配房自动跟随迁移到新版本 | 打回→重提后房务看板数据仍存在;逐单详情需刷新 |
| ⑤ 冻结期重提 | 被打回户在冻结期可重提(REJECTED_TO_ADMIN→PENDING_REVIEW);后期阶段允许整团确认 | 被打回户重提入口恢复;整团确认入口全阶段显示;缓存 requirementConfirmed 需刷新 |
- 工单正文:#7210 接口变更节、口径与定案节、验收标准 AC-1~AC-22
- 接口文档:docs/group/团期模块接口文档-v2.0.html §0C.2、GB-ADM-012/013、§3.1
- 实现方案:docs/group/团期房务实现方案-v1.0.html §3.5
---
## 关联 / 联系人
### 链接
- **Issue**: [#7210](https://git.1814.love:8443/wx/HL/issues/7210)
- **PR**: [#7220](https://git.1814.love:8443/wx/HL/pulls/7220)、[#7222](https://git.1814.love:8443/wx/HL/pulls/7222)、[#7242](https://git.1814.love:8443/wx/HL/pulls/7242)(复审返工)
- **Merge commit**: `2e5812fdf`(#7220)、`5ab5a6cbe`(#7222)、`8d3b75671`(#7242)
### 联系人
- **后端负责人**: wx
- **前端负责人(hl-ui)**: mmg
@@ -0,0 +1,607 @@
---
schema: "hl-changelog/v2"
ticket: "7211"
title: "定制师 ↔ 团期管理员站内会话(GROUP:{orderId})"
consumer: "admin"
author: "wx(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "86340b24"
target_release: ""
verified_at: "2026-09-08"
status_note: "前端已交付并验证(commit 86340b24):chat.js 新增 openGroupChat(POST /message/chat/open-group,body 仅 {orderId} 字符串不传 peerAdminId,silentError),send 静默正则扩 /^(?:FLEET|GROUP):[^:]+$/、messages/read 对 GROUP 键静默,281002 不透传原文案由 ChatDrawer 统一映射「刷新重试/订单负责人可能已变更」;住宿安排卡加「联系团期管理员」(显隐只看 itinerary.groupBatchId 非空不看 OrderMainVO.groupBatchId,无定制师显「暂无定制师」,角标 itineraryGroupUnreadCount),团期「查看需求」名单表每户加「联系定制师」(仅 group-batch:demand:confirm 持码露出,团队共享「待处理」角标,操作列宽 76→150 必要调宽);抽屉头徽章透传后端 peerRoleLabel,前端不建 GROUP_ADMIN 硬编码映射;SSE JSON 解析宽容未知字段零改动,聊天 Tab unread-total 与消息中心 unread-count 两角标按契约分开;定向 114 例+回归 87 文件 918 例全绿,对抗 review PASS,checkpoint 全绿(Vitest 全量+生产构建)。"
updated_at: "2026-09-08"
base: "dev-v3"
---
# 团期模块:定制师 ↔ 团期管理员站内会话(GROUP:{orderId})
> **服务**: `hl-order-service-v3`、`hl-user-service`
> **Issue**: #7211
> **PR**: #7225(order-v3)、#7226(user-service)
> **日期**: 2026-09-07
> **影响范围**: 管理后台子订单详情「住宿安排」卡、团期详情「查看需求」名单表
## ⚠️ 关键变化
**新增团期管理员团队会话通道**:定制师与团期管理员团队(而非指派个人)按子订单维度建立实时对话(会话键 `GROUP:{orderId}`),镜像车务团队制(团队共享未读水位行、定制师广播落池、任一管理员读即全员清零)。
**顶部角标两条规则分开**:
1. 团期管理员团队共享池(`admin_id=0`)未读**只计入**聊天 Tab 与 SSE,**不计入**消息中心角标;
2. 团期管理员回复落定制师个人行(`sender_role=GROUP_ADMIN`),照常计入定制师顶部角标、消息中心与收件箱。
**前端勾选要点**:
- `ItineraryVO.groupBatchId` 非空时才显示「联系团期管理员」按钮(**不看** `OrderMainVO.groupBatchId`);
- `peerOnline` 按账号在线判定,不承诺"几分钟内回复";
- 无定制师时 `peerName=null` 展示「暂无定制师」;
- 281002 权限错误不透传原文案,用「刷新重试 / 订单负责人可能已变更」;
- `groupChatUnreadCount` 是团队共享「待处理」,任一管理员读即全员清零。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 开启团期管理员会话(新增) | POST | `/admin/message/chat/open-group` | 新增接口 | 定制师 ↔ 团期管理员团队会话,键 `GROUP:{orderId}` |
| 2 | 开启会话(通用,GROUP 分流) | POST | `/admin/message/chat/open` | 修改接口 | `bizModule` 新增 `GROUP`,分流到 open-group(`peerAdminId` 忽略,不产生 pair 键) |
| 3 | 会话列表 | GET | `/admin/message/chat/conversations` | 修改接口 | `bizModule` 过滤新增 `GROUP`;`peerRole / peerRoleLabel` 新增 `GROUP_ADMIN / 团期管理员`;持码者团队共享未读 |
| 4 | 消息列表 | GET | `/admin/message/chat/{conversationKey}/messages` | 修改接口 | 支持 `GROUP:{orderId}` 键;`senderRole` 新增 `GROUP_ADMIN` |
| 5 | 发送消息 | POST | `/admin/message/chat/{conversationKey}/messages` | 修改接口 | 定制师发 → 团队池;管理员发 → 定制师个人行 |
| 6 | 标记已读 | POST | `/admin/message/chat/{conversationKey}/read` | 修改接口 | 持码者读 → 团队水位推进、全员清零、定制师收 `im-chat-read` |
| 7 | 聊天 Tab 未读总数 | GET | `/admin/message/chat/unread-total` | 修改接口 | 当前角色持码时合入 GROUP 团队池未读 |
| 8 | 子订单行程(住宿安排卡) | GET | `/v3/admin/order/{id}/itinerary` | 修改接口 | 新增 `groupBatchId`(按钮显隐依据)、`groupUnreadMessageCount` |
| 9 | 团期子订单名单 | GET | `/v3/admin/order/group-batch/{groupBatchId}/orders` | 修改接口 | 每项新增 `groupChatUnreadCount`(团队共享「待处理」) |
**SSE 契约**(`/ws/admin-msg/stream`,事件 `im-chat` / `im-chat-read`,非 REST):payload 新增 `targetPermissionCode`(String,可空);GROUP 团队信令为 `"group-batch:demand:confirm"`,只投递给当前角色持码的连接(实测:持码 ADMIN 收到,不持码 CUSTOMIZER 收不到,同一账号切到不持码角色后的旧连接也收不到)。`im-chat-read` 示例:`{"adminId":1002,"type":"READ","conversationKey":"GROUP:2096701865117831170","readerAdminId":1001,"lastReadMessageId":"2096701909673943042"}`。前端无需改解析,但请确认 SSE JSON 解析不拒绝未知字段。
**内部 Feign**(不经网关,前端不用):`POST /internal/message/chat/biz-unread-batch` `bizModule` 新增 `GROUP`(`unreadScope` TEAM / PERSONAL);`POST /internal/message/chat/reconcile-fleet-consultant` 路径不变、同时收敛 FLEET 与 GROUP;order-v3 `POST /internal/house/order-chat-summary-batch` 新增 `groupBatchId / groupBatchNo`。
---
## 三、接口详情
### 1. 开启团期管理员会话 `POST /admin/message/chat/open-group`
**VO**: `ChatOpenGroupReqVO → ChatOpenFullRespVO`
#### 使用场景
定制师或团期管理员打开与对方团队的会话;调用方为 hl-ui 子订单详情「住宿安排」卡「联系团期管理员」、团期详情「查看需求」名单表「联系定制师」。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `orderId` | Body | Long(String) | ✓ | @NotNull | 子订单 ID;**不接受** `peerAdminId`(传了被忽略) |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| `conversationKey` | String | `GROUP:{orderId}` |
| `isNew` | Boolean | 开方成员行此前是否不存在 |
| `peerAdminId` | Long | 定制师视角恒 0(团队占位);管理员视角 = 当前定制师 adminId(无定制师时 0) |
| `peerName` | String | 定制师视角「团期管理员」;管理员视角 = 定制师姓名快照,**无定制师时 null**,前端展示「暂无定制师」 |
| `peerRole` | String | 定制师视角 `GROUP_ADMIN`;管理员视角 `CUSTOMIZER` |
| `peerRoleLabel` | String | 「团期管理员」或「定制师」 |
| `peerOnline` | Boolean | 定制师视角 = 任一持码活跃账号在线;管理员视角 = 定制师在线。**按账号在线判定,不按当前角色**,不保证对方能立刻收到消息 |
| `unreadCount` | Integer | 恒 0(打开即已读) |
| `order` | ChatOrderCardVO | 订单卡:orderNo / teamNo / customerName / destination / tripDays / productName / adultCount / childCount / **groupBatchNo**(String) / **groupBatchId**(String);摘要不可达时为 null |
| `thread` | ChatThreadRespVO | 最新消息线索:conversationKey / hasMore / nextCursor / list[] 消息数组 |
| `unreadTotal` | Integer | 标记已读后顶部合并未读总数(不含 GROUP 团队池,与 unread-count REST 保持一致) |
#### 请求示例
```json
POST /admin/message/chat/open-group
{
"orderId": 60123456789001
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"conversationKey": "GROUP:60123456789001",
"isNew": false,
"peerAdminId": 0,
"peerName": "团期管理员",
"peerRole": "GROUP_ADMIN",
"peerRoleLabel": "团期管理员",
"peerOnline": true,
"unreadCount": 0,
"order": {
"orderNo": "HL2609010001",
"teamNo": "T20260901001",
"customerName": "李四",
"destination": "三亚",
"tripDays": 4,
"productName": "豪华蜜月游",
"adultCount": 2,
"childCount": 0,
"groupBatchNo": "G20260901001",
"groupBatchId": "1867000000001"
},
"thread": {
"conversationKey": "GROUP:60123456789001",
"hasMore": false,
"nextCursor": null,
"list": []
},
"unreadTotal": 0
}
}
```
#### 空数据 / 降级响应
摘要不可达或缺失时,`order` 为 null;`thread.list` 可为空。
#### 错误响应
```json
{
"code": 281002,
"message": "无权访问该会话",
"success": false,
"data": null
}
```
#### 业务边界条目
- 打开即标记已读,`unreadCount` 恒 0
- 团队水位行 `admin_id=0` 自动创建,定制师与管理员各有成员行
- 定制师视角 `peerRole=GROUP_ADMIN`;管理员视角 `peerRole=CUSTOMIZER`
- 无定制师的新团期:按钮不显示(`groupBatchId` 为空),`peerName` 返 null
- 转单后旧定制师再开 → 281002,新定制师开 → 200 且承接未读
---
---
---
### 2. 开启会话(通用,GROUP 分流) `POST /admin/message/chat/open`
**VO**: `ChatOpenReqVO → ChatOpenFullRespVO`
#### 使用场景
旧客户端沿用通用入口时传 `bizModule=GROUP`;新代码直接用 `open-group`。两者同一实现。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `bizModule` | Body | String | ✓ | — | 新增枚举值 `GROUP`(原 HOUSE / FLEET / DIRECT) |
| `bizId` | Body | Long(String) | ✓ | — | 团期子订单 ID |
| `peerAdminId` | Body | Long(String) | — | — | GROUP 下忽略:团队会话不按对端个人建键 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| conversationKey | String | `GROUP:{orderId}` |
| 其余字段 | — | 与 `open-group` 完全相同(见 1) |
#### 请求示例
```json
POST /admin/message/chat/open
{"bizModule": "GROUP", "bizId": "2096701865117831170", "peerAdminId": "123"}
```
#### 响应示例
```json
{"code": 200, "message": "成功", "success": true, "data": {"conversationKey": "GROUP:2096701865117831170", "isNew": false, "peerAdminId": "0", "peerName": "团期管理员", "peerRole": "GROUP_ADMIN", "peerRoleLabel": "团期管理员", "peerOnline": true, "unreadCount": 0, "order": {"orderNo": "HL20260907044734031", "groupBatchNo": "Q202610012052935476548939777", "groupBatchId": "2096412454643802114"}, "thread": {"conversationKey": "GROUP:2096701865117831170", "hasMore": false, "list": []}, "unreadTotal": 1}}
```
#### 空数据 / 降级响应
同 `open-group`:order-v3 不可达时 `order` 为 null(会话仍可开)。
#### 错误响应
```json
{"code": 281002, "message": "无权访问该会话", "data": null, "success": false}
```
| 码 | 触发 |
|---|---|
| 281002 | 非该单当前定制师且当前角色不持 `group-batch:demand:confirm`;转单后旧定制师;order-v3 摘要不可达(失败关闭)。前端不透传原文案,用「刷新重试 / 订单负责人可能已变更」 |
| 281015 | 非团期子订单 |
| 281012 | bizId 为空 |
#### 业务边界条目
- 实测 `peerAdminId=123` 也返回 `GROUP:{orderId}`,库内不出现 `GROUP:{id}:{a}:{b}` pair 键。
---
### 3. 会话列表 `GET /admin/message/chat/conversations`
**VO**: `查询参数 → PageResult<ChatConversationRespVO>`
#### 使用场景
聊天 Tab 列表。团期管理员(持码角色)看到所有团期子订单的 GROUP 会话(团队共享未读);定制师看到自己名下团单的 GROUP 会话;不持码 ADMIN / 其他定制师看不到。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `bizModule` | Query | String | — | — | 新增 `GROUP`;不传时非团队模块列表**排除** FLEET / GROUP 团队行(与既有 FLEET 规则一致) |
| `pageNo / pageSize` | Query | Integer | — | — | 不变 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| list[].bizModule | String | `GROUP` |
| list[].peerRole / peerRoleLabel | String | 定制师视角 `GROUP_ADMIN` / 团期管理员;管理员视角 `CUSTOMIZER` / 定制师 |
| list[].peerName | String | 定制师视角「团期管理员」;管理员视角定制师姓名 |
| list[].peerOnline | Boolean | 定制师视角 = 任一持码活跃账号在线(按账号不按当前角色,不保证对方能立刻收到) |
| list[].unreadCount | Integer | 持码者为团队共享未读(任一持码者读即全员清零) |
| list[].orderNo / teamNo | String | 订单号 / 团号 enrich |
#### 请求示例
```json
GET /admin/message/chat/conversations?pageNo=1&pageSize=50&bizModule=GROUP
```
#### 响应示例
```json
{"code": 200, "message": "成功", "success": true, "data": {"list": [{"conversationKey": "GROUP:2096701865117831170", "bizModule": "GROUP", "orderNo": "HL20260907044734031", "teamNo": "26-4983", "peerRole": "CUSTOMIZER", "peerRoleLabel": "定制师", "peerName": "test_admin", "unreadCount": 0, "peerOnline": true}], "total": 1}}
```
#### 空数据 / 降级响应
不持码的 ADMIN / 其他定制师:`list=[]`(实测 0 条);无角色 token 对团队模块失败关闭返空页。
#### 错误响应
```json
{"code": 401, "message": "Token无效或已过期", "data": null, "success": false}
```
| 码 | 触发 |
|---|---|
| 401 | 未登录 / token 失效(沿用既有) |
#### 业务边界条目
- 会话卡 `peerRole` 与消息气泡 `senderRole` 的中文映射都要补 `GROUP_ADMIN → 团期管理员`。
- 未读语义是团队共享「待处理」,别写成「你的未读」。
---
### 4. 消息列表 `GET /admin/message/chat/{conversationKey}/messages`
**VO**: `查询参数 → ChatThreadRespVO`
#### 使用场景
打开 GROUP 会话后拉取历史;`conversationKey=GROUP:{orderId}`。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `conversationKey` | Path | String | ✓ | — | `GROUP:{orderId}` |
| `cursor / pageSize` | Query | — | — | — | 不变 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| list[].senderRole | String | 新增 `GROUP_ADMIN`(管理员回复);定制师广播为 null |
| list[].readByPeer | Boolean | 任一持码者读后为 true(`im-chat-read` 同步推送) |
#### 请求示例
```json
GET /admin/message/chat/GROUP:2096701865117831170/messages?pageSize=20
```
#### 响应示例
```json
{"code": 200, "message": "成功", "success": true, "data": {"conversationKey": "GROUP:2096701865117831170", "hasMore": false, "nextCursor": null, "list": [{"messageId": "2096701935376617473", "senderAdminId": "1001", "senderName": "admin", "senderRole": "GROUP_ADMIN", "msgType": "TEXT", "content": "管理员回复 1", "isMine": false, "readByPeer": false}]}}
```
#### 空数据 / 降级响应
无消息时 `list=[]`。
#### 错误响应
```json
{"code": 281002, "message": "无权访问该会话", "data": null, "success": false}
```
| 码 | 触发 |
|---|---|
| 281002 | 非该单当前定制师且当前角色不持 `group-batch:demand:confirm`;转单后旧定制师;order-v3 摘要不可达(失败关闭)。前端不透传原文案,用「刷新重试 / 订单负责人可能已变更」 |
#### 业务边界条目
- 定制师端消息气泡对 `senderRole=GROUP_ADMIN` 显示「团期管理员」徽章。
---
### 5. 发送消息 `POST /admin/message/chat/{conversationKey}/messages`
**VO**: `ChatMessageSendReqVO → ChatMessageSendRespVO`
#### 使用场景
定制师发 → 落团队池(全体持码者共享未读,SSE `im-chat` 定向到持码连接);管理员发 → 落定制师个人行(定制师顶部角标 / 收件箱照常 +1)。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `conversationKey` | Path | String | ✓ | — | `GROUP:{orderId}` |
| `content` | Body | String | ✓ | — | 不变 |
| `priority / msgType` | Body | String | — | — | 不变 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| messageId | String | 不变 |
#### 请求示例
```json
POST /admin/message/chat/GROUP:2096701865117831170/messages
{"content": "请确认第二晚房型"}
```
#### 响应示例
```json
{"code": 200, "message": "成功", "success": true, "data": {"messageId": "2096701909673943042"}}
```
#### 空数据 / 降级响应
无。
#### 错误响应
```json
{"code": 281002, "message": "无权访问该会话", "data": null, "success": false}
```
| 码 | 触发 |
|---|---|
| 281002 | 非该单当前定制师且当前角色不持 `group-batch:demand:confirm`;转单后旧定制师;order-v3 摘要不可达(失败关闭)。前端不透传原文案,用「刷新重试 / 订单负责人可能已变更」 |
#### 业务边界条目
- 转单与 GROUP 发送 / 已读 / 开会话互斥(order-v3 转单入口与 user-service 共用同一把 `GROUP:{orderId}` 锁);实测与转单并发的发送被串行后按新归属拒绝(281002)。
- 新增 GROUP 日志只打标识符,不打客户姓名 / 消息内容。
---
### 6. 标记已读 `POST /admin/message/chat/{conversationKey}/read`
**VO**: `无 body → ChatReadRespVO`
#### 使用场景
持码者读 → 团队水位推进、全员未读清零、给定制师补发 `im-chat-read`;定制师读 → 只推进自己行。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `conversationKey` | Path | String | ✓ | — | `GROUP:{orderId}` |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| unreadTotal | Integer | 标记后的顶部合并未读(不含 GROUP 团队池) |
#### 请求示例
```json
POST /admin/message/chat/GROUP:2096701865117831170/read
```
#### 响应示例
```json
{"code": 200, "message": "成功", "success": true, "data": {"unreadTotal": 13}}
```
#### 空数据 / 降级响应
无。
#### 错误响应
```json
{"code": 281002, "message": "无权访问该会话", "data": null, "success": false}
```
| 码 | 触发 |
|---|---|
| 281002 | 非该单当前定制师且当前角色不持 `group-batch:demand:confirm`;转单后旧定制师;order-v3 摘要不可达(失败关闭)。前端不透传原文案,用「刷新重试 / 订单负责人可能已变更」 |
#### 业务边界条目
- 实测管理员读后名单表两持码账号 `groupChatUnreadCount` 同时 1→0,定制师收到 `im-chat-read`(`readerAdminId` 为读的管理员)。
---
### 7. 聊天 Tab 未读总数 `GET /admin/message/chat/unread-total`
**VO**: `无参 → ChatUnreadTotalRespVO`
#### 使用场景
聊天 Tab 角标。**当前角色持码时合入 GROUP 团队池未读**;消息中心 `GET /admin/message/unread-count` 与顶部合并角标**不含**团队池。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| (无) | — | — | — | — | 无入参 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| chatUnreadTotal | Integer | 持码者 = 个人未读 + GROUP 团队池未读(+ FLEET 池,车务管理员) |
#### 请求示例
```json
GET /admin/message/chat/unread-total
```
#### 响应示例
```json
{"code": 200, "message": "成功", "success": true, "data": {"chatUnreadTotal": 3}}
```
#### 空数据 / 降级响应
无。
#### 错误响应
```json
{"code": 401, "message": "Token无效或已过期", "data": null, "success": false}
```
| 码 | 触发 |
|---|---|
| 401 | 未登录 / token 失效 |
#### 业务边界条目
- 实测定制师广播后持码 ADMIN 的 `chat/unread-total` 2→3,而 `/admin/message/unread-count` 13→13。
- 定制师收到管理员回复 → 个人行 → `unread-count` 0→1,前端不得过滤 `senderRole=GROUP_ADMIN` 的行。
---
### 8. 子订单行程(住宿安排卡) `GET /v3/admin/order/{id}/itinerary`
**VO**: `ItineraryVO`
#### 使用场景
子订单详情「住宿安排」卡:「联系团期管理员」按钮显隐 + 角标。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `id` | Path | Long | ✓ | — | 子订单 ID |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| groupBatchId | String | 新增。统一归团解析结果(含历史兼容单 `product_batch_id` 反查);普通单 null。**按钮显隐只看它,不看 `OrderMainVO.groupBatchId`** |
| groupUnreadMessageCount | Integer | 新增。当前登录定制师在本单 GROUP 会话的个人未读;非团单 / 降级 / 未登录 0 |
#### 请求示例
```json
GET /v3/admin/order/2096701865117831170/itinerary
```
#### 响应示例
```json
{"code": 200, "message": "成功", "success": true, "data": {"groupBatchId": "2096412454643802114", "groupUnreadMessageCount": 1, "unreadMessageCount": 0}}
```
#### 空数据 / 降级响应
普通单:`"groupBatchId": null, "groupUnreadMessageCount": 0`;user-service 不可达 → `groupUnreadMessageCount=0`,其余字段正常。
#### 错误响应
```json
{"code": 582001, "message": "订单不存在", "data": null, "success": false}
```
| 码 | 触发 |
|---|---|
| 582001 | 订单不存在(沿用既有) |
#### 业务边界条目
- `groupBatchId` 与 `OrderMainVO.groupBatchId` 语义不同(后者是原始列,历史兼容单可能为空)。
---
### 9. 团期子订单名单 `GET /v3/admin/order/group-batch/{groupBatchId}/orders`
**VO**: `查询参数 → List<GroupBatchOrderItemRespVO>`
#### 使用场景
团期详情「查看需求」名单表每户「联系定制师」+ 角标。
#### 入参字段表
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `groupBatchId` | Path | Long | ✓ | — | 团期 ID |
| `includeTravelers / includeNeeds / includeCancelled` | Query | Boolean | — | — | 不变 |
#### 出参字段表
| 字段 | 类型 | 说明 |
|---|---|---|
| [].groupChatUnreadCount | Integer | 新增。团队共享「待处理」未读(TEAM 口径):任一管理员读即全员清零,**不是**当前登录人个人未读;降级 0 |
#### 请求示例
```json
GET /v3/admin/order/group-batch/2096412454643802114/orders
```
#### 响应示例
```json
{"code": 200, "message": "成功", "success": true, "data": [{"orderId": "2096701865117831170", "orderNo": "HL20260907044734031", "groupChatUnreadCount": 1}]}
```
#### 空数据 / 降级响应
user-service 不可达或超时 → 全部 0,主列表照常返回。
#### 错误响应
```json
{"code": 589507, "message": "无操作权限(非团期管理员 / 非本定制师名下)", "data": null, "success": false}
```
| 码 | 触发 |
|---|---|
| 589507 | 无 `group-batch:view`(沿用既有守卫) |
| 589500 | 团期不存在 |
#### 业务边界条目
- 文案用「待处理 / 待回复」而非「未读消息」;本字段只供页面级加载,不要做高频轮询。
---
## 四、契约约束与正确调用方式
### 授权规则
会话准入:①当前定制师本人(token 角色 CUSTOMIZER 且 me == consultant_id)或②团期管理员(token 当前角色持权限码 `group-batch:demand:confirm`);均不满足返 281002。
### 转单锁定
order-v3 转单入口:FLEET 锁 → 判团 → GROUP 锁 → 事务。普通单不取 GROUP 锁。并发互斥。
---
## 五、数据库行为
- **无表结构变更、无 DDL**(现有列宽已足);
- **权限种子** `V20260906_006`(INSERT 权限码 + 关联)由 #7210 落地;若本单先合则自带;
- **数据形态**:admin_message 新增 bizModule=GROUP 行;sender_role=NULL(定制师广播)或 GROUP_ADMIN(管理员回复);admin_conversation_member 新增 admin_id=0 团队行与个人行。
---
## 六、边界行为
- 打开即已读;团队水位行自动创建;
- 定制师广播到全体持码账号;无定制师时 peerName=null;
- 权限缓存 TTL 10 分钟;撤权生效靠 TTL 或运维 `DEL admin_permissions:v2:role:{roleKey}`;
- 团期软删时订单卡 `groupBatchNo=null`;前端用「团期已归档」占位。
---
## 七、不影响范围
- 车务(FLEET)聊天流程与数据;
- 房 / 车需求创建/修改;
- 抢单池流程;
- 消息中心界面(GROUP 池行暂未纳管,后续单独工单);
- 网关路由。
---
## 八、测试环境已验证
- 部署:dev-v3 `072a476be`(PR-A #7225,order-v3)+ `f4251d946`(PR-B #7226,user-service),测试服 `BRANCH=dev-v3 deploy-backend.sh` 两服务已滚;lock4j 键前缀两服务均默认值。
- 网关实测(团 A `2096412454643802114`,定制师 test_admin / 持码 ADMIN admin / SUPER_ADMIN wx / 不持码 qulili):AC-1 open-group 200(`GROUP:{orderId}`、`peerRole=GROUP_ADMIN`、订单卡 `groupBatchNo=Q202610012052935476548939777` / `groupBatchId`);AC-2 普通单 281015;AC-3 不持码 281002、短信登录临时 CUSTOMIZER(DB 角色 ADMIN)281002、撤码后 `DEL admin_permissions:v2:role:ADMIN` → 281002、恢复 → 200;AC-4 未提需求 200;AC-5 定制师广播落 `admin_id=0` 池行、名单表 TEAM 未读两账号同值 1、管理员读后全员 0 且定制师收 `im-chat-read`;AC-6 管理员回复落定制师个人行 `sender_role=GROUP_ADMIN`、SSE `im-chat` 到达、itinerary `groupUnreadMessageCount=1`、`unread-count` 0→1;AC-7 持码 ADMIN 收到含 `targetPermissionCode` 的 `im-chat`、不持码收不到、切角色旧连接收不到,`chat/unread-total` 2→3 而 `unread-count` 不变;AC-8 会话列表 GROUP 过滤命中(orderNo / teamNo / peerRoleLabel)、不持码 0 条;AC-9 通用 open 同键无 pair 键;AC-10 转单后旧行 ARCHIVED / 新行 ACTIVE / 旧定制师 281002、并发发送被串行后拒绝;AC-10b 历史兼容单 open-group 200 且 itinerary `groupBatchId` 非空;AC-12 / AC-13 字段与上述一致。
---
## 十、相关文档
- 工单正文:#7211
- 架构方案:#7211 评论「架构方案」
- 接口文档:docs/group/团期模块接口文档-v2.0.html §0C.11.3
---
## 关联 / 联系人
### 链接
- **Issue**: [#7211](https://git.1814.love:8443/wx/HL/issues/7211)
- **PR(order-v3)**: [#7225](https://git.1814.love:8443/wx/HL/pulls/7225)
- **PR(user-service)**: [#7226](https://git.1814.love:8443/wx/HL/pulls/7226)
### 联系人
- **后端负责人**: wx
- **前端负责人(hl-ui)**: mmg
@@ -0,0 +1,290 @@
---
schema: "hl-changelog/v2"
ticket: "7217"
title: "支付管理·出纳(待付款队列 / 登记付款 / 已付款台账 / 收款确认 + 业务外状态机补全)"
consumer: "admin"
author: "yst"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "7278c5f9"
target_release: ""
verified_at: "2026-09-10"
status_note: "verified 2026-09-10 mmg: cashier.js 四端点(queue/pay/payments-page/confirm-in)+buildCashierPayPayload 按 bizType 分叉(NONBIZ→payAccountId+amount+fee,EXPENSE→fundAccountId+actualAmount);CashierQueuePage 共享页(props.payType) n-tabs[待付款,已付款台账],登记付款弹窗金额默认带入 actualAmount 可改(不一致后端仅 WARN);confirm-in 触发点归 #7165 业务外收入列表。pay/expense 与 pay/nonbiz-out 两薄壳。checkpoint 全绿。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 支付管理(出纳统一收付执行)
## 1. 接口背景
财务域「支付管理」:出纳统一收付执行出口。上游业务单(本期=业务外支出)审批通过后进入出纳待付款队列,出纳在队列里选单登记付款——后端同事务完成「记资金流水 OUT + 回写业务单 PAID + 重算账户结存 + 透支闸校验」;业务外收入走「收款确认入账」端点直接记 IN 流水。同时业务外收支域补两个状态机推进端点(submit/approve),把单据从草稿推到已批准。
- 出纳域路径前缀:/admin/finance/cashier(4 个新端点)
- 业务外域补端点:/admin/finance/nonbiz-flows/{id}/submit、/{id}/approve(2 个新端点)
- 服务:hl-finance(财务服务)
## 2. 变更清单
| 类型 | 接口 | 说明 |
|---|---|---|
| 新增 | GET /admin/finance/cashier/queue | 出纳待付款队列(本期仅 NONBIZ 业务外支出进队列) |
| 新增 | POST /admin/finance/cashier/pay | 登记付款(统一动作:记 OUT 流水 + 回写 PAID + 重算结存 + 透支闸) |
| 新增 | GET /admin/finance/cashier/payments/page | 出纳已付款流水台账分页(数据源 fin_fund_flow OUT) |
| 新增 | POST /admin/finance/cashier/confirm-in | 收款确认入账(业务外收入批准即入账:记 IN 流水 + 回写 PAID) |
| 新增 | PUT /admin/finance/nonbiz-flows/{id}/submit | 提交收支单(PENDING → SUBMITTED,提交后锁定) |
| 新增 | PUT /admin/finance/nonbiz-flows/{id}/approve | 批准收支单(SUBMITTED → APPROVED;本期手工置,企微审批待接通) |
## 3. 接口详情
### 3.1 待付款队列 GET /queue
- 使用场景:管理后台「支付管理」待付款页签,出纳看待付单据列表。
- 认证:管理后台管理员 Token(Authorization: Bearer admin-token),未登录返 401。
- 幂等性:查询接口,天然幂等。限流:网关默认。
### 3.2 登记付款 POST /pay
- 使用场景:出纳实际打款后在系统登记。后端同事务:记资金流水 OUT → 回写业务单 status=PAID → 重算账户结存 → 透支闸(余额不足且账户不允许透支时整单回滚报 598604)。
- 认证:同上。幂等性:业务幂等——同一业务单重复付款被 CAS 条件更新拦截,返回 598602,不会重复记账。限流:网关默认。
### 3.3 已付款台账 GET /payments/page
- 使用场景:出纳「已付款」页签,查历史付款流水(数据源为资金流水表 fin_fund_flow 的 OUT 出纳业务类记录)。
- 认证:同上。幂等:查询接口。限流:网关默认。
### 3.4 收款确认入账 POST /confirm-in
- 使用场景:业务外收入单批准(APPROVED)后,出纳确认钱已到账,记 IN 流水并回写单据 PAID。入账金额 = 单据实收 actualAmount,不可手改。
- 认证:同上。幂等性:业务幂等——单据回写 PAID 后重复调用报 598606。限流:网关默认。
### 3.5 提交收支单 PUT /nonbiz-flows/{id}/submit
- 使用场景:业务外收支草稿录入人提交审批,单据 PENDING → SUBMITTED 并锁定(不可编辑/删除)。
- 认证:同上。幂等性:重复提交报 598505。限流:网关默认。无 Body。
### 3.6 批准收支单 PUT /nonbiz-flows/{id}/approve
- 使用场景:财务负责人批准审批中的单据,SUBMITTED → APPROVED;支出单此后进出纳待付款队列,收入单待收款确认。本期为手工审批,企微审批流待接通。
- 认证:同上。幂等性:重复批准报 598505。限流:网关默认。无 Body。
## 4. 接口入参
### 4.1 队列 Query(CashierQueueReqVO)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| payType | String | 否 | NONBIZ | 付款类型;空=NONBIZ。本期仅接通 NONBIZ(业务外支出),其余为预留枚举未建上游,传未接通值报 598607 |
| page | Number | 否 | 默认 1 | 页码 |
| pageSize | Number | 否 | 默认 20 | 每页条数 |
### 4.2 登记付款 Body(CashierPayReqVO)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| bizType | String | 是 | NONBIZ | 业务类型;本期仅 NONBIZ(业务外支出) |
| bizId | Number | 是 | — | 业务单据 ID(须 status=APPROVED,否则 598602) |
| payAccountId | Number | 是 | — | 出账公司账户 ID(fin_fund_account,不存在/停用报 598603) |
| payMethod | String | 否 | — | 付款方式(字典 fin_pay_way 码值:CASH/BANK_TRANSFER/WECHAT/ALIPAY) |
| amount | Number | 是 | 大于0 | 付款金额(598605) |
| fee | Number | 否 | 不小于0 | 手续费(挂出账流水) |
| voucherNo | String | 否 | — | 付款凭证号 |
| voucherUrl | String | 否 | — | 付款凭证影像 URL |
| payDate | String | 是 | yyyy-MM-dd | 付款日期(可回溯补录) |
| operatorName | String | 否 | — | 后端忽略,统一取当前登录人快照;字段保留仅为入参兼容 |
### 4.3 台账 Query(CashierPaymentPageReqVO)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| bizType | String | 否 | NONBIZ | 业务类型筛选;空=NONBIZ |
| fundAccountId | Number | 否 | — | 出账公司账户 ID;空=不限 |
| flowNo | String | 否 | 模糊 | 流水号筛选 |
| flowAtStart | String | 否 | yyyy-MM-dd | 收付日期起 |
| flowAtEnd | String | 否 | yyyy-MM-dd | 收付日期止 |
| page | Number | 否 | 默认 1 | 页码 |
| pageSize | Number | 否 | 默认 20 | 每页条数 |
### 4.4 收款确认 Body(CashierConfirmInReqVO)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| bizId | Number | 是 | — | 业务单据 ID(须 direction=IN 且 status=APPROVED,否则 598606) |
| payAccountId | Number | 是 | — | 入账公司账户 ID(fin_fund_account) |
| payMethod | String | 否 | — | 收款方式(字典 fin_pay_way 码值) |
| voucherNo | String | 否 | — | 收款凭证号 |
| voucherUrl | String | 否 | — | 收款凭证影像 URL |
| payDate | String | 是 | yyyy-MM-dd | 收款日期(可回溯补录) |
### 4.5 状态机端点路径参数
| 接口 | 字段 | 类型 | 说明 |
|---|---|---|---|
| PUT /nonbiz-flows/{id}/submit、/{id}/approve | id | Number | 收支单 ID;无 Body |
## 5. 出参字段
### 5.1 队列行(CashierQueueRowRespVO)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | String | 单据 ID(雪花字符串) |
| bizNo | String | 业务单号(上游单据无单号列时为空,前端展示以 id 兜底) |
| payType | String | 付款类型码(NONBIZ) |
| payTypeName | String | 付款类型中文名(业务外支出) |
| unitId | String | 外部单位 ID(雪花字符串) |
| unitName | String | 外部单位名快照 |
| category | String | 收支类别码 |
| categoryName | String | 收支类别中文名(取自 fin_nonbiz_category) |
| amount | Number | 付款金额 |
| fee | Number | 手续费(挂本单) |
| actualAmount | Number | 实付 = amount − fee |
| operatorName | String | 申请人姓名快照 |
| createTime | String | 申请时间(yyyy-MM-dd HH:mm:ss) |
| occurDate | String | 发生日期(yyyy-MM-dd) |
| status | String | 单据状态(队列内恒 APPROVED) |
| remark | String | 备注 |
### 5.2 台账行(CashierPaymentRowRespVO)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | String | 流水 ID(雪花字符串) |
| flowNo | String | 流水号(LS+yyyyMMdd+4位序号) |
| fundAccountId | String | 出账公司账户 ID(雪花字符串) |
| accountName | String | 出账账户名称 |
| amount | Number | 金额 |
| fee | Number | 手续费(挂出账流水) |
| balanceAfter | Number | 本笔记完后账户结存快照 |
| bizType | String | 业务类型码(NONBIZ) |
| bizTypeName | String | 业务类型中文名 |
| bizId | String | 关联业务单据 ID(雪花字符串) |
| bizNo | String | 业务单号(上游无单号列时为空) |
| counterparty | String | 对方单位名快照 |
| voucherUrl | String | 付款凭证影像 URL |
| flowAt | String | 收付落账时间(yyyy-MM-dd HH:mm:ss) |
| operatorName | String | 经办人姓名快照 |
| remark | String | 备注 |
### 5.3 付款 / 收款确认响应(CashierPayRespVO)
| 字段 | 类型 | 说明 |
|---|---|---|
| flowId | String | 资金流水 ID(雪花字符串) |
| flowNo | String | 资金流水号(LS+yyyyMMdd+4位序号) |
| balanceAfter | Number | 本笔记完后账户结存快照 |
| bizId | String | 业务单据 ID(已回写 PAID) |
### 5.4 状态机端点响应
data 为 null(Result<Void>),code=200 即流转成功。
## 6. 枚举 / 数据字典
### payType / bizType(出纳业务类型,代码枚举)
| 值 | 中文 | 说明 |
|---|---|---|
| NONBIZ | 业务外支出 | 本期唯一接通(队列/登记/台账均支持) |
| EXPENSE / PAYMENT 等 | — | 预留枚举,上游未建,传入报 598607 |
### 业务外单据状态机(本次补全后)
PENDING →(submit)→ SUBMITTED →(approve)→ APPROVED →(出纳 pay / confirm-in)→ PAID;SUBMITTED 可驳回为 REJECTED(驳回端点后续提供)。状态值见「06_7165_业务外收支流水」changelog 第 6 节。
### payMethod(收付方式,数据字典 fin_pay_way)
| 码值 | 中文 |
|---|---|
| CASH | 现金 |
| BANK_TRANSFER | 银行转账 |
| WECHAT | 微信 |
| ALIPAY | 支付宝 |
## 7. 错误码(段位 598600-598699 + 业务外段补 598505)
| 错误码 | 含义 | 触发场景 |
|---|---|---|
| 598601 | 业务单不存在 | bizId 无效或已软删 |
| 598602 | 业务单状态非已批准,不可付款 | 业务单非 APPROVED 即登记付款;同一单据重复付款(CAS 兜底)也报此码 |
| 598603 | 出账账户不存在或已停用 | payAccountId 无效(recordFlow 内 FOR UPDATE 重读兜底) |
| 598604 | 账户余额不足且不允许透支 | 出账触发透支闸(资金流水域 595103 在出纳边界的翻译) |
| 598605 | 付款金额无效(金额须大于0,手续费不得为负) | amount≤0 或 fee<0 |
| 598606 | 收款确认单状态非法(须为已批准的业务外收入单) | confirm-in 的单据非 direction=IN+APPROVED;重复确认也报此码 |
| 598607 | 付款类型非法 | payType/bizType 传未接通值(本期仅 NONBIZ) |
| 598505 | 状态流转非法(提交须草稿态,批准须审批中) | submit/approve 时状态不符(业务外收支段,见 7165 changelog) |
## 8. 示例
### 8.1 典型成功(队列选单 → 登记付款)
```http
GET /admin/finance/cashier/queue?payType=NONBIZ&page=1&pageSize=20
```
```json
{"code":200,"success":true,"data":{"records":[{"id":"2094311122233344455","bizNo":null,"payType":"NONBIZ","payTypeName":"业务外支出","unitId":"2094001122334455667","unitName":"市文旅局","category":"DEPOSIT_REFUND","categoryName":"押金退回","amount":2000.00,"fee":0,"actualAmount":2000.00,"operatorName":"腰苏图","createTime":"2026-09-06 11:00:00","occurDate":"2026-09-05","status":"APPROVED","remark":"质保金退回"}],"total":1,"page":1,"pageSize":20}}
```
```http
POST /admin/finance/cashier/pay
Content-Type: application/json
{
"bizType": "NONBIZ",
"bizId": 2094311122233344455,
"payAccountId": 2097009988776655443,
"payMethod": "BANK_TRANSFER",
"amount": 2000.00,
"fee": 0,
"voucherNo": "FK-20260907-01",
"payDate": "2026-09-07"
}
```
```json
{"code":200,"success":true,"data":{"flowId":"2094400011122233445","flowNo":"LS202609070001","balanceAfter":112894.00,"bizId":"2094311122233344455"}}
```
### 8.2 边界情况(回溯补录 / 收款确认)
付款日期可回溯补录历史付款:
```json
{"bizType":"NONBIZ","bizId":2094311122233344460,"payAccountId":2097009988776655443,"amount":0.01,"payDate":"2026-08-15"}
```
收款确认入账(业务外收入 APPROVED 单):
```json
{"bizId":2094311000000000001,"payAccountId":2097009988776655443,"payMethod":"BANK_TRANSFER","payDate":"2026-09-07"}
```
入账金额由后端取单据 actualAmount,Body 无金额字段。空队列:records=[]、total=0,HTTP 200。
### 8.3 业务失败(重复付款 / 余额不足 / 类型非法)
对同一单据再次登记付款:
```json
{"code":598602,"message":"业务单状态非已批准,不可付款","success":false,"data":null}
```
账户余额不足且不允许透支:
```json
{"code":598604,"message":"账户余额不足且不允许透支","success":false,"data":null}
```
payType 传未接通值:
```json
{"code":598607,"message":"付款类型非法(本期仅支持 NONBIZ 业务外支出)","success":false,"data":null}
```
## 9. 业务边界
适用:
- 业务外支出(NONBIZ OUT)批准后的出纳付款执行与台账查询。
- 业务外收入(NONBIZ IN)批准后的收款确认入账。
- 业务外收支单从草稿到已批准的状态机推进(submit/approve)。
不适用 / 限制:
- 队列/登记付款本期仅接通 NONBIZ 一条线(方案 A);工资/提成等类型已裁掉,其余枚举预留未建上游,传未接通值报 598607。
- 批准本期为手工操作(approve 端点直接置 APPROVED),企微审批流留 TODO 未接通。
- 出纳实付金额与单据 actualAmount 不一致时后端打 WARN 审计日志但不硬拦,允许出纳按实际打款登记。
- confirm-in 入账金额恒等于单据 actualAmount,不支持部分入账。
特殊边界:
- operatorName 入参被后端忽略,经办人统一取当前登录人快照,防止冒名登记。
- 登记付款 / 收款确认均为同事务「流水 + 回写」原子操作,失败整单回滚,不会出现只记流水不回写。
- fin_nonbiz_flow 本 PR 补 3 列(pay_account_id/pay_flow_id/paid_at,Flyway V20260906_104),部署自动执行。
## 10. 注意事项
- 所有雪花 ID 均为字符串,前端按 String 处理。
- 队列行 bizNo 可能为空(上游单据无单号列),前端展示建议以单据 id 兜底。
- 台账数据源是资金流水表(fin_fund_flow OUT),不是业务单表;一笔付款对应一行台账。
- balanceAfter 是「本笔记完后」的账户结存快照,前端可直接展示无需再查账户余额。
- 业务外收支域的 5 个基础端点见「06_7165_业务外收支流水」changelog;本文件只覆盖出纳 4 端点 + nonbiz 2 个流转端点。
## 11. 关联 / 联系人
- Issue:https://git.1814.love:8443/wx/HL/issues/7217
- PR:https://git.1814.love:8443/wx/HL/pulls/7221
- Commit:https://git.1814.love:8443/wx/HL/commit/850cbf454c
- Epic:https://git.1814.love:8443/wx/HL/issues/7216
- 负责人:腰苏图(yst)
@@ -0,0 +1,540 @@
---
schema: "hl-changelog/v2"
ticket: "7232"
title: "费用报销(申请→审批→出纳付款)+ 出纳队列接通费用线"
consumer: "admin"
author: "yst"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "860ad6bc"
target_release: "v2.1"
verified_at: "2026-09-10"
status_note: "verified 2026-09-10 mmg: expense.js 八端点+EXPENSE_STATUS 五态映射;expense/expense 列表页(状态/部门/公司/关键字筛选,分类末级按名传/公司按名传/部门手录注释,驳回 rejectReason 必填,详情按 REJECTED/PAID 条件显,操作列按状态机分叉);出纳 cashier 三端点扩容 EXPENSE 线随 #7217 一并交付(commit 7278c5f9)。expense.spec 锁端点/ID 透传/状态兜底。checkpoint 全绿。2026-09-10 后端更正:§10 出纳付款登记入参字段名勘误——付款账户/金额实为统一扁平 payAccountId/amount(此前误写输出字段 fundAccountId/actualAmount,前端据此调用触发 400)。2026-09-10 mmg 跟进修复(commit 860ad6bc):buildCashierPayPayload 删 EXPENSE 分叉统一 payAccountId/amount,CashierQueuePage 清理双键+EXPENSE 手续费恒 0 不传 fee,cashier.spec 断言翻转;checkpoint 全绿(含 Vitest 全量+生产构建)。此前联调记录的 cashier/pay EXPENSE 400 实为本文档笔误所致,非后端校验 bug。"
updated_at: "2026-09-10"
base: "dev-v3"
---
# 费用报销(申请→审批→出纳付款)+ 出纳队列接通费用线
财务域新增**费用报销**子域:员工申请报销 → 财务审批(本期手工)→ 出纳付款 → 回写付讫。同时**出纳域待付款队列 / 付款登记 / 已付款台账**三个既有接口的入参枚举扩容,接通费用报销线(`payType/bizType` 新增 `EXPENSE` 值)。
- expense 新域路径前缀:`/admin/finance/expenses`(8 个新端点)
- cashier 既有域路径前缀:`/admin/finance/cashier`(3 个端点入参枚举扩容,无字段增删)
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 |
|---:|---|---|---|---|
| 1 | 报销分页 | GET | `/admin/finance/expenses/page` | 新增 |
| 2 | 申请报销 | POST | `/admin/finance/expenses` | 新增 |
| 3 | 报销详情 | GET | `/admin/finance/expenses/{id}` | 新增 |
| 4 | 编辑草稿 | PUT | `/admin/finance/expenses/{id}` | 新增 |
| 5 | 删除草稿 | DELETE | `/admin/finance/expenses/{id}` | 新增 |
| 6 | 提交报销 | PUT | `/admin/finance/expenses/{id}/submit` | 新增 |
| 7 | 批准报销 | PUT | `/admin/finance/expenses/{id}/approve` | 新增 |
| 8 | 驳回报销 | PUT | `/admin/finance/expenses/{id}/reject` | 新增 |
| 9 | 出纳待付款队列 | GET | `/admin/finance/cashier/queue` | 修改(入参 `payType` 扩容 `EXPENSE`) |
| 10 | 出纳付款登记 | POST | `/admin/finance/cashier/pay` | 修改(入参 `bizType` 扩容 `EXPENSE`) |
| 11 | 出纳已付款台账 | GET | `/admin/finance/cashier/payments/page` | 修改(入参 `bizType` 扩容 `EXPENSE`) |
## 三、接口详情
### 1. 报销分页 `GET /admin/finance/expenses/page`
#### 使用场景
管理后台「费用报销」列表页,按状态 / 部门 / 公司主体 / 关键字筛选分页查询。
#### 认证 / 幂等性 / 限流
- 认证:管理后台管理员 Token(`Authorization: Bearer <admin-token>`),未登录返 401。
- 幂等性:查询接口,天然幂等。
- 限流:走网关默认限流,无特殊配置。
#### 入参(Query)
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `status` | Query | String | 否 | 见状态枚举 | 按报销状态筛选,省略查全部 |
| `departmentId` | Query | Number | 否 | 正整数 | 按部门 ID 筛选 |
| `company` | Query | String | 否 | — | 按公司主体名称筛选 |
| `keyword` | Query | String | 否 | — | 关键字模糊搜索(单号 / 事由) |
| `page` | Query | Number | 是 | ≥1 | 页码 |
| `pageSize` | Query | Number | 是 | 1-100 | 每页条数 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.records[].id` | String | 报销单 ID(雪花 ID,Long 转字符串防 JS 精度丢失) |
| `data.records[].expenseNo` | String | 报销单号(`FY-` 前缀) |
| `data.records[].expenseCategory` | String | 费用分类末级名称 |
| `data.records[].departmentId` | String | 部门 ID |
| `data.records[].departmentName` | String | 部门名称 |
| `data.records[].company` | String | 公司主体名称 |
| `data.records[].amount` | Number | 报销金额 |
| `data.records[].offsetLoanAmt` | Number | 冲销借款金额(本期恒 0,见业务边界) |
| `data.records[].payableAmt` | Number | 应付金额(本期 = amount) |
| `data.records[].reason` | String | 报销事由 |
| `data.records[].rejectReason` | String | 驳回原因(仅 REJECTED 状态有值,其余为 null) |
| `data.records[].fundAccountId` | String | 付讫资金账户 ID(仅 PAID 有值) |
| `data.records[].status` | String | 状态码,见状态枚举 |
| `data.records[].paidAt` | String | 付讫时间(仅 PAID 有值) |
| `data.records[].approvalInstanceId` | String | 审批实例 ID(本期恒空,企微审批未接) |
| `data.records[].createTime` | String | 创建时间 |
| `data.total` / `page` / `pageSize` | Number | 分页元信息 |
#### 请求示例(典型)
```http
GET /admin/finance/expenses/page?page=1&pageSize=20&status=APPROVED&departmentId=10086
Authorization: Bearer <admin-token>
```
#### 响应示例(典型)
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [
{
"id": "2094278854020399106",
"expenseNo": "FY-20260907-0001",
"expenseCategory": "市内交通费",
"departmentId": "10086",
"departmentName": "市场部",
"company": "呼籁旅行社有限公司",
"amount": 300.00,
"offsetLoanAmt": 0,
"payableAmt": 300.00,
"reason": "客户拜访打车费",
"rejectReason": null,
"fundAccountId": null,
"status": "APPROVED",
"paidAt": null,
"approvalInstanceId": null,
"createTime": "2026-09-07 10:00:00"
}
],
"total": 1,
"page": 1,
"pageSize": 20
}
}
```
#### 空数据 / 边界响应
无匹配时 `records=[]`、`total=0`,HTTP 200,前端正常渲染空列表。
### 2. 申请报销 `POST /admin/finance/expenses`
**VO**: `ExpenseCreateReqVO`
#### 使用场景
员工在管理后台提交一张费用报销单,落库为 PENDING 草稿(未进审批)。
#### 认证 / 幂等性 / 限流
- 认证:管理后台管理员 Token。
- 幂等性:非幂等(每次创建生成新单号),重复提交产生多张单据,前端提交后应禁用按钮。
- 限流:网关默认。
#### 入参(Body)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| `expenseCategory` | String | 是 | 必须存在、为末级(level=2)、未停用 | 费用分类**末级名称**,按名称传不按 ID(取自 `fin_expense_category` 字典) |
| `departmentId` | Number | 是 | 必须存在 | 部门 ID(用户域组织树 `wechat_department`) |
| `company` | String | 是 | 必须为启用项 | 公司主体名称,按名称传(取自 `travel_agency` 启用项) |
| `amount` | Number | 是 | >0 | 报销金额 |
| `reason` | String | 是 | 非空 | 报销事由 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.id` | String | 新建报销单 ID |
| `data.expenseNo` | String | 报销单号(`FY-` 前缀) |
| `data.status` | String | 创建后为 `PENDING` |
#### 请求示例(典型成功)
```json
{
"expenseCategory": "市内交通费",
"departmentId": 10086,
"company": "呼籁旅行社有限公司",
"amount": 300.00,
"reason": "客户拜访打车费"
}
```
#### 请求示例(边界情况)
金额为最小粒度 / 事由最长:
```json
{
"expenseCategory": "办公用品费",
"departmentId": 10086,
"company": "呼籁旅行社有限公司",
"amount": 0.01,
"reason": "采购 A4 打印纸一箱"
}
```
#### 请求示例(业务失败)
费用分类传了非末级 / 已停用名称 → 598703:
```json
{
"expenseCategory": "交通费",
"departmentId": 10086,
"company": "呼籁旅行社有限公司",
"amount": 300.00,
"reason": "打车"
}
```
```json
{"code":598703,"message":"费用分类非法(不存在/非末级/已停用)","success":false,"data":null}
```
金额 ≤0 → 598704;部门或公司主体非法 → 598705。
### 3. 报销详情 `GET /admin/finance/expenses/{id}`
#### 入参
| 字段 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---:|---|
| `id` | Path | String | 是 | 报销单 ID(雪花字符串) |
#### 出参
字段与分页行一致(`id/expenseNo/expenseCategory/departmentId/departmentName/company/amount/offsetLoanAmt/payableAmt/reason/rejectReason/fundAccountId/status/paidAt/approvalInstanceId/createTime`),见 §1 出参表。
#### 错误响应
```json
{"code":598701,"message":"报销单不存在","success":false,"data":null}
```
### 4. 编辑草稿 `PUT /admin/finance/expenses/{id}`
**VO**: `ExpenseUpdateReqVO`(字段与 `ExpenseCreateReqVO` 完全一致,见 §2 入参表)
#### 业务边界
- 仅 `PENDING` 状态可编辑,其余状态调用返回 598702。
- 编辑不改单号,状态保持 PENDING。
#### 请求示例
```json
{
"expenseCategory": "市内交通费",
"departmentId": 10086,
"company": "呼籁旅行社有限公司",
"amount": 350.00,
"reason": "客户拜访打车费(含返程)"
}
```
#### 错误响应
```json
{"code":598702,"message":"报销单状态非法(仅PENDING可编辑删除、提交须PENDING、审批须SUBMITTED)","success":false,"data":null}
```
### 5. 删除草稿 `DELETE /admin/finance/expenses/{id}`
#### 业务边界
- 仅 `PENDING` 状态可删除,其余状态调用返回 598702。
- 删除为软删除,单据不再出现在任何列表。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data` | Boolean | `true` 表示删除成功 |
### 6. 提交报销 `PUT /admin/finance/expenses/{id}/submit`
#### 业务边界
- 仅 `PENDING` 状态可提交,提交后流转 `PENDING → SUBMITTED`,单据进入锁定态(不可编辑 / 删除)。
- 无 Body 入参。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.id` | String | 报销单 ID |
| `data.status` | String | 提交后为 `SUBMITTED` |
### 7. 批准报销 `PUT /admin/finance/expenses/{id}/approve`
#### 业务边界
- 仅 `SUBMITTED` 状态可批准,流转 `SUBMITTED → APPROVED`,进入出纳待付款队列。
- 本期为**手工审批**(不接企微审批流),`approvalInstanceId` 恒空。
- 无 Body 入参。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.id` | String | 报销单 ID |
| `data.status` | String | 批准后为 `APPROVED` |
### 8. 驳回报销 `PUT /admin/finance/expenses/{id}/reject`
#### 入参(Body)
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| `rejectReason` | String | 是 | 非空 | 驳回原因(回显在详情 `rejectReason` 字段) |
#### 业务边界
- 仅 `SUBMITTED` 状态可驳回,流转 `SUBMITTED → REJECTED`,不进出纳队列。
#### 请求示例
```json
{"rejectReason": "缺少打车发票附件,请补充后重新提交"}
```
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.id` | String | 报销单 ID |
| `data.status` | String | 驳回后为 `REJECTED` |
### 9. 出纳待付款队列 `GET /admin/finance/cashier/queue`(修改:payType 扩容)
#### 变更点
入参 `payType` 枚举由仅 `NONBIZ` 扩容为 `NONBIZ / EXPENSE`。传 `EXPENSE` 时返回费用报销线(`fin_expense` 表 `status=APPROVED` 的单据);其余字段、结构、分页契约不变。
#### 入参(Query,增量)
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `payType` | Query | String | 否 | `NONBIZ` / `EXPENSE` | 付款类型筛选;省略 / 空 = 既有行为 |
#### 出参(队列行,费用线字段填充规则)
| 字段 | 类型 | 费用线(payType=EXPENSE)取值 |
|---|---|---|
| `id` | String | 报销单 ID |
| `bizNo` | String | 报销单号(= expenseNo) |
| `payType` | String | `EXPENSE` |
| `payTypeName` | String | `费用` |
| `unitId` / `unitName` | String | 槽位复用:部门 ID / 部门名称 |
| `category` / `categoryName` | String | 费用分类(末级名称) |
| `amount` | Number | 报销金额 |
| `fee` | Number | 手续费(费用线为 0) |
| `actualAmount` | Number | 应付金额(= payableAmt) |
| `operatorName` | String | 申请人 |
| `createTime` | String | 创建时间 |
| `occurDate` | String | 发生日期 |
| `status` | String | 队列状态 |
| `remark` | String | 报销事由(= reason) |
#### 请求示例
```http
GET /admin/finance/cashier/queue?payType=EXPENSE
Authorization: Bearer <admin-token>
```
### 10. 出纳付款登记 `POST /admin/finance/cashier/pay`(修改:bizType 扩容)
#### 变更点
入参 `bizType` 枚举新增 `EXPENSE` 值。`bizType=EXPENSE` 时 `bizId` 传报销单 ID,登记付款后回写 `fin_expense` 状态为 `PAID`、记录 `fundAccountId` / `paidAt`(回写列名,非入参字段),同事务记资金流水。
#### 入参(Body,关键字段)
> ⚠️ 出纳付款登记为**全 bizType 统一扁平入参**(NONBIZ / EXPENSE / PAYMENT / PREPAY 共用同一套字段),付款账户 / 金额字段名固定为 `payAccountId` / `amount`。下方为全量必填/关键字段:
| 字段 | 类型 | 必填 | 约束 | 说明 |
|---|---|---:|---|---|
| `bizType` | String | 是 | 新增 `EXPENSE` | 业务类型;费用报销传 `EXPENSE` |
| `bizId` | String | 是 | 对应业务单据 ID | `bizType=EXPENSE` 时传报销单 ID |
| `payAccountId` | String | 是 | 必须存在且 ACTIVE | 出账资金账户 ID(fin_fund_account) |
| `amount` | Number | 是 | >0 | 付款金额(出纳按实际打款金额录入,可与应付不一致) |
| `payDate` | String | 是 | yyyy-MM-dd | 付款日期(可回溯补录) |
| `payMethod` | String | 否 | 字典 fin_pay_way | 付款方式(CASH/BANK_TRANSFER/WECHAT/ALIPAY) |
| `fee` | Number | 否 | ≥0 | 手续费(挂出账流水) |
| `voucherNo` | String | 否 | — | 付款凭证号 |
| `voucherUrl` | String | 否 | — | 付款凭证影像 URL |
#### 请求示例(bizType=EXPENSE)
```json
{
"bizType": "EXPENSE",
"bizId": "2097885645098659841",
"payAccountId": "2095340438490583041",
"amount": 300.00,
"payDate": "2026-09-10",
"payMethod": "BANK_TRANSFER"
}
```
#### 成功响应
```json
{
"code": 200,
"message": "成功",
"data": { "flowId": "2097946971271495682", "flowNo": "LS202609100002", "balanceAfter": 114594.0, "bizId": "2097885645098659841" },
"success": true
}
```
#### 业务边界
- 出纳实际打款金额与 `payableAmt` 不一致时后端**打 WARN 审计日志但不硬拦**(允许出纳按实际打款登记)。
- 并发防重:同事务记资金流水 + 条件更新回写,对同一报销单重复付款返回 598602。
#### 错误响应
重复付款:
```json
{"code":598602,"message":"业务单状态非已批准,不可付款","success":false,"data":null}
```
付款类型非法(598607 文案已扩为含 EXPENSE):
```json
{"code":598607,"message":"付款类型非法,本期支持 NONBIZ/EXPENSE","success":false,"data":null}
```
### 11. 出纳已付款台账 `GET /admin/finance/cashier/payments/page`(修改:bizType 扩容)
#### 变更点
入参新增 `bizType` 筛选,支持 `EXPENSE`;传空 / 省略 = 既有 NONBIZ 行为。其余字段、结构不变。
#### 入参(Query,增量)
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---:|---|---|
| `bizType` | Query | String | 否 | `NONBIZ` / `EXPENSE` | 业务类型筛选;空 = NONBIZ(既有行为) |
#### 请求示例
```http
GET /admin/finance/cashier/payments/page?page=1&pageSize=20&bizType=EXPENSE
Authorization: Bearer <admin-token>
```
## 六.5、枚举 / 数据字典
### `status`(报销单状态,代码枚举)
**所属字段**: `ExpensePageReqVO.status / ExpenseRespVO.status` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `PENDING` | 待提交 | 草稿态,可编辑可删除 |
| `SUBMITTED` | 审批中 | 已提交,锁定(不可编辑 / 删除) |
| `APPROVED` | 已批准 | 审批通过,待出纳付款 |
| `REJECTED` | 已驳回 | 审批驳回,`rejectReason` 有值 |
| `PAID` | 已付讫 | 出纳付款完成,终态只读 |
状态机:`PENDING →(submit)→ SUBMITTED →(approve)→ APPROVED →(出纳付款)→ PAID`;`SUBMITTED →(reject)→ REJECTED`。
### `payType` / `bizType`(出纳域,枚举值扩容)
**所属字段**: `cashier/queue` 入参 `payType`、`cashier/pay` 入参 `bizType`、`cashier/payments/page` 入参 `bizType` | **类型**: `String`
| 值 | 中文 | 说明 |
|---|---|---|
| `NONBIZ` | 非业务付款 | 既有值,行为不变 |
| `EXPENSE` | 费用(报销) | 新增值,费用报销线 |
### `expenseCategory`(费用分类,数据字典 `fin_expense_category`)
- 按**末级名称**(level=2)传值,按名称不按 ID。
- 仅正常(未停用)项可选;传非末级 / 不存在 / 已停用名称返回 598703。
### `company`(公司主体,数据字典 `travel_agency`)
- 按名称传值,仅启用项可选;非法返回 598705。
### 错误码(新段 5987xx + 出纳段扩充)
| 错误码 | 含义 | 触发场景 |
|---|---|---|
| 598701 | 报销单不存在 | 详情 / 编辑 / 删除 / 提交 / 审批传错 ID |
| 598702 | 报销单状态非法 | 非 PENDING 编辑删除、非 PENDING 提交、非 SUBMITTED 审批 |
| 598703 | 费用分类非法 | 分类不存在 / 非末级 / 已停用 |
| 598704 | 金额无效 | `amount ≤ 0` |
| 598705 | 部门或公司主体非法 | `departmentId` 不存在或 `company` 非启用项 |
| 598706 | 单号取号撞号重试耗尽 | 极端并发下取号失败(重试后仍撞号) |
| 598607 | 付款类型非法(文案扩充) | 出纳付款 / 台账传非 `NONBIZ/EXPENSE` 值 |
## 六.6、修改前后对比
| 项目 | 修改前 | 修改后 |
|---|---|---|
| 费用报销接口 | 无 | 新增 `/admin/finance/expenses` 下 8 端点(分页 / 申请 / 详情 / 编辑 / 删除 / 提交 / 批准 / 驳回) |
| 出纳队列 `payType` | 仅 `NONBIZ` | 扩容 `NONBIZ / EXPENSE` |
| 出纳付款 `bizType` | 仅 `NONBIZ` | 扩容 `NONBIZ / EXPENSE` |
| 出纳台账筛选 | 无 `bizType` 入参 | 新增 `bizType` 入参(空 = NONBIZ) |
| 598607 文案 | 仅提示 NONBIZ | 扩为「付款类型非法,本期支持 NONBIZ/EXPENSE」 |
## 六.7、影响评估
- **是否破坏向后兼容**:否。expense 8 端点为纯新增;出纳 3 端点为入参枚举扩容,省略新值时行为与之前完全一致。
- **前端是否必须同步上线**:否。旧功能不受影响;费用报销页面与出纳队列费用线 Tab 可按节奏上线。
- **前端 workaround 清理点**:无(全新能力)。
## 七、不影响范围
- 小程序端(C 端):费用报销为纯管理后台能力,`/mp/**` 零改动。
- 出纳 NONBIZ 线:不传 `payType/bizType=EXPENSE` 时行为与之前完全一致。
- 员工借款 / 冲销:本期未建借款域,`offsetLoanAmt` 恒 0、`payableAmt = amount`,留 TODO 后续接入。
## 八、测试环境已验证
192.168.100.236 测试服行为级 E2E **16 PASS / 0 FAIL**:
- 全链路走通:申请 → 提交 → 批准 → 出纳付款 → 回写 PAID。
- 结存勾稽正确:付款前资金账户余额 114894 − 打款 300 = 114594。
- 并发防重生效:重复付款返回 598602。
- 四类校验生效:598702(状态非法)/ 598703(分类非法)/ 598704(金额无效)/ 598705(部门或公司非法)。
## 当前状态
- 后端:已部署并已验证。
- 前端:待处理(费用报销页面 + 出纳队列费用线 Tab)。
## 十、相关文档
- Issue:[#7232](https://git.1814.love:8443/wx/HL/issues/7232)
- 后端 PR:[#7247](https://git.1814.love:8443/wx/HL/pulls/7247)
## 关联 / 联系人
- **Issue**: [#7232](https://git.1814.love:8443/wx/HL/issues/7232)
- **PR**: [#7247](https://git.1814.love:8443/wx/HL/pulls/7247)
- **后端负责人**: @yst
- **当前状态**: 后端已就绪(TEST 已验证),前端待接入。
@@ -0,0 +1,282 @@
---
schema: "hl-changelog/v2"
ticket: "7234"
title: "车务出行人明文与供应商域判权口径统一到 token 当前角色(契约不变,仅拒绝口径与审计语义)"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "not_required"
frontend_owner: "mmg"
frontend_ref: ""
target_release: ""
verified_at: "2026-09-07"
status_note: "前端 mmg 已 grep 实查为 not_required:契约(请求/响应/错误码)全不变,仅后端判权依据由库默认角色改为 token 当前角色(X-Admin-Role),纯服务端加固。车务明文接口前端经 src/api/fleet/board.js:37 调用但契约不变;供应商 items/list 前端无该路径直接调用;前端 hasPermission(directives/permission.js、stores/user.js)是读自身 store 权限码做 UI 显隐,不感知后端判权口径;X-Admin-Role/roleHasPermission/PERMISSION_CHECK_FAILED 前端零读取。撤权 10 分钟生效为后端缓存 TTL,无需前端改动。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 车务/供应商: 敏感读取判权统一到 token 当前角色
> **服务**: hl-fleet-service (8086/8186) · hl-resource-service · hl-order-service-v3 · hl-user-service
> **PR**: #7272
> **Issue**: #7234
> **日期**: 2026-09-07
> **影响范围**: 管理后台「车务派单看板·查看出行人明文」与「供应商域读写」两条链路的鉴权判定口径
---
## ⚠️ 关键变化
**请求、响应、错误码全部不变,前端不改代码。** 唯一变化是「谁有权限」的判定依据:
由**数据库默认角色**(`admin_user.current_role_id`)改为**本次请求 token 的当前角色**(网关注入的 `X-Admin-Role`)。
对正常账号无可见变化——正常登录时 token 角色与库角色一致。差异只在两者分叉时出现:
短信登录会把 token 角色临时置成 `CUSTOMIZER` 却不改库里的默认角色,改后这类会话一律按 `CUSTOMIZER` 判权,不再借用库角色的权限码。
**撤权最迟 10 分钟生效**:角色权限码走 Redis 缓存(`admin_permissions:v2:role:{roleKey}`,TTL 10 分钟)。撤掉某角色的权限码关联后,最多 10 分钟后该角色被拒。
---
## 一、背景
出行人明文(姓名/证件号/手机号/紧急联系人)是平台敏感度最高的数据。链路上已有调用方白名单、token 与 adminId 一致性校验、二次权限校验、访问审计四道闸,但权限那道判的是**库角色**:一旦 token 角色与库角色分叉,闸门放行与否取决于一个与本次会话无关的字段。
这不是「当前公开入口已可越权读明文」——车务入口在网关层已被 `VEHICLE_MANAGER`/`SUPER_ADMIN` 门禁挡住,供应商守卫也先校验 token 角色集合。本单是**口径加固**:把「谁在操作」的唯一事实来源固定为 token 角色,消除库角色与 token 角色分叉时静默放权的可能,并补上 order-v3 内部二次校验缺失的角色绑定(不接受调用方自报角色)。与 #7210 复审返工同一口径。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 车务查看出行人明文 | POST | `/admin/fleet/board/orders/:orderId/travelers/plain` | 修改接口 | 契约不变;无权限判定由库角色改按 token 当前角色 |
| 2 | 供应商列表(供应商域读写代表) | GET | `/admin/supplier/items/list` | 修改接口 | 契约不变;供应商域读/写/授信/状态/资源关系全部由库角色改按 token 当前角色,角色集合前置校验不变 |
---
## 三、接口详情
### 1. 车务查看出行人明文 `POST /admin/fleet/board/orders/:orderId/travelers/plain`
**VO**: `无请求体` → `Result<List<OrderTravelerPlainForFleetDTO>>`
#### 使用场景
车务派单看板「查看出行人明文」。持 `VEHICLE_MANAGER`/`SUPER_ADMIN` 且其角色拥有 `FLEET_TRAVELER_PLAIN_VIEW` 权限码的车务管理员,查看某订单出行人的明文姓名/证件号/手机号/紧急联系人。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| orderId | path | Long | 是 | 雪花 ID | 订单 ID |
| Authorization | header | String | 是 | `Bearer <管理端 token>` | 网关透传 |
#### 出参 `Result<List<OrderTravelerPlainForFleetDTO>>`
| 字段 | 类型 | 说明 |
|---|---|---|
| name | String | 明文姓名(不变) |
| idNo | String | 明文证件号(不变) |
| phone | String | 明文手机号(不变) |
| emergencyContact | String | 紧急联系人(不变) |
| emergencyPhone | String | 紧急联系人电话(不变) |
| ageAtDeparture | Integer | 按订单出发日计算的年龄(不变) |
本单不改返回结构,字段与改前完全一致。
#### 请求示例
```json
POST /admin/fleet/board/orders/2094610801951203329/travelers/plain
Authorization: Bearer <VEHICLE_MANAGER 的 token>
```
#### 响应示例
```json
{
"code": 200,
"success": true,
"data": [
{"travelerId": "...", "name": "张三", "idNo": "...", "phone": "...", "emergencyContact": "...", "ageAtDeparture": 10}
]
}
```
成功时 `fleet_sensitive_access_log` 落一条 `SUCCESS`(`row_count` = 返回条数)。
#### 空数据 / 降级响应
该订单无出行人时返回空数组。权限服务不可用一律 fail-closed(不返回明文)。
#### 错误响应
```json
{"code": 605039, "success": false, "message": "无权查看出行人明文信息", "data": null}
```
| 码 | 触发 | 审计落库 |
|---|---|---|
| 403(网关) | 当前角色非 `VEHICLE_MANAGER`/`SUPER_ADMIN` | 请求未到达 fleet,**不写审计** |
| 605039 | 到达 fleet 后:当前角色无 `FLEET_TRAVELER_PLAIN_VIEW` 权限码 | `DENIED` + `PERMISSION_DENIED` |
| 605039 | 权限服务不可用(Feign 降级) | `DENIED` + `PERMISSION_CHECK_FAILED`(与上一行区分,供排查故障 vs 无权限) |
#### 业务边界
- **判权口径变化**:无权限判定由 `hasPermission(adminId, FLEET_TRAVELER_PLAIN_VIEW)`(库角色)改为 `roleHasPermission(当前角色, FLEET_TRAVELER_PLAIN_VIEW)`(token 角色)。
- **审计语义不变**:拒绝仍写 `DENIED`+`PERMISSION_DENIED`;`FAILED` 仍只用于 order 服务调用失败分支。新增区分:权限服务降级写 `DENIED`+`PERMISSION_CHECK_FAILED`。
- **内部边界加固**:order-v3 侧二次校验改为从被校验的 token 上取角色判权,请求体声明的角色只做一致性比对,不一致即拒——不接受调用方自报角色。
---
### 2. 供应商列表(供应商域读写代表) `GET /admin/supplier/items/list`
**VO**: `SupplierListQueryReqVO` → `Result<List<SupplierListItemRespVO>>`
#### 使用场景
供应商管理列表查询。此处作为供应商域的代表:域内读/写/授信写/状态写/资源关系维护端点判权口径同步变化,请求/响应/错误码均不变。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| (查询条件) | query | — | 否 | 同改前 | 本单不改查询参数 |
| Authorization | header | String | 是 | `Bearer <管理端 token>` | 网关透传 |
#### 出参 `Result<List<SupplierListItemRespVO>>`
| 字段 | 类型 | 说明 |
|---|---|---|
| supplierId | String | 供应商 ID(雪花,序列化为字符串,不变) |
| supplierNo | String | 供应商编号(不变) |
| shortName | String | 供应商简称(不变) |
本单不改返回结构,字段与改前完全一致。
#### 请求示例
```json
GET /admin/supplier/items/list
Authorization: Bearer <ADMIN/FINANCE/SUPER_ADMIN 的 token>
```
#### 响应示例
```json
{"code": 200, "success": true, "data": [ { "supplierId": "...", "shortName": "..." } ]}
```
#### 空数据 / 降级响应
无命中返回空数组。权限服务不可用一律按无权限 fail-closed(拒绝)。
#### 错误响应
```json
{"code": 403, "success": false, "message": "无权访问供应商数据", "data": null}
```
| 码 | 触发 |
|---|---|
| 403 | 当前 token 角色不在读集合(`READ_ROLES`=ADMIN/FINANCE/SUPER_ADMIN)内 |
| 403 | 角色在集合内但该角色无对应权限码(读);写端点按其既有 `395002`/状态码拒 |
#### 业务边界
- **判权口径变化**:域内私有判权由 `hasPermission(adminId, code)`(库角色)改为 `roleHasPermission(role, code)`(token 角色)。
- **角色集合前置校验不变**:`READ_ROLES`/`WRITE_ROLES`/`STATUS_WRITE_ROLES`/`RESOURCE_RELATION_*` 仍先按 token 角色集合挡一道,对正常账号无可见变化。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误理解
- ✅ 判权只认本次请求 token 的当前角色(`X-Admin-Role`)。多角色账号必须先切到有权限的角色再操作。
- ❌ 不要以为「库里默认角色有权限就一定能读」——短信登录等临时切角色的会话按 token 角色判,不借库角色的码。
- ✅ 撤销某角色的权限码后,最多 10 分钟生效(角色权限码缓存 TTL)。
---
## 五、数据库行为
- 车务明文入口:只对 `fleet_sensitive_access_log` **追加**一条审计(成功 `SUCCESS`/拒绝 `DENIED`/失败 `FAILED`),无其他业务表写入。判权读的是 user-service 的角色权限码缓存(Redis,TTL 10 分钟),非本服务库表。
- 供应商域读端点:无写库;判权同样读 user-service 角色权限码。
- 全链路无表结构变更、无 Flyway。
---
## 六、边界行为
- 车务明文入口:网关角色门禁(403,不写审计)在前,服务内权限码判定(605039,写审计)在后,口径都是 token 角色。
- 供应商域:角色集合前置校验(403)在前,权限码判定在后,口径都是 token 角色。
- 权限服务不可用:车务侧写 `DENIED`+`PERMISSION_CHECK_FAILED`(区分于无权限);供应商侧按无权限拒(其域无区分故障/无权限的审计语义)。
---
## 六.6、修改前后对比
| 维度 | 改前 | 改后 |
|---|---|---|
| 车务明文判权依据 | 库默认角色 `admin_user.current_role_id`(`hasPermission(adminId, code)`) | 本次 token 当前角色 `X-Admin-Role`(`roleHasPermission(role, code)`) |
| 供应商域判权依据 | 同上,按 adminId 落库角色 | 按 token 当前角色 |
| order-v3 内部二次校验 | 只绑定 adminId,角色可由调用方自报 | 角色从被校验 token 上取,请求体声明只做一致性比对,不一致即拒 |
| 权限服务不可用时车务审计 | `DENIED`+`PERMISSION_DENIED` | 新增区分:`DENIED`+`PERMISSION_CHECK_FAILED` |
| 请求 / 响应 / 错误码 | — | **完全不变** |
## 六.7、影响评估
- **前端**:无需改代码。正常账号(token 角色=库角色)行为不变;只需知悉拒绝语义与「撤权最迟 10 分钟生效」。
- **正常账号**:无可见变化。
- **临时切角色会话**(如短信登录临时 `CUSTOMIZER`):按 token 角色判权,不再借用库角色权限码——这是本单的目的。
- **回滚**:纯判权口径与内部端点变更,无数据迁移,回滚 PR 即可;但需按部署顺序(user-service 先上)。
---
## 七、不影响范围
- 请求参数、响应结构、错误码:全部不变。
- 网关路由与 `/admin/fleet/**` 角色门禁:不变。
- 脱敏返回结构、敏感访问审计落库链路:不变(只读,不改写入语义)。
- Flyway / 表结构:无变更。
- 服务间内部端点(`/internal/user/admin/access-token-context` 新增替代 `validate-access-token`)属服务间调用,不面向前端。
---
## 八、测试环境已验证
TEST 网关 + 真实鉴权实测(2026-09-07,PR #7272 分支部署 user→order-v3→fleet→resource):
### 车务明文(AC-10/AC-11)
- 临时 `CUSTOMIZER` token 调明文入口 → 网关 `403`,`fleet_sensitive_access_log` 计数不变(未到 fleet)。
- 真实 `VEHICLE_MANAGER`(持 `FLEET_TRAVELER_PLAIN_VIEW`)登录 token 调同入口 → 返回明文(3 条),审计落 `SUCCESS`(row_count=3)。
- 撤掉 `VEHICLE_MANAGER` 的 `FLEET_TRAVELER_PLAIN_VIEW` 关联并等角色缓存过期后 → 同账号同角色被拒 `605039`,审计落 `DENIED`+`PERMISSION_DENIED`;恢复关联后放行。
### 供应商域(AC-12)
- 同一 adminId(库角色 `VEHICLE_MANAGER`,无 `supplier:list`),token 角色 `ADMIN`/`FINANCE` → 200 出数,`CUSTOMIZER`/`VEHICLE_MANAGER` → 403。判权按 token 角色而非库角色(否则四种都应被拒)。
### 单测与门禁
- user/order-v3/fleet/resource 本单新增改动单测全绿;fleet `spotless:check` 干净。含 AC-4 自报角色绕过用例、AC-5 实经 fallback 工厂用例。
---
## 十、相关文档
- 工单 #7234;同口径前序 #7210 复审返工。
---
## 关联 / 联系人
### 链接
- Issue: https://git.1814.love:8443/wx/HL/issues/7234
- PR: https://git.1814.love:8443/wx/HL/pulls/7272
### 联系人
- 后端: jw
- 前端: mmg(无需改代码;知悉拒绝语义与撤权 10 分钟生效即可)
@@ -0,0 +1,430 @@
---
schema: "hl-changelog/v2"
ticket: "7244"
title: "订单选品接口透出 productType 与团期班期列表 batches"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "ccbbc446"
target_release: ""
verified_at: "2026-09-07"
status_note: "前端已交付并验证(功能主体 ccbbc446 + API 契约注释 7894071e):新增 groupBatch utils(可选仅 ENROLLING/NEARLY_FULL、剩房 ?? 不吞 0、期号取 batchLabel、batchId 全程字符串)与 useOrderDeepLink(详情 lineId 拉选品 batches 定位预选,缺期/已满警告转 Step1);Step1Sku GROUP 卡展开班期行、Step2Info batchLocked 锁出发日跳日历反解、Step3Confirm 增「第N期」、index canNext 缺班期拦截+创单 productBatchId 优先 draft、报价 quote 传所选 batchId。order-v2/new 定向 Vitest 29 绿 + checkpoint 全绿(含 Vitest 全量+生产构建)。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 订单选品接口: 新增 productType 与内嵌可售班期列表 batches
> **服务**: hl-product-service-v2 | **PR**: #7259 | **Issue**: #7244 | **合并提交**: `9800cddde`
> **影响范围**: 管理后台「新建订单 → 选择产品」页
---
## ⚠️ 关键变化
**新建订单时,团期产品(`productType === "GROUP"`)必须让运营选定一个班期,不选不允许进入下一步。班期数据现在直接内嵌在选品接口的每个产品项里,前端不需要再单独请求班期接口。**
响应新增两个字段:`productType`(产品类型)与 `batches`(该产品当前可售班期列表)。**11 个旧字段的名称、类型、顺序一字未动**,不消费新字段的页面不受影响。
---
## 一、背景
新建订单页选完产品后直接让运营手填出发日期,团期产品因此可能建出「没有归属班期」或「出发日与任何班期都对不上」的订单,后续团期看板认领不到该单。
要在选品页当场选班期,就需要选品接口把班期带出来。原本可以再加一个「按产品查班期」的接口,但选品页是分页列表,每个产品一次请求会退化成 N+1;因此改为在选品接口内嵌,一次请求把本页所有团期产品的可售班期全部带回。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 订单选品列表 | GET | `/admin/product/item/order-picker` | **响应新增字段** | 每个产品项新增 `productType` 与 `batches[]` |
入参、路径、错误码均未变。
---
## 三、接口详情
### 1. 订单选品列表 `GET /admin/product/item/order-picker`
**VO**: `OrderPickerProductVO`(本次新增两个静态嵌套类 `OrderPickerProductVO.BatchItem`、`OrderPickerProductVO.BatchTierPrice`)
#### 使用场景
管理后台「新建订单」流程第 1 步「选择产品」页加载与翻页时调用。运营按主题或关键字筛出产品,选中产品后进入第 2 步选档位。本次改动后,团期产品的可售班期随产品一起返回,运营在同一页选定班期,不再需要单独请求班期接口,也不再手填出发日期。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `lineId` | query | Long | 否 | - | 主题/线路 ID,按主题筛选产品 |
| `keyword` | query | String | 否 | - | 产品名关键字 |
| `page` | query | Integer | 否 | 默认 1 | 页码 |
| `pageSize` | query | Integer | 否 | 默认 10 | 每页条数 |
#### 出参 `Result<PageResult<OrderPickerProductVO>>`
`data.records[]` 的每一项:
| 字段 | 类型 | 说明 |
|------|------|------|
| `productId` | String | 产品 ID(雪花,字符串序列化)。非空 |
| `name` | String | 产品名。非空 |
| `subtitle` | String | 副标题。可空 |
| `tripDays` | Integer | 行程天数。可空 |
| `tripNights` | Integer | 行程晚数。可空 |
| `tags` | String[] | 标签。可空 |
| `coverImageUrl` | String | 封面图。可空 |
| `startPrice` | String | 起价(金额字符串)。可空 |
| `tierPrices` | Object[] | 产品档位起价(`tierSeq` / `tierName` / `tierDescription` / `startPrice`)。可空 |
| `hot` | Boolean | 是否热门。非空 |
| `nextSaleDate` | String | 最近可售日。可空 |
| **`productType`** | **String** | **新增**。产品类型,见六.5。非空 |
| **`batches`** | **Object[]** | **新增**。当前可售班期列表。非 GROUP 产品恒为 `[]`(空数组,**不是 null**)。非空 |
**`batches[]` 每一项**
| 字段 | 类型 | 可空 | 说明 |
|------|------|------|------|
| `batchId` | String | 否 | 班期 ID(雪花,字符串序列化)。创建订单时作为 `productBatchId` 回传 |
| `batchNo` | String | 否 | 班期编号 |
| `batchLabel` | String | 否 | 期号,如 `"7"` 表示第 7 期。**按该产品全部未删班期的出发日升序位置计算**,与团期看板显示的期号一致 |
| `batchName` | String | 是 | 班期名(后端已去除前后空格) |
| `departureDate` | String | 否 | 出发日 `yyyy-MM-dd` |
| `endDate` | String | 是 | 返团日 `yyyy-MM-dd` |
| `enrollmentDeadline` | String | 是 | 报名截止日 `yyyy-MM-dd` |
| `maxRooms` | Integer | 否 | 房间数上限。**本接口恒非 null**,`0` = 不限 |
| `remainRooms` | Integer | 是 | 剩余房数。**`null` = 不限,`0` = 已满**。含运营线下占位房数 |
| `maxParticipants` | Integer | 是 | 人数上限,`null` 或 `0` = 不限 |
| `remainSlots` | Integer | 是 | 剩余人数。**`null` = 不限,`0` = 已满** |
| `batchStatus` | String | 否 | 班期状态,见六.5 |
| `batchStatusLabel` | String | 否 | 班期状态中文文案,可直接显示 |
| `tierPrices` | Object[] | 否 | 该班期的档位价,至少 1 项 |
**`batches[].tierPrices[]` 每一项**
| 字段 | 类型 | 可空 | 说明 |
|------|------|------|------|
| `tierSeq` | Integer | 否 | 档位序号,未配置档位时为 `1` |
| `tierName` | String | 否 | 档位名,未配置档位时为 `"默认档"` |
| `adultPrice` | String | 是 | 成人价(金额字符串) |
| `childPrice` | String | 是 | 儿童价(金额字符串) |
#### 请求示例
```http
GET /admin/product/item/order-picker?lineId=2044248925572919297&page=1&pageSize=20
Authorization: Bearer {token}
```
#### 响应示例
测试环境实测,团期产品:
```json
{
"code": 200,
"msg": null,
"data": {
"total": 1,
"records": [
{
"productId": "2044306857534636034",
"name": "冻干粉发短信给",
"subtitle": "发短信给对方搞定",
"tripDays": 3,
"tripNights": 2,
"tags": ["亲子", "研学", "露营"],
"coverImageUrl": "https://hlgl-test.oss-cn-beijing.aliyuncs.com/test/material/2026/03/07/ed278d1da74c3827cb06b7f0cf6091fe.jpg",
"startPrice": "1000.00",
"tierPrices": [
{ "tierSeq": 1, "tierName": "轻奢", "tierDescription": null, "startPrice": "1000.00" }
],
"hot": true,
"nextSaleDate": "2026-10-01",
"productType": "GROUP",
"batches": [
{
"batchId": "2052935476557328386",
"batchNo": "Q202610012052935476548939777",
"batchLabel": "7",
"batchName": "没,那你",
"departureDate": "2026-10-01",
"endDate": "2026-10-03",
"enrollmentDeadline": "2026-09-30",
"maxRooms": 80,
"remainRooms": 25,
"maxParticipants": 0,
"remainSlots": null,
"batchStatus": "ENROLLING",
"batchStatusLabel": "报名中",
"tierPrices": [
{ "tierSeq": 1, "tierName": "轻奢", "adultPrice": "2925.00", "childPrice": "2425.00" }
]
},
{
"batchId": "2096631555760807938",
"batchNo": "Q202612012096631555752419329",
"batchLabel": "8",
"batchName": "QA-7189-1201",
"departureDate": "2026-12-01",
"endDate": "2026-12-03",
"enrollmentDeadline": "2026-11-30",
"maxRooms": 4,
"remainRooms": 4,
"maxParticipants": 6,
"remainSlots": 6,
"batchStatus": "ENROLLING",
"batchStatusLabel": "报名中",
"tierPrices": [
{ "tierSeq": 1, "tierName": "轻奢", "adultPrice": "1000.00", "childPrice": "600.00" }
]
}
]
}
]
}
}
```
#### 空数据 / 降级响应
非团期产品的 `batches` 是空数组而不是 `null`(测试环境实测):
```json
{
"productId": "2049754271968047106",
"name": "的风格发多少",
"productType": "CORE",
"batches": []
}
```
该产品全部班期都已返团或已取消时,`batches` 同样是 `[]`,产品本身仍出现在列表里。
整页无匹配产品时:
```json
{ "code": 200, "msg": null, "data": { "total": 0, "records": [] } }
```
订单侧班期统计服务不可用时,本接口**不失败**:该批班期按「无活跃订单」计算,`remainRooms` / `remainSlots` 可能偏大(偏向显示为可订),响应结构与 `code` 不变,服务端记 ERROR 日志。
#### 错误响应
未新增错误码,沿用既有:
```json
{
"code": 401,
"msg": "未登录或登录已过期",
"data": null
}
```
```json
{
"code": 403,
"msg": "无权限访问该数据",
"data": null
}
```
#### 业务边界
- **鉴权**: 需登录;`@DataScope` 行级校验产品数据权限,越权返 403。
- **只读**: 无任何写操作,不产生数据变更,可重复调用。
- **班期可见性**: 只返回「未取消 且 返团日未过(返团日为空视为未过)」的班期;已返团、取消中、已取消、已软删的班期不出现。满员(`FULL`)的班期**仍然返回**,由前端灰显。
- **期号口径**: `batchLabel` 按该产品**全部未删班期**的出发日升序位置计算,再做可售过滤——所以它与数组下标不对应,与团期看板显示的期号一致。
- **空值语义**: `remainRooms` / `remainSlots` 为 `null` 表示不限,为 `0` 表示已满;`maxRooms` 恒非 null,`0` 表示不限。
- **降级**: 订单侧统计不可用时按无活跃订单计算并记 ERROR,不 500、不阻断页面。
- **兼容**: 11 个旧字段的名称、类型、顺序完全未变,不消费新字段的调用方无需改动。
---
## 四、契约约束与正确调用方式
本接口是只读 GET,无请求体。约束体现在**怎么读响应**,下面是三条读法对照。
### ✅ 正确 / ❌ 错误 读法对照
| 场景 | 读法 |
|------|------|
| ✅ 判断班期能否选中 | `["ENROLLING", "NEARLY_FULL"].includes(batch.batchStatus)` |
| ❌ 判断班期能否选中 | `batch.remainRooms > 0` → 不限容量时 `remainRooms` 为 `null`,`null > 0` 为 `false`,会把不限的班期误判成不可选 |
| ✅ 显示剩余房数 | `batch.remainRooms ?? "不限"` |
| ❌ 显示剩余房数 | `batch.remainRooms \|\| "不限"` → `0`(已满)会被显示成「不限」 |
| ✅ 取期号 | `batch.batchLabel` |
| ❌ 取期号 | `index + 1` → `batches` 已过滤掉已返团班期,下标与真实期号对不上 |
| ✅ 回传班期 | `productBatchId: String(batch.batchId)` |
| ❌ 回传班期 | `productBatchId: Number(batch.batchId)` → 雪花 ID 超出 JS 安全整数范围,精度丢失 |
### 哪些班期会出现在 `batches` 里
后端已过滤,只返回「**未取消** 且 **返团日未过**(返团日为空视为未过)」的班期。已返团、取消中、已取消、已软删的班期不会出现,**前端不需要再过滤一次**。
---
## 五、数据库行为
本接口为只读查询,**无任何写操作**,不产生数据变更。
---
## 六、边界行为
- 未登录 → 401(网关拦截)
- 无该产品数据权限 → 403(`@DataScope` 行级校验)
- 该产品无任何可售班期(全部已返团/已取消)→ `batches` 为 `[]`,产品本身仍出现在列表里
- 非 GROUP 产品 → `batches` 恒为 `[]`,不是 `null`
- 班期未配置档位 → `tierPrices` 返回一项 `tierSeq=1`、`tierName="默认档"` 的兜底档
- 订单侧统计服务降级 → 该批班期按「无活跃订单」计算,剩余数可能偏大,接口不 500 不阻断页面(服务端记 ERROR 日志)
- 本页可售班期总数超过 500 → 服务端记 warn,**不截断**,仍全量返回
---
## 六.5、枚举 / 数据字典
### productType(com.hulalv.enums.ProductTypeEnum)
**所属字段**: `records[].productType` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `CORE` | 核心产品 | `batches` 恒为 `[]` |
| `GROUP` | 小蒙马(即团期产品) | `batches` 为可售班期列表,创建订单必须选定其一 |
| `CUSTOM` | 私人定制 | `batches` 恒为 `[]` |
(中文名取自枚举定义本身,与后台产品管理页的类型显示一致。)
### batchStatus(com.hulalv.enums.BatchStatusEnum)
**所属字段**: `records[].batches[].batchStatus` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `ENROLLING` | 报名中 | **可选中下单**;会出现在 `batches` |
| `NEARLY_FULL` | 即将满额 | **可选中下单**;会出现在 `batches` |
| `FULL` | 已满额 | 不可选(灰显);仍会出现在 `batches` |
| `FINISHED` | 已结束 | 不可选;一般不会出现(返团日已过会被过滤) |
| `CANCELLING` | 取消中 | 不可选;**不会出现**(后端已过滤) |
| `CANCELLED` | 已取消 | 不可选;**不会出现**(后端已过滤) |
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `records[].productType` | 不存在 | 新增,`CORE` / `GROUP` / `CUSTOM` |
| `records[].batches` | 不存在 | 新增,可售班期数组(非 GROUP 恒 `[]`) |
| 其余 11 个字段 | 有 | **完全不变**(名称、类型、顺序、取值均未动) |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 选品页拿班期 | 拿不到,需另外调接口或让运营手填出发日 | 一次请求随产品带回 |
| 班期期号 | 无 | `batchLabel`,与团期看板一致 |
| 请求次数 | 1 次 | 仍是 1 次(班期用一次 `IN` 查询取回,不随产品数增长) |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否。11 个旧字段完全未动,只追加两个新字段。
- **前端是否必须同步上线**: 否(不上线则维持现状,选品页看不到班期)。但**团期订单必须选班期**这条业务要求要靠前端落地,见八.5。
- **前端 workaround 清理点**: 若前端此前为团期产品单独请求过班期接口或让用户手填出发日,改用本接口的 `batches` 后可撤除。
---
## 七、不影响范围
- **仅影响**: 管理后台「新建订单 → 选择产品」页调用的 `GET /admin/product/item/order-picker`
- **零影响**:
- 订单创建接口 `POST /v3/admin/order`(本次未改 order-v3 任何代码)
- 报价 `POST /admin/product/item/{id}/quote`、价格日历 `pricing-calendar`
- 团期看板全部读端点(`/v3/admin/order/group-batch` 系列)
- 产品侧班期管理接口(`/admin/product/item/{id}/schedule` 系列)
- 小程序 C 端全部接口
- 存量数据(不做任何数据迁移,班期名的前后空格只在输出时去除,不回写库)
---
## 八、测试环境已验证
2026-09-07 测试环境(`api.test.1814.love`)网关实测 **15/15 通过**:
```
GET /admin/product/item/order-picker?lineId=2044248925572919297&page=1&pageSize=20 → 200 ✓
productType=GROUP,batches 14 个字段齐全 ✓
各字段与 GET /admin/product/item/2044306857534636034/schedule/list 逐字段一致 ✓
6 个已返团班期被过滤,10-01 与 12-01 期在列,按 departureDate 升序 ✓
期号 10-01="7"、12-01="8",与 /v3/admin/order/group-batch/board?scope=ALL 逐一相等 ✓
班期名 " 没,那你" → "没,那你"(已去空格) ✓
11 个旧字段一个不少 ✓
线下占位: 容量 8 房 + 占位 3 房 + 线上 0 单 → remainRooms=5 ✓
不限容量: maxRooms=0 → remainRooms=null, remainSlots=null, batchStatus=ENROLLING ✓
满员: 容量 1 房 + 占位 1 房 → remainRooms=0, FULL, "已满额", 且仍在 batches 里 ✓
取消与软删的班期不再出现在 batches ✓
非 GROUP 产品 batches 为 [] 而非 null ✓
```
验证产品: `productId=2044306857534636034`(冻干粉发短信给,GROUP,8 期)、`productId=2049754271968047106`(的风格发多少,CORE)
单元测试: `mvn -pl hl-product-service-v2 test` → `Tests run: 1574, Failures: 0, Errors: 0, Skipped: 0`
---
## 八.5、前端配套(mmg 待办)
> **2026-09-07 13:50 复测结论**:后端接口已就绪。`GET /admin/product/item/order-picker?lineId=2044248925572919297` 对产品「冻干粉发短信给」返回 `productType=GROUP` 与 2 期可售班期——第 7 期「没,那你」2026-10-01→2026-10-03 剩 25 房 ENROLLING;第 8 期「QA-7189-1201」2026-12-01→2026-12-03 剩 4 房 ENROLLING。但此刻管理后台「新建订单 → 选择产品」页展开产品后仍只显示档位、没有班期列表,经查 `origin/master` 的 `src/views/order-v2/new/components/Step1Sku.vue` 中 `batches` / `productType` / `batchLabel` / `productBatchId` **均未出现(0 次命中)**,即前端尚未消费本契约。**这不是后端缺陷**,下面 5 条待办完成后该页即可选班期。
1. **`Step1Sku.vue`**: `productType === "GROUP"` 的产品卡展开显示 `batches[]`,每行建议格式
`第{batchLabel}期 · {batchName} · {departureDate} 到 {endDate} · 剩 {remainRooms ?? "不限"} 房 · {batchStatusLabel}`;
`batchStatus` 不属于 `{ENROLLING, NEARLY_FULL}` 的班期灰显不可选。选中档位与班期后 emit 带上 `productBatchId` 与 `batchDepartureDate`。
2. **GROUP 未选班期不允许进入第 3 步**。第 3 步的出发日直接取所选班期的 `departureDate`(不再让用户手选日期),报价 `POST /admin/product/item/{id}/quote` 传 `batchId`。
3. **创建订单 payload**: `productBatchId = String(batch.batchId)`;非 GROUP 产品不传该字段。
4. **深链**: 带 `?productBatchId=…` 进入时,在 `batches` 中预选该期;若该期不在列表里(已结束或已取消),给出提示而不是静默忽略。
5. **金额只显示后端报价结果**,`batches[].tierPrices` 仅用于展示参考价,不参与前端算价。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #7219 | #7189 | 团期看板以产品全班期为基底 + scope 筛选,`batchLabel` 期号口径在此确立 | ✅ 有效 |
| #7256 | #7245 | 班期名去空格 + 不限容量 remain 统一 null + board 库存同源 | ✅ 有效(本接口沿用同一口径) |
| **本 PR #7259** | **#7244** | 选品接口透出 `productType` 与 `batches` | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#7244](https://git.1814.love:8443/wx/HL/issues/7244)
- 关联 PR: [wx/HL#7259](https://git.1814.love:8443/wx/HL/pulls/7259)
- 期号口径来源: `changelogs-v2/2026-09/07_7189_团期看板产品全班期基底与scope范围筛选-修改接口-管理后台.md`
- 剩余库存口径来源: `changelogs-v2/2026-09/07_7245_团期看板班期名与不限容量口径修正-修改接口-管理后台.md`
## 关联 / 联系人
### 链接
- **Issue**: [#7244](https://git.1814.love:8443/wx/HL/issues/7244)
- **PR**: [#7259](https://git.1814.love:8443/wx/HL/pulls/7259)
- **Merge commit**: [9800cddde](https://git.1814.love:8443/wx/HL/commit/9800cddde)
### 联系人
- **后端负责人**: @wx
- **前端负责人**: @mmg
@@ -0,0 +1,389 @@
---
schema: "hl-changelog/v2"
ticket: "7245"
title: "团期看板 batchName 去空格 + 不限容量 remain 统一为 null + board 库存同源"
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 #7256 已合入 dev-v3(732889527);2026-09-07 测试环境网关实测 AC-1/2/4/4b/5/6 全部通过。前端 mmg 已实查为 not_required:无任一读取点用后端 remainRooms/remainParticipants 判满员(PeriodRow 用 enrolledRooms>=maxRooms 自算,maxRooms=0/null 不显满团,与 null=不限一致);remainParticipants 读取点全按 !=null;前端无 batchName.trim();board enrolledRooms 仅显示。结构零变化,显示自动改善,无需改码。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 团期看板: 三处响应值口径修正(结构不变)
> **服务**: hl-order-service-v3 | **PR**: #7256 | **Issue**: #7245 | **合并提交**: `732889527`
> **影响范围**: 管理后台团期看板分页 / board / 详情三个读端点的响应值
---
## ⚠️ 关键变化
**`remainRooms` 与 `remainParticipants` 为 `null` 表示「不限」,前端不要按 `=== 0` 判满员。**
改前:容量字段为 `0` 时这两个值返 `0`,而同一列表里未建团行返 `null`,两类行语义打架,运营看到「剩余 0 人」会误判满员。
改后:容量为 `null` 或 `0` 一律返 `null`;容量大于 0 时才返 `max(0, 容量 − 已用)`。
另有两处修正:班期名去除前后空格;board 未建团行的已用房数改与分页、产品侧同源。
**接口路径、入参、响应结构均未变**,前端无需改码。
---
## 一、背景
1. 产品侧存量脏值(如 `" 没,那你"`,前导空格)经合并层实时透出成看板行标题。product 侧只在保存路径 trim、order 侧只在建团与刷新快照时 trim,**存量列不会自愈**。
2. `remainParticipants` 两路不一致:未建团行走 `normalizeRemainingSlots`(不限返 null),命中行走 `calcRemain`(max=0 返 0)。
3. board 未建团行的已用房数取订单侧计数器(**不含线下占位** `manual_order_count`),而合并分页与产品侧取 `occupiedRooms`(含占位)——**同一个班期两个剩余数**。实测容量 8 / 占位 3 / 线上 0 时,产品侧与分页回「剩 5」,board 回「剩 8」。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期看板分页 | GET | `/v3/admin/order/group-batch` | **响应值修正** | `batchName` 去空格;不限容量 remain 返 null |
| 2 | 团期合并看板 | GET | `/v3/admin/order/group-batch/board` | **响应值修正** | 同上,另:未建团行已用房数改与分页同源 |
| 3 | 团期详情 | GET | `/v3/admin/order/group-batch/:groupBatchId` | **响应值修正** | 同 1 |
---
## 三、接口详情
### 1. 团期看板分页 `GET /v3/admin/order/group-batch`
**VO**: `GroupBatchPageItemRespVO`
#### 使用场景
管理后台「团期订单」看板列表。传 `productId` 走合并基底(命中 / 未建团 / 孤儿三类行),不传走订单侧老路径。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `productId` | Query | Long | ❌ | 字符串传参 | 有值走合并基底 |
| `scope` | Query | String | ❌ | `ONGOING`/`FINISHED`/`ALL` | 缺省:有 productId 时 ONGOING,否则 ALL |
| `opsStage` | Query | String | ❌ | 八桶之一 | 运营阶段筛选 |
| `batchStatus` | Query | String | ❌ | 团期九态 | 状态筛选 |
| `month` | Query | String | ❌ | `yyyy-MM` | 出发月 |
| `keyword` | Query | String | ❌ | — | 编号或名称模糊 |
| `pageNo` / `pageSize` | Query | Integer | ❌ | 默认 1 / 20,上限 100 | 分页 |
#### 出参 `Result<PageResult<GroupBatchPageItemRespVO>>`
本次仅三个字段取值变化,结构不变:
| 字段 | 类型 | 说明 |
|------|------|------|
| `batchName` | String | 班期名,**已去除前后空白**;null 仍为 null |
| `remainRooms` | Integer | 剩余房数。**`maxRooms` 为 null 或 0 时返 null(不限)**,否则 `max(0, maxRooms − enrolledRooms)` |
| `remainParticipants` | Integer | 剩余人数。口径同上,基于 `maxParticipants` 与 `enrolledPeople` |
#### 请求示例
```http
GET /v3/admin/order/group-batch?productId=2044306857534636034&scope=ALL&pageSize=50
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"total": 8,
"records": [
{
"productBatchId": "2052935476557328386",
"batchName": "没,那你",
"maxRooms": 80,
"enrolledRooms": 55,
"remainRooms": 25,
"maxParticipants": 0,
"remainParticipants": null
}
]
}
}
```
#### 空数据 / 降级响应
无匹配班期时 `records` 为空数组、`total` 为 0,不报错。
#### 错误响应
无权限:
```json
{
"code": 589507,
"message": "无权限操作该团期",
"success": false,
"data": null
}
```
#### 业务边界
- 只读接口,不产生任何写入
- `remainRooms` / `remainParticipants` 为 **null 表示不限**,不是「剩 0」
- 命中行容量读订单侧快照,未建团行读产品侧实时值——改产品容量不会立刻影响命中行
- 行集、排序、分页与其余字段值均不变
---
### 2. 团期合并看板 `GET /v3/admin/order/group-batch/board`
**VO**: `GroupBatchBoardItemRespVO`
#### 使用场景
按产品维度展示全部班期(含未建团班期)的合并看板。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `productId` | Query | Long | ✅ | 字符串传参 | 产品 ID |
| `scope` | Query | String | ❌ | 缺省 `ALL` | 范围筛选 |
#### 出参 `Result<List<GroupBatchBoardItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `batchName` | String | 已去除前后空白 |
| `remainRooms` / `remainParticipants` | Integer | 不限时返 null,口径同接口 1 |
| `enrolledRooms` | Integer | **未建团行改取产品侧 `occupiedRooms`(含线下占位)**,与分页及产品侧同源;命中行与孤儿行仍为订单侧计数 |
| `enrolledPeople` | Integer | 未建团行改取产品侧 `enrolledCount`,同上 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/board?productId=2044306857534636034
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": [
{
"productBatchId": "2045498872326717443",
"groupBatchId": null,
"batchName": "冻干粉发短信给",
"maxRooms": 11,
"enrolledRooms": 9,
"remainRooms": 2
}
]
}
```
#### 空数据 / 降级响应
产品下无班期时返回空数组。
#### 错误响应
产品域不可用:
```json
{
"code": 589515,
"message": "拉取产品信息失败",
"success": false,
"data": null
}
```
#### 业务边界
- 只读接口
- **未建团行的已用量含线下占位**,与产品侧 `schedule/list` 剩余库存一致
- 命中行的权威源仍是订单侧计数器,不受产品侧占位影响
---
### 3. 团期详情 `GET /v3/admin/order/group-batch/:groupBatchId`
**VO**: `GroupBatchDetailRespVO`
#### 使用场景
团期详情页头部信息。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `groupBatchId` | Path | Long | ✅ | — | 团期主订单 ID(**不是** productBatchId) |
#### 出参 `Result<GroupBatchDetailRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `batchName` | String | 快照值,已去除前后空白 |
| `remainRooms` / `remainParticipants` | Integer | 不限时返 null,口径同接口 1 |
#### 请求示例
```http
GET /v3/admin/order/group-batch/2096779382436651009
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"batchName": "#7196复验班期",
"maxRooms": 9,
"remainRooms": 9,
"maxParticipants": 0,
"remainParticipants": null
}
}
```
#### 空数据 / 降级响应
无空数据形态;团期不存在返 589500。
#### 错误响应
```json
{
"code": 589500,
"message": "团期不存在",
"success": false,
"data": null
}
```
#### 业务边界
- 只读接口
- 容量读订单侧快照,产品侧改容量需等下次该班期下单刷新才反映
---
## 四、契约约束与正确调用方式
- **`remainRooms` / `remainParticipants` 为 null 一律解释为「不限」**,不要按 `=== 0` 或 `!= null` 判满员;判满员应看 `maxXxx > 0 && remainXxx === 0`。
- **`batchName` 已由服务端去空格**,前端不必再 trim;null 仍为 null(未转空串)。
- **board 与分页的剩余数现已同源**,两处显示不一致即为缺陷,可直接报。
- 命中行与未建团行的容量来源不同(订单侧快照 vs 产品侧实时),验证时不要混用样本。
---
## 五、数据库行为
三个接口均为**只读**,不产生任何写入。本次**无表变更、无 Flyway、无 H2 schema 变更,不回填存量数据**。
产品侧 `group_tour_batch.batch_name` 的存量脏值**未回填**,由读端 trim 兜住。
---
## 六、边界行为
- 容量为 null 或 0 → remain 返 null
- 容量大于 0 且已用超额 → remain 返 0(不返负数)
- `batchName` 为 null → 保持 null
- `batchName` 为纯空白 → 转空串
- 未建团行含线下占位 → 已用量计入
- 无权限 → 589507;产品域不可用 → 589515;团期不存在 → 589500
---
## 六.5、枚举 / 数据字典
本次无新增枚举。
---
## 六.6、修改前后对比
| 项 | 变更前 | 变更后 |
|---|---|---|
| `batchName` | 原样透出,可带前后空格 | **trim 后**输出,null 仍 null |
| 容量为 0 时 `remainRooms` | 返 `0`(前端显示「剩余 0」误判满员) | 返 **null**(不限) |
| 容量为 0 时 `remainParticipants` | 命中行返 `0`、未建团行返 `null`(两路打架) | 三类行统一返 **null** |
| board 未建团行 `enrolledRooms` | 订单侧计数,**不含线下占位** | 产品侧 `occupiedRooms`,**含线下占位** |
| board 与分页剩余数 | 同班期可能不同(实测 8 vs 5) | **一致** |
| 路径 / 入参 / 响应结构 | — | **均未变** |
## 六.7、影响评估
| 维度 | 评估 |
|---|---|
| 兼容性 | 结构零变化,仅三个字段取值修正;前端无需改码 |
| 前端 | 行标题与「剩余」列显示自动改善;若曾用 `remainXxx === 0` 判满员,不限行不再误判 |
| 数据 | 无 DDL、无迁移、无回填;纯读端派生 |
| 性能 | 无额外查询,未建团行改用入参里已有的产品侧字段,零新增 IO |
| 回滚 | 还原 Converter 即可,无数据侧残留 |
| 风险 | 低。改动集中在单个静态 Converter,13 例单测 + 6 组网关用例覆盖 |
---
## 七、不影响范围
- **零影响**:三个接口的路径、入参、响应结构、行集、排序、分页
- **零影响**:导出 CSV(GB-ADM-008,读订单快照)
- **零影响**:建团与刷新时的快照写入路径
- **零影响**:product-v2、`normalizeRemainingSlots`、Feign 契约的 `remainingSlots` MAX_VALUE 语义
- **零影响**:`GroupBatchMergedRowsService`(含 `matchesKeyword`,用 contains,带空格不影响搜索)
---
## 八、测试环境已验证
✅ 2026-09-07 于测试环境网关实测,真实鉴权(管理端 admin),部署本单特性分支后验收再合入 dev-v3。
| # | 用例 | 结果 |
|---|---|---|
| AC-1 | 分页 `batchName` 无前后空白 | ✅ 8 行全通过;目标脏值行 `2052935476557328386` → `"没,那你"` |
| AC-2 | 容量 0/null 的行 remain 均为 null | ✅ 违例 0(其中容量 0/null 的行 1 行构成有效样本) |
| AC-4 | board 同样成立 | ✅ 8 行,带空白 0、违例 0 |
| **AC-4b** | **board 与分页库存同源** | ✅ **8 行 `remainRooms` 全部一致**。样本:未建团行 `2045498872326717443` 已用 9 / 剩 2(含线下占位),改前 board 会回 `0/11` |
| AC-5 | 详情端点 | ✅ `batchName` 无空白;同一行 `maxRooms=9→remainRooms=9`(有限容量正常算)、`maxParticipants=0→remainParticipants=null`(不限) |
| AC-6 | 缺省 productId 老路径回归 | ✅ 22 行,容量 0/null 的 21 行 remain 均为 null,batchName 无空白 |
**本地单测**:`GroupBatchConverterTest` 新增 13 例(trim 四组、max=0 三组、未建团行含线下占位取 `occupiedRooms`、命中行仍用订单侧计数);全量 **8728 例 0 failures**(余 7 个 error 为 Testcontainers 依赖 Docker,纯 dev-v3 同样失败)。
---
## 九、相关历史 PR
- #7256 本次变更
- 前序:#7188(batchLabel 与保存路径 trim)、#7189(合并基底引入,暴露两路 remain 不一致)
---
## 十、相关文档
- 团期需求文档:`docs/group/`(dev-v3 分支)
---
## 关联 / 联系人
- **Issue**: #7245 | **PR**: #7256(合并提交 `732889527`)
- **服务**: hl-order-service-v3
- **后端**: jw | **前端**: 无需改码,仅需知悉「null = 不限」
@@ -0,0 +1,338 @@
---
schema: "hl-changelog/v2"
ticket: "7250"
title: "团期看板分页芯片透出 chipStats 计数(done/total/error),让「一户打回整格红」可解释"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "2eb27845"
target_release: ""
verified_at: "2026-09-08"
status_note: "前端已交付并验证(commit 2eb27845, 汇总工单项5):新增纯函数 chipStatsTip,六芯片 tooltip 透出「已完成 {done}/{total}」,error>0 按房/车「N 户已打回」、约「N 户合同异常」、保「N 户保险异常」追加,导/摄恒不追加,chipStats 缺失回落旧文案;颜色仍只由 chips.X 聚合态决定,spec 锁 error>0 但 chips DONE 仍 done 色。"
updated_at: "2026-09-08"
base: "dev-v3"
---
# 团期: 看板芯片透出 chipStats 计数
> **服务**: hl-order-service-v3
> **PR**: #7254
> **Issue**: #7250
> **日期**: 2026-09-07
> **影响范围**: 管理后台团期看板列表的六个配置芯片(房/车/导/摄/约/保)
---
## ⚠️ 关键变化
**只增字段,`chips` 一字未改。** 同一请求的行集、排序、`chips` 六项取值全部不变,老前端不改不报错。
新增 `records[].chipStats.{hotel|vehicle|guide|photo|contract|insurance} = {total, done, error}`。
**颜色一律以 `chips.X` 为准**,`chipStats` 只解释「红成什么程度」——现场 55 户里 1 户被打回,
芯片就整格红,运营看不出是 1 户还是 55 户出问题。
---
## 一、背景
wx 2026-09-07 在测试服看团期看板,产品「冻干粉发短信给」10-01 期的「房」芯片整格红。
排查结论:该团期 55 户活跃子订单里有 1 户处于「驳回给定制师」,而聚合规则是
「任一户落在失败值集合即整格 ERROR」,且该判定优先于「进行中」。
规则本身是对的——被打回的户需要人工介入,必须显眼——但一个红块表达不出「1/55」还是「55/55」。
wx 拍板:**口径不改,透出计数**。
| | 改前 | 改后 |
|---|---|---|
| `chips.hotel` | `ERROR` | `ERROR`(**不变**) |
| 能看出几户出问题 | 不能 | `chipStats.hotel = {total:55, done:0, error:1}` |
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 团期看板分页 | GET | `/v3/admin/order/group-batch` | 响应新增字段 | 每行新增 `chipStats` 六芯片计数;`chips` 与其余字段不变 |
---
## 三、接口详情
### 1. 团期看板分页 `GET /v3/admin/order/group-batch`
**VO**: `GroupBatchPageItemRespVO`
#### 使用场景
管理后台「团期订单」看板列表(GB-ADM-001)。前端在六个芯片上加悬停提示,用本次新增的三个数
说清「红成什么程度」。调用方 hl-ui `src/stores/orderV2Batch.js` 的 `fetchBatchPage`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| productId | Query | Long | ❌ | — | **入参一个都没变**。传值时走产品全班期合并基底 |
| scope | Query | String | ❌ | ONGOING / FINISHED / ALL | 不变(#7189) |
| opsStage | Query | String | ❌ | 八桶之一 | 不变 |
| batchStatus | Query | String | ❌ | 团期九态 | 不变 |
| month | Query | String | ❌ | yyyy-MM | 不变 |
| keyword | Query | String | ❌ | — | 不变 |
| deadlineFrom / deadlineTo | Query | String(date) | ❌ | — | 不变 |
| pageNo / pageSize | Query | Integer | ❌ | 缺省 1 / 20 | 不变 |
#### 出参 `Result<PageResult<GroupBatchPageItemRespVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| records[].chipStats | Object | **新增**:六芯片计数,键与 `chips` 一一对应,固定返回全部 6 个键 |
| records[].chipStats.{芯片}.total | Integer | 计入统计的户数(免闸户不计);零户行为 0 |
| records[].chipStats.{芯片}.done | Integer | 已完成户数(房车导摄 DONE / 约 SIGNED / 保 INSURED) |
| records[].chipStats.{芯片}.error | Integer | 失败户数(房/车:驳回给定制师、驳回给管理员;约:作废中、已作废;保:已取消、投保失败;导/摄无失败态恒 0) |
| records[].chips 及其余全部字段 | — | **完全不变** |
#### 请求示例
```http
GET /v3/admin/order/group-batch?productId=2044306857534636034&scope=ALL&pageSize=50 HTTP/1.1
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"success": true,
"data": {
"records": [
{
"groupBatchId": "2096412454643802114",
"batchLabel": "7",
"batchName": "没,那你",
"orderCount": 55,
"chips": { "hotel": "ERROR", "vehicle": "TODO", "guide": "TODO", "photo": "TODO", "contract": "TODO", "insurance": "TODO" },
"chipStats": {
"hotel": { "total": 55, "done": 0, "error": 1 },
"vehicle": { "total": 55, "done": 0, "error": 0 },
"guide": { "total": 55, "done": 0, "error": 0 },
"photo": { "total": 55, "done": 0, "error": 0 },
"contract": { "total": 55, "done": 0, "error": 0 },
"insurance": { "total": 55, "done": 0, "error": 0 }
}
}
],
"total": 2, "page": 1, "pageSize": 20
}
}
```
#### 空数据 / 降级响应
零子订单行(含未建团行 `groupBatchId=null`):三个计数全 0,**`chips` 取值沿用既有规则不变**。
```json
{
"groupBatchId": null,
"orderCount": 0,
"chips": { "hotel": "TODO", "vehicle": "TODO", "guide": "TODO", "photo": "TODO", "contract": "TODO", "insurance": "TODO" },
"chipStats": {
"hotel": { "total": 0, "done": 0, "error": 0 },
"vehicle": { "total": 0, "done": 0, "error": 0 },
"guide": { "total": 0, "done": 0, "error": 0 },
"photo": { "total": 0, "done": 0, "error": 0 },
"contract": { "total": 0, "done": 0, "error": 0 },
"insurance": { "total": 0, "done": 0, "error": 0 }
}
}
```
> 注意:零户行的 `chips` **不是恒 `TODO`**——团期处于 `TRIP_FINISHED` / `REVIEWING` / `SETTLED` 时,
> 房车导摄走硬规则 2 得 `DONE`(对空单列表同样生效)。本次只统一零户的三个计数,不动状态。
#### 错误响应
```json
{
"code": 589507,
"message": "无操作权限(非团期管理员 / 非本定制师名下)",
"success": false,
"data": null
}
```
**无新增错误码**。既有:589507 无权限、589515 传 productId 时产品域不可用。
#### 业务边界
- **只读接口**,无写操作、无新失败分支。
- **性能不退化**:失败计数并入既有 `countByChip` 的同一次遍历,芯片相关查询次数与改前一致;
顺带收敛了改前「`aggregateByChip` 调完 `countByChip` 后 `resolveAggregateStatus` 又算一遍」的重复计算,
总遍历次数不增反减。
- **`chips` 与 `chipStats` 同源**:一次 `aggregateAll` 同时产出,不存在两者打架的窗口。
- **覆盖场景**:见「四、契约约束」的例外矩阵——`error > 0` 不等于芯片一定红。
---
## 四、契约约束与正确调用方式
### ✅ 正确 / ❌ 错误消费方式
| 场景 | 做法 |
|------|------|
| ✅ 芯片颜色 | 一律读 `chips.X`,`chipStats` 只用于 tooltip |
| ✅ 完成度文案 | 「已完成 {done}/{total}」 |
| ✅ 异常文案 | 房/车「{error} 户已打回」、约「{error} 户**合同异常**」、保「{error} 户**保险异常**」(或统一「{error} 户异常」)。约/保刻意只写「异常」:合同异常含「作废中 / 已作废」两态,保险异常含「已取消 / 投保失败」两态,要精确原因请点进对应芯片明细(约 = GB-ADM-094、保 = GB-ADM-095) |
| ❌ 把 done/total 说成「待配」 | `done` 是**已完成**户数,不是待办户数 |
| ❌ 把合同异常、保险异常统称「打回」 | 三类失败语义不同,措辞按芯片区分:只有房/车的两个失败态才是「打回」 |
| ❌ 把约的 error 写成「合同已作废」、保的 error 写成「投保失败」 | 以偏概全:约含「作废中 / 已作废」,正在作废会被说成已作废;保含「已取消 / 投保失败」,主动退保会被说成投保失败。统一写「合同异常」「保险异常」,精确原因走明细接口 |
| ❌ 用 `total − done − error` 当「待配户数」 | 里面混着未开始、进行中、待审核三种;现场 55 户里 48 户 PENDING、1 户 PROCESSING、2 户待审核、3 户未开始、1 户打回,仅凭 `done=0/total=55/error=1` **推不出 48**。要精确分布请点进该芯片对应的逐户明细(GB-ADM-090~095,房 090 / 车 091 / 导 092 / 摄 093 / 约 094 / 保 095) |
| ❌ 用 `error > 0` 推断颜色 | 见下方例外矩阵 |
### 例外矩阵:`error` 与 `chips.X` 何时合法背离
后端有三条状态覆盖规则,它们**都保留真实计数、只改状态**:
| 场景 | 触发规则 | 该芯片 `error` | `chips.X` |
|---|---|---|---|
| 团期已流团(CANCELLED),仍有户处于失败态 | 硬规则 1:六芯片恒 `TODO` | > 0 | `TODO` |
| 团期已出行完毕 / 核团中 / 已结算,房务仍有打回户 | 硬规则 2:房车导摄恒 DONE | > 0 | `DONE` |
| 房车导摄未全部完成,合同或保险有异常户(约:作废中 / 已作废;保:已取消 / 投保失败) | 硬规则 3:约/保恒 `TODO` | > 0 | `TODO` |
| 以上都未触发(普通分支) | — | > 0 | `ERROR`(**严格等价**) |
服务端只在普通分支做「`error > 0` 当且仅当 ERROR」的自校验,不一致记 ERROR 日志并以 `chips.X` 为准;
覆盖分支不校验、不记日志。
---
## 五、数据库行为
**无表变更、无 Flyway、无 H2 schema 变更。** 本次只是把内存里已经算出来的数往外给。
---
## 六、边界行为
- 未登录 → 401(网关拦截)
- 无权限 → 589507
- 传 `productId` 时产品域不可用 → 589515
- 零子订单行 / 未建团行 → `chipStats` 六项 `{0,0,0}`,不为 null,前端不必判空
- 免闸户(`needs_hotel` 等为 false)→ 既不计 `total` 也不计 `error`
---
## 六.5、枚举 / 数据字典
### 各芯片的失败值集合(决定 `error` 计数)
**所属字段**: `chipStats.{芯片}.error` | **类型**: `Integer`(下表是被计入的状态值)
| 芯片 | 计入 error 的状态值 | 来源列 |
|----|------|------|
| hotel / vehicle | `REJECTED_TO_CONSULTANT`、`REJECTED_TO_ADMIN` | `order_main.room_control_status` / `vehicle_control_status` |
| contract | `VOIDING`、`VOIDED` | `order_main.contract_status` |
| insurance | `CANCELLED`、`FAILED` | `order_main.insurance_status` |
| guide / photo | 无失败态,`error` 恒 `0` | `order_main.guide_status` / `photographer_status` |
### 各芯片的完成值(决定 `done` 计数)
| 芯片 | 计入 done 的状态值 |
|----|------|
| hotel / vehicle / guide / photo | `DONE` |
| contract | `SIGNED` |
| insurance | `INSURED` |
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `records[].chips` | 六个 String | **完全不变** |
| `records[].chipStats` | 不存在 | 六芯片 `{total, done, error}` |
| 其余全部字段 | — | 不变 |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 芯片红色可解释性 | 只有一个 ERROR | 附带 total/done/error 三个数 |
| 芯片相关查询次数 | N | **N(不变)** |
| 单次聚合的订单遍历次数 | `countByChip` 一趟 + `resolveAggregateStatus` 内部又一趟 | **一趟**(顺带收敛) |
## 六.7、影响评估
- **是否破坏向后兼容**: 否。纯增字段,`chips` 与其余键的值逐项一致。
- **前端是否必须同步上线**: 否。不消费 `chipStats` 则表现与改前完全相同。
- **前端 workaround 清理点**: 无(此前前端没有可清理的兼容逻辑,只是显示不出细节)。
## 七、不影响范围
- **仅影响**: `GET /v3/admin/order/group-batch` 的响应新增一个键
- **零影响**:
- `GET /v3/admin/order/group-batch/board`(board 不含 `chipStats`)
- `GET /v3/admin/order/group-batch/{id}` 团期详情
- `GET /v3/admin/order/group-batch/export` 导出(CSV 列未动)
- `GET /v3/admin/order/group-batch/{id}/chips/{chip}` 芯片明细 GB-ADM-090~095(本就逐户给状态)
- 聚合口径本身(`chips` 的判定规则一字未改)
- 小程序、H5 全部接口
---
## 八、测试环境已验证
✅ **已验证。** 2026-09-07 部署 dev-v3 到测试服并过网关实测,AC-1 ~ AC-9 全部通过
(实测执行与回写见提交 `16e0133`;AC-6d 缺失用例由 PR #7258 补齐 `6d3b38de7`)。
网关实测要点:
| # | 用例 | 结果 |
|---|---|---|
| 1 | `chipStats` 六项与 `chips/{chip}` 明细端点的 `totalCount` / `doneCount` 逐一相等 | ✅ |
| 2 | `error` 计数与明细 `items` 里失败态户数相等——vehicle 芯片 55 户中 1 户 `REJECTED_TO_CONSULTANT`,`chipStats.vehicle.error=1` | ✅ |
| 3 | 跨 6 个产品 55 行普通聚合分支满足「`error > 0` 当且仅当 `chips.X = ERROR`」,**违例 0** | ✅ |
单测证据:`JAVA_TOOL_OPTIONS=-Xmx3g mvn -o -pl hl-order-service-v3 -am test`
全量 **8734 例 Failures: 0**(7 例 Testcontainers 因无 Docker 报错,与基线一致);
`GroupBatchChipResolverTest` 47 例(新增 16:AC-6 七例 + AC-6b 四例 + AC-6c 五例)、
`GroupBatchQueryServiceTest` 41 例(新增 2,含「芯片投影只查一次」断言)、
`GroupBatchConverterTest` 50 例;ArchTest 六道门禁全绿。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #7207 | #7204 | 导/摄零指派落 `TODO` | ✅ 有效 |
| #7192 | #7189 | 看板产品全班期基底与 scope;零户行 chips 口径统一 | ✅ 有效 |
| **本 PR #7254** | **#7250** | 芯片透出 chipStats 计数,口径不改 | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#7250](https://git.1814.love:8443/wx/HL/issues/7250)
- 关联 PR: [wx/HL#7254](https://git.1814.love:8443/wx/HL/pulls/7254)
- 前端待办:`changelogs-v2/2026-09/07_frontend_团期看板对齐后端契约待办汇总-前端优化-管理后台.md` 第 5 条(归 mmg,本篇发布后再动手)
- 接口文档:`docs/group/团期模块接口文档-v2.0.html` GB-ADM-001、`docs/order-v3/api/API-SPEC.html` 已同步
## 关联 / 联系人
### 链接
- **Issue**: [#7250](https://git.1814.love:8443/wx/HL/issues/7250)
- **PR**: [#7254](https://git.1814.love:8443/wx/HL/pulls/7254)
- **Merge commit**: [47c9a6d29](https://git.1814.love:8443/wx/HL/commit/47c9a6d29)
### 联系人
- **后端负责人**: @jw
- **前端负责人**: @mmg(tooltip,见前端汇总 changelog 第 5 条)
@@ -0,0 +1,64 @@
---
schema: "hl-changelog/v2"
ticket: "7263"
title: "大期数团期产品(存活班期 ≥ 约 260 期)库存/已报名/有效订单数/展示态由静默错值恢复为准确值(无接口、字段、路径、参数变化)"
consumer: "multiple"
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 #7268 合入 dev-v3,squash f1eb72444),不动接口/字段/路径/参数/枚举/错误码,仅让大期数(≥约260期)GROUP 产品库存类字段由错变对。前端 mmg 已实查为 not_required:前端读 sellable 判可订、enrolledPeople/remainStock/orderCount 显示,全部为读后端值直接用、无前端兜底/反推逻辑;后端值由错变对,前端显示自动改善,无需改码。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 大期数团期产品库存字段静默降级修复(修复)
## 一、给前端的一句话
**前端无需任何改动。** 本次修复不动接口、不动字段、不动路径、不动参数、不动枚举、不动错误码,只是让**存活班期数很多(约 260 期以上)的 GROUP 团期产品**,其库存类字段从「悄悄算错」恢复为「算对」。字段还是原来那些字段,结构一模一样,只是数值变准了。
## 二、症状(改前)
product-v2 聚合 order-v3 活跃订单数时,把该产品**全部班期 ID** 一次性拼进一个 GET 请求的 query string。当存活班期 ≥ 约 260 期时,请求行超过 order-v3 内嵌 Tomcat 默认 `max-http-header-size`(8192 字节),order-v3 在协议层直接拒收,product-v2 侧把它当成「无活跃订单」降级——**接口仍返回 HTTP 200**,但下面这些字段悄悄退化为只含线下占位值:
- 管理端班期列表 / 统一价格日历:`orderCount`、`enrolledPeople`、`remainStock`、`remainParticipants`、`sold`、`bookedRooms`、`sellable`、`batchStatus`
- 小程序班期列表 / 价格日历 / 最早可售日 / 产品详情:`enrolledCount`、`remainFamilies`、`remainParticipants`、`batchStatus`、`isSelectable`、`remainStock`、`isBooking`
- order-v3 团期看板经 internal 详情合并的库存类字段
后果:满员班期可能显示为「可选 / 可订」,运营选完才被创单侧拒、用户点进去才发现无名额;小程序侧还会把错值缓存 5 分钟。
## 三、修复(改后)
聚合改为在 product-v2 内部按**每片 ≤100 个班期 ID** 分片串行调用 order-v3、内存合并,任意期数都不再超限。**响应契约完全不变**,只是数值恢复准确。
## 四、受影响接口(全部「复用不改」,仅数值由错变对)
| 端 | 接口 |
|---|---|
| 管理后台 | `GET /admin/product/item/:id/schedule/list` |
| 管理后台 | `GET /admin/product/item/:id/pricing-calendar` |
| 管理后台 | `GET /admin/product/item/order-picker` |
| 小程序端 | `GET /mp/product/:id/schedules` |
| 小程序端 | `GET /mp/product/:id/price-calendar` |
| 小程序端 | `GET /mp/product/:id/earliest-available-date` |
| 小程序端 | `GET /mp/product/:id` (`isBooking`) |
以上接口的 method、path、入参、出参字段、枚举、错误码**均无变化**,前端调用方式、类型定义、渲染逻辑一律不用改。
## 五、影响范围
- 只影响存活班期数很多(约 260 期以上)的 GROUP 产品;期数较少的产品改前改后表现一致。
- 二期尚未上生产,本修复当前只作用于测试环境及二期上线后随班期累积而到来的将来。
- 后端只滚 `hl-product-service-v2`,order-v3 / 网关 / 其它服务不动。
## 关联 / 联系人
- 工单:https://git.1814.love:8443/wx/HL/issues/7263
- PR:https://git.1814.love:8443/wx/HL/pulls/7268 (squash f1eb72444 合入 dev-v3)
- 后端:jw
@@ -0,0 +1,260 @@
---
schema: "hl-changelog/v2"
ticket: "7265"
title: "供应商账户原地修改与变更明细"
consumer: "admin"
author: "lc(GIT)"
change_type: "新增接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "626bc9e5"
target_release: ""
verified_at: "2026-09-08"
status_note: "前端已交付并验证(commit 626bc9e5):推翻 #7182 新记录式,PUT /update 改原地修改返回原 accountId(审批期原值 ACTIVE、通过同行应用新值、不新增待审替换行),EditModal/ManageModal/accounts.js 全文去「新记录式」注释与提示,在途审批围栏 pendingApprovalIds 保留;新增 getSupplierBankAccountChangeRecords 封装+SupplierAccountChangeRecordsModal 分页弹窗(锁 occurredAt DESC、operationType/fieldName/status/时间范围筛选、from/to 成对双校验防 395045/395046、valueAvailability 三态),操作列加「变更明细」入口(不显隐,395002 后端兜底);字段维持 #7291 口径不加回三结算字段(#7265 入参表系陈旧表述);supplier 域 181 例绿,对抗 review PASS,checkpoint 全绿。"
updated_at: "2026-09-08"
base: "dev-v3"
---
# 供应商账户:原地修改与变更明细
## 一、关键变化
- `PUT /admin/supplier/bank-accounts/{accountId}/update` 现在返回原 `accountId`,账户列表不再新增待审替换行。审批期间正式账户保持原值和 `ACTIVE`,通过后在同一行应用新值,驳回或撤销则保持不变。
- 新增账户变更明细分页接口,展示该账户的创建、修改、启用、停用和删除历史。
- 本文覆盖 #7182 中“修改会新建账户、返回新 accountId”的旧说明。前端不得把修改响应追加为第二条账户数据。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 提交账户修改审批 | PUT | `/admin/supplier/bank-accounts/{accountId}/update` | 行为修改 | 改为原 accountId 原地审批 |
| 2 | 查询账户变更明细 | GET | `/admin/supplier/bank-accounts/{accountId}/change-records/page` | 新增接口 | 按单个账户分页查询变更事实 |
## 三、接口详情
### 1. 提交账户修改审批 `PUT /admin/supplier/bank-accounts/{accountId}/update`
**VO**: `SupplierAccountUpdateReqVO / BankAccountSubmitResultRespVO`
#### 使用场景
对 `ACTIVE` 账户提交完整新资料并发起企微审批。提交成功后继续使用原账户行和原 `accountId`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `accountId` | Path | String | 是 | 正整数 | 当前账户 ID |
| `accountType` | Body | String | 是 | `CORPORATE` / `PERSONAL` | 对公 / 对私 |
| `bankName` | Body | String | 是 | 非空,最长 500 字符 | 开户银行 |
| `bankBranch` | Body | String/null | 否 | 最长 500 字符 | 开户支行 |
| `accountNo` | Body | String | 是 | 8~128 位,仅数字、空格和 `-` | 除当前账户外须全局唯一 |
| `proofFileUrls` | Body | Array/null | 否 | 最多 20 项 | 完整快照;`[]` 表示清空 |
| `settleMode` | Body | String/null | 否 | `PREPAY` / `MONTHLY` / `SINGLE` | 结算方式 |
| `accountPeriod` | Body | String/null | 条件必填 | `MONTHLY` 时必填,其他方式须为空 | 月结账期 |
| `invoiceType` | Body | String/null | 否 | `SPECIAL` / `NORMAL` / `NONE` | 发票类型 |
| `taxRate` | Body | String/null | 条件必填 | 可开票时必填;`NONE` 时须为空 | 税率 |
| `expectedUpdateTime` | Body | String | 是 | `yyyy-MM-dd HH:mm:ss` | 账户详情或列表的最新 `updateTime` |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `accountId` / `approvalLogId` | String | 原账户 ID / 本次审批记录 ID |
| `requestNo` / `provider` / `spNo` | String | 幂等号 / 审批提供方 / 企微单号 |
| `approvalStatus` / `approvalStatusName` | String | 审批状态 / 中文名 |
| `syncStatus` | String | 审批结果同步状态 |
| `accountStatus` / `isDefault` | String | 正式账户当前状态 / 默认标记 |
| `submittedAt` / `finishedAt` | String/null | 提交时间 / 终态时间 |
#### 请求示例
```http
PUT /admin/supplier/bank-accounts/726500000000000001/update
Authorization: Bearer <当前有效凭证>
Content-Type: application/json
{"accountType":"CORPORATE","bankName":"示例银行","bankBranch":"示例支行","accountNo":"6222000012345678","proofFileUrls":[],"settleMode":"PREPAY","accountPeriod":null,"invoiceType":"NONE","taxRate":null,"expectedUpdateTime":"2026-09-07 15:00:00"}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"accountId": "726500000000000001",
"approvalLogId": "726500000000000002",
"requestNo": "SUP-ACC-UPDATE-example",
"provider": "WECOM",
"approvalStatus": "PENDING",
"approvalStatusName": "审核中",
"spNo": "202609070001",
"spStatus": null,
"syncStatus": "REQUESTING",
"accountStatus": "ACTIVE",
"isDefault": "NO",
"submittedAt": "2026-09-07 15:00:01",
"finishedAt": null
},
"success": true
}
```
#### 空数据 / 降级响应
无空成功数据。企微异常时可能保留原审批事实并返回 `APPLY_FAILED` 或 `RESULT_UNCERTAIN`;正式账户不会提前改变,前端不得自动创建新申请。
#### 错误响应
```json
{"code":395028,"message":"该账户正在变更审批中,请勿重复提交","data":null,"success":false}
```
#### 业务边界
- 仅 `ACTIVE` 供应商的 `ACTIVE` 账户可修改;需要账户管理与审批提交权限。
- `code=200` 仅表示审批已提交,不表示新值已生效;响应 `accountId` 与 Path 中原值相同。
- 同一账户已有在途审批、版本过期或新账号被其他账户占用时失败且不产生第二条账户。
### 2. 查询账户变更明细 `GET /admin/supplier/bank-accounts/{accountId}/change-records/page`
**VO**: `SupplierAccountChangeRecordPageReqVO / PageResult<SupplierAccountChangeRecordRespVO>`
#### 使用场景
在账户管理页查看指定账户的完整变更时间线,并按操作、状态、字段或时间范围筛选。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `accountId` | Path | String | 是 | 正整数 | 账户 ID |
| `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 | 否 | `DRAFT` / `PENDING` / `REJECTED` / `ACTIVE` / `DISABLED` | 事实中的账户状态 |
| `from` / `to` | Query | String | 否 | `yyyy-MM-dd HH:mm:ss` | 必须成对传入,且 `from <= to` |
| `sortBy` | Query | String | 否 | `occurredAt` / `changeLogId`,默认前者 | 排序字段 |
| `sortDirection` | Query | String | 否 | `ASC` / `DESC`,默认 `DESC` | 排序方向 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `records[].operationType` | String | 操作类型 |
| `records[].oldValue` / `newValue` | String | 变更前后中文业务摘要 |
| `records[].valueAvailability` | String | `FULL` 或 `LEGACY_MASKED_UNRECOVERABLE` |
| `records[].changeReason` | String | 变更原因 |
| `records[].status` | String | 账户状态中文名 |
| `records[].operatorName` / `operatorRole` | String | 操作人展示名 / 操作时角色中文名 |
| `records[].occurredAt` | String | 发生时间,格式 `yyyy-MM-dd HH:mm:ss` |
| `total` / `page` / `pageSize` | Integer | 总数 / 当前页 / 每页数量 |
#### 请求示例
```http
GET /admin/supplier/bank-accounts/726500000000000001/change-records/page?page=1&pageSize=20&operationType=UPDATE&sortBy=occurredAt&sortDirection=DESC
Authorization: Bearer <当前有效凭证>
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"records": [{
"operationType": "UPDATE",
"oldValue": "开户支行:旧示例支行",
"newValue": "开户支行:新示例支行",
"valueAvailability": "FULL",
"changeReason": "提交收款账户修改审批,终审前保留原账户原值生效",
"status": "已生效",
"operatorName": "示例管理员",
"operatorRole": "超级管理员",
"occurredAt": "2026-09-07 15:00:01"
}],
"total": 1,
"page": 1,
"pageSize": 20
},
"success": true
}
```
#### 空数据 / 降级响应
无记录时返回 `records: []`、`total: 0`。操作人服务降级时返回不含内部 ID 的稳定展示名;历史完整值不可恢复时返回 `valueAvailability=LEGACY_MASKED_UNRECOVERABLE`。
#### 错误响应
```json
{"code":395045,"message":"历史查询起始与结束时间必须同时传入","data":null,"success":false}
```
#### 业务边界
- 需要 `supplier:approval:read`;账户证明附件继续受 `supplier:account:proof:read` 独立控制,无权限时摘要显示“无权查看”。
- 查询只返回 Path 指定账户的事实,不混入同供应商其他账户或主体变更。
- `oldValue`、`newValue` 可能含完整业务值,前端须按敏感信息展示规范处理。
## 四、契约约束与正确调用方式
1. 编辑前读取账户详情,完整回填当前资料和 `updateTime`。
2. 修改成功后保留原列表行,以响应中的同一 `accountId` 刷新列表;不得追加第二条待审账户。
3. 在账户操作区新增“变更明细”,调用新增 GET 接口并支持分页,默认按 `occurredAt DESC` 展示。
4. `approvalStatus=PENDING` 时展示“审核中”,正式账户仍使用列表返回的 `ACTIVE` 原值;终态后重新拉取列表和明细。
5. 移除 #7182 的“新 accountId / 新待审行替换原行”处理。
## 五、数据库行为
账户修改提交、审批通过、驳回或撤销均只保留原账户列表行和原 `accountId`;提交审批只追加可查询的审批与变更事实,不新增替换账户。校验失败不改变账户或新增事实。
## 六、边界行为
- `401`:未登录或凭证失效。
- `400`:分页、账户字段或结算组合不合法。
- `395001`:账户或所属供应商不存在;`395002`:无读取或写入权限。
- `395005` / `395010`:状态不允许或供应商未生效。
- `395011` / `395028`:账户已有在途审批或变更;`395014`:版本过期;`395027`:新账号被其他账户占用。
- `395045` / `395046`:时间范围缺一端或起止倒置。
- `395019`~`395022`:企微配置、绑定、提交或对账异常,不自动新建申请。
- 业务错误可能仍使用 HTTP 200,必须同时判断响应体 `code` 和 `success`。
## 七、不影响范围
- 不修改账户新增、启用、停用、删除和默认账户切换接口。
- 不改变现有请求字段、枚举或审批状态字段;不影响小程序接口。
- 旧替换式账户历史继续可读,不合并或删除历史记录。
## 八、测试环境已验证
- 修改请求返回原 `accountId`;提交前后账户列表与有效账户数量均为 1,正式账户内容未提前变化。
- 新接口返回新增的 `UPDATE` 明细,分页和 `operationType` 筛选有效。
- 未登录返回 `401`;非法账号返回 `400`;时间范围只传一端返回 `395045`,失败路径未产生写入。
**当前状态:后端已部署并通过 TEST Gateway 验证;待前端接入。**
## 十、相关文档
- [Issue #7265](https://git.1814.love:8443/wx/HL/issues/7265)
- [PR #7271](https://git.1814.love:8443/wx/HL/pulls/7271)
- [被本文纠正的 #7182 说明](https://git.1814.love:8443/wx/HL/issues/7182)
## 关联 / 联系人
### 链接
- **Issue**: [#7265](https://git.1814.love:8443/wx/HL/issues/7265)
- **PR**: [#7271](https://git.1814.love:8443/wx/HL/pulls/7271)
- **Merge commit**: [859a62ddb512074b33103be9bb9e4e579bdbb3ad](https://git.1814.love:8443/wx/HL/commit/859a62ddb512074b33103be9bb9e4e579bdbb3ad)
### 联系人
- **后端负责人**: @lc
@@ -0,0 +1,175 @@
---
schema: "hl-changelog/v2"
ticket: "7274"
title: "景区首次设置供应商免变更原因"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "b8ed1234"
target_release: ""
verified_at: "2026-09-07"
status_note: "前端已交付并验证(commit b8ed1234):SupplierRelationModal 新增 isScenicFirstSet(module==='SCENIC' 且无现有关系),命中时选完供应商跳过原因弹框直提 049、body 不带 changeReason 键(additionalProperties:false 不多传);submitBind/runSubmit 抽公共逻辑复用,rebind/unbind/非 SCENIC 仍走原因弹框必填校验零改动。modal 定向 Vitest 5 绿 + checkpoint 全绿(含 Vitest 全量+生产构建)。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 景区管理:首次设置供应商免变更原因
## 一、关键变化
景区尚未绑定供应商时,用户选择供应商并点击“确定选择”后应直接调用设置接口;不再打开“设置供应商”变更原因弹框,也不需要提交 `changeReason`。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 设置或改绑资源供应商 | PUT | `/admin/supplier/resource-relations/{resourceModule}/{resourceId}/update` | 请求必填性调整 | 仅 `SCENIC` 首次设置允许省略 `changeReason` |
## 三、接口详情
### 1. 设置或改绑资源供应商 `PUT /admin/supplier/resource-relations/{resourceModule}/{resourceId}/update`
**VO**: `SupplierResourceReassignReqVO / SupplierResourceRelationRespVO`
#### 使用场景
景区管理选择候选供应商后,首次设置直接提交;同一路径继续兼容原有改绑流程。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `resourceModule` | Path | String | 是 | 景区固定为 `SCENIC` | 当前资源模块 |
| `resourceId` | Path | String | 是 | 正整数 | 景区 ID |
| `supplierId` | Body | String | 是 | 正整数 | 选中的供应商 ID |
| `changeReason` | Body | String | 条件必填 | 最长 500 字 | `SCENIC` 首次设置可省略;景区改绑及其他资源设置/改绑仍必填 |
| `expectedCurrentSupplierId` | Body | String | 改绑必填 | 与版本时间同传 | 当前关系供应商 ID |
| `expectedRelationUpdateTime` | Body | String | 改绑必填 | `yyyy-MM-dd HH:mm:ss` | 当前关系的 `updateTime` |
| `requiredTypeCode` | Body | String | 否 | 最长 64 字 | 景区沿用既有规则,可省略 |
| `remark` | Body | String | 否 | 最长 500 字 | 关系备注 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `data.relationId` | String | 当前关系 ID |
| `data.supplierId` / `supplierName` | String | 生效供应商 ID / 全称 |
| `data.resourceModule` / `resourceId` | String | 资源模块 / 资源 ID |
| `data.requiredTypeCode` | String | 景区为 `SCENIC` |
| `data.available` / `unavailableReasons` | Boolean / Array | 当前可用性及原因 |
| `data.updateTime` | String | 后续改绑或解绑使用的关系版本 |
| 其他既有字段 | - | 响应字段和语义均未变化 |
#### 请求示例
```http
PUT /admin/supplier/resource-relations/SCENIC/3001000000000000015/update
Authorization: Bearer <当前有效凭证>
Content-Type: application/json
{"supplierId":"2094247871271350274"}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"relationId": "2096882924480589826",
"supplierId": "2094247871271350274",
"supplierNo": "SUP2094247871271350274",
"supplierName": "示例景区供应商",
"resourceModule": "SCENIC",
"moduleName": "景区管理",
"resourceId": "3001000000000000015",
"resourceName": "示例景区",
"requiredTypeCode": "SCENIC",
"requiredTypeName": "景区管理",
"remark": null,
"available": true,
"unavailableReasons": [],
"createTime": "2026-09-07 16:47:02",
"updateTime": "2026-09-07 16:47:02"
},
"success": true
}
```
#### 空数据 / 降级响应
没有空成功数据;校验或依赖失败时返回业务错误和 `data: null`,不会降级为成功,也不会创建或改写关系。
#### 错误响应
```json
{"code":400,"message":"changeReason不能为空","data":null,"success":false}
```
#### 业务边界
- 只有 `SCENIC` 且当前无供应商关系的首次设置可省略 `changeReason`。
- 景区改绑仍须提交两个并发版本字段和非空原因;其他资源设置/改绑与所有解绑行为不变。
- 权限、资源数据范围、供应商状态与资格门禁不变。
## 四、契约约束与正确调用方式
- 当前资源模块为 `SCENIC` 且没有现有关系时,供应商选择器点击“确定选择”后立即提交上述 PUT,请求体不要补默认原因或空原因。
- 提交成功后结束设置流程,不再打开 `SupplierRelationModal` 中的“设置供应商”原因弹框。
- 景区已有关系时仍按改绑流程提交两个并发版本字段和非空 `changeReason`。
- 非景区资源的首次设置、改绑,以及所有资源的解绑流程均保持原原因校验和弹框行为。
## 五、数据库行为
首次设置成功后可立即查询到当前关系,并追加一条原因为空的关系变更审计;后端不会生成默认或伪造原因。校验失败时关系与审计均不变化。本次没有表结构或存量数据迁移。
## 六、边界行为
- 景区显式改绑省略 `changeReason`:`code=400`、`success=false`,消息为 `changeReason不能为空`。
- 未登录:应用响应 `code=401`、`success=false`。
- 业务错误可能仍使用 HTTP 200,调用方必须同时判断响应体 `code` 和 `success`。
- 无新增响应字段、错误码、枚举或接口路径。
## 六.6、修改前后对比
| 场景 | 修改前 | 修改后 |
|---|---|---|
| `SCENIC` 首次设置 | 原因必填,确定选择后打开原因弹框 | 原因可省略,确定选择后直接完成设置 |
| `SCENIC` 改绑 | 原因与并发版本必填 | 不变 |
| 其他资源设置/改绑、解绑 | 原因必填 | 不变 |
## 六.7、影响评估
- **是否破坏向后兼容**: 否;原有携带原因的请求仍可用。
- **前端是否必须同步上线**: 是;后端无法移除前端主动打开的弹框。
- **前端 workaround 清理点**: 景区无现有关系时,删除“确定选择后打开原因弹框”的步骤并直接提交。
## 七、不影响范围
- **仅影响**: 管理后台景区首次设置供应商。
- **零影响**: 景区改绑、其他资源设置/改绑、所有解绑、响应字段、错误码和小程序接口。
## 八、测试环境已验证
- 首次设置请求完全省略 `changeReason`,返回 `code=200`,关系查询与空原因审计一致。
- 匿名设置返回 `401`;景区显式改绑省略原因返回 `400`,失败路径零写入。
- 测试关系已解绑并回读恢复为未绑定状态。
**当前状态:后端已部署并验证;待前端处理。**
## 十、相关文档
- [Issue #7274](https://git.1814.love:8443/wx/HL/issues/7274)
- [PR #7282](https://git.1814.love:8443/wx/HL/pulls/7282)
## 关联 / 联系人
- **Issue**: [#7274](https://git.1814.love:8443/wx/HL/issues/7274)
- **PR**: [#7282](https://git.1814.love:8443/wx/HL/pulls/7282)
- **Merge commit**: [86286f5fdfac0b5e097bf36ab0ac4be757aeaac0](https://git.1814.love:8443/wx/HL/commit/86286f5fdfac0b5e097bf36ab0ac4be757aeaac0)
- **后端负责人**: @lc
@@ -0,0 +1,327 @@
---
schema: "hl-changelog/v2"
ticket: "7290"
title: "供应商合同十项必填并移除登记原因"
consumer: "admin"
author: "lc(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "verified"
frontend_owner: "mmg"
frontend_ref: "87cf8ba8"
target_release: ""
verified_at: "2026-09-07"
status_note: "前端已交付并验证(commit 87cf8ba8):SupplierContractEditModal 十项转必填(文本 trim 非空/枚举日期判空/autoRenew 按 ===true-false 不把 false 当空,endDate 合并必填+不早于开始日期单条 rule),contractNo label 补合同编号→合同编号;删登记/修改原因输入+changeReasonRule+buildBody 不再组装 changeReason;必填恒携带、可选(contractType/amount/pricingMode/settleCycle/remark)保持新增省略/编辑整份替换,expectedUpdateTime 围栏与 395014/395051 提示不变;api add/update JSDoc 改新口径,delete 与删除审计流程零改动。目标 spec 24/24 绿+supplier 域 183/183 绿+checkpoint 全绿(Vitest 全量+生产构建)。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 供应商合同:十项必填并移除登记原因
## 一、关键变化
- 新增、编辑合同均要求提交:合同名称、合同编号、合同状态、业务线、关联主合同、签署日期、自动续约、有效期开始、有效期结束、合同附件。
- 页面字段 `contractNo` 的文案改为“合同编号”,不再显示“补合同编号”。
- 新增、编辑不再要求登记原因,请移除原因输入框并停止发送 `changeReason`;旧调用方多传该字段仍兼容忽略。
- 新增、编辑成功后不再产生供应商变更审计。合同删除接口及其必填原因、删除审计均不变。
本文覆盖此前 #6654、#6842 中“合同业务字段可空、新增或编辑原因必填”的旧口径。
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|---|---|---|---|---|
| 1 | 新增供应商合同 | POST | `/admin/supplier/items/{supplierId}/contracts/add` | 请求校验与行为修改 | 十项必填,移除登记原因与新增审计 |
| 2 | 编辑供应商合同 | PUT | `/admin/supplier/items/{supplierId}/contracts/{contractId}/update` | 请求校验与行为修改 | 十项必填,移除登记原因与编辑审计 |
## 三、接口详情
### 1. 新增供应商合同 `POST /admin/supplier/items/{supplierId}/contracts/add`
**VO**: `SupplierContractCreateReqVO / SupplierContractRespVO`
#### 使用场景
在已有供应商下新增一份完整合同,不再填写登记原因。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID | 所属供应商 |
| `contractName` | Body | String | 是 | 非空,最长 500 | 合同名称 |
| `contractNo` | Body | String | 是 | 非空,最长 100 | 合同编号;页面不得再写“补合同编号” |
| `status` | Body | String | 是 | `DRAFT` / `ACTIVE` / `SIGNED` / `EXPIRED` | 合同状态 |
| `businessLine` | Body | String | 是 | 非空,最长 100 | 业务线 |
| `relatedMainContract` | Body | String | 是 | 非空,最长 100 | 关联主合同引用 |
| `signDate` | Body | String | 是 | `yyyy-MM-dd` | 签署日期 |
| `autoRenew` | Body | Boolean | 是 | `true` / `false` | 自动续约;`false` 是有效值 |
| `startDate` | Body | String | 是 | `yyyy-MM-dd` | 有效期开始 |
| `endDate` | Body | String | 是 | 不早于 `startDate` | 有效期结束 |
| `scanFileUrl` | Body | String | 是 | 非空,最长 1000 | 合同附件永久地址 |
| `contractType` | Body | String/null | 否 | `FRAME` / `SINGLE_TRIP` / `PURCHASE` | 合同类型 |
| `amount` | Body | String/null | 否 | 非负,最多 10 位整数和 2 位小数 | 合同金额 |
| `pricingMode` | Body | String/null | 否 | 最长 100 | 计价方式 |
| `settleCycle` | Body | String/null | 否 | 最长 32 | 结算周期 |
| `remark` | Body | String/null | 否 | 最长 500 | 备注 |
`changeReason` 已从请求模型移除,不要再由新增表单组装。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` / `message` / `success` | Integer / String / Boolean | 成功为 `200` / `成功` / `true` |
| `data.contractId` | String | 合同 ID |
| `data.contractName` / `contractNo` | String | 合同名称 / 合同编号 |
| `data.status` / `businessLine` | String | 合同状态 / 业务线 |
| `data.relatedMainContract` | String | 关联主合同 |
| `data.signDate` / `startDate` / `endDate` | String | `yyyy-MM-dd` |
| `data.autoRenew` | Boolean | 自动续约 |
| `data.scanFileUrl` | String | 合同附件地址 |
| `data.contractType` / `amount` / `pricingMode` / `settleCycle` / `remark` | 对应类型/null | 可选字段,响应结构不变 |
| `data.updateTime` | String | 合同版本,`yyyy-MM-dd HH:mm:ss` |
#### 请求示例
```json
{
"contractName": "年度服务合同",
"contractNo": "HT-2026-001",
"status": "SIGNED",
"businessLine": "旅行服务",
"relatedMainContract": "MAIN-2026-001",
"signDate": "2026-09-07",
"autoRenew": false,
"startDate": "2026-09-07",
"endDate": "2027-09-06",
"scanFileUrl": "https://files.example.com/contracts/demo.pdf",
"contractType": "FRAME",
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"remark": null
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"contractId": "2097000000000000001",
"contractName": "年度服务合同",
"contractNo": "HT-2026-001",
"contractType": "FRAME",
"signDate": "2026-09-07",
"startDate": "2026-09-07",
"endDate": "2027-09-06",
"businessLine": "旅行服务",
"relatedMainContract": "MAIN-2026-001",
"autoRenew": false,
"amount": "1200.50",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "SIGNED",
"scanFileUrl": "https://files.example.com/contracts/demo.pdf",
"remark": null,
"updateTime": "2026-09-07 20:00:00"
},
"success": true
}
```
#### 空数据 / 降级响应
没有空成功数据。十项必填字段缺少、为 `null` 或文本为空白时均失败,`data` 为 `null`。
#### 错误响应
```json
{"code":400,"message":"合同编号不能为空","data":null,"success":false}
```
#### 业务边界
- 前端提交前应同时校验十项必填;业务错误可能仍使用 HTTP 200,必须判断响应体 `code` 和 `success`。
- `autoRenew=false` 不得被空值过滤器删除;`startDate` 不得晚于 `endDate`。
- 权限、供应商状态、审批冻结、幂等和错误码均保持原规则。
### 2. 编辑供应商合同 `PUT /admin/supplier/items/{supplierId}/contracts/{contractId}/update`
**VO**: `SupplierContractUpdateReqVO / SupplierContractRespVO`
#### 使用场景
完整编辑一份已有合同,不再填写编辑原因。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| `supplierId` | Path | String | 是 | 正整数 ID | 所属供应商 |
| `contractId` | Path | String | 是 | 正整数 ID | 目标合同 |
| `contractName` | Body | String | 是 | 非空,最长 500 | 合同名称 |
| `contractNo` | Body | String | 是 | 非空,最长 100 | 合同编号 |
| `status` | Body | String | 是 | `DRAFT` / `ACTIVE` / `SIGNED` / `EXPIRED` | 合同状态 |
| `businessLine` | Body | String | 是 | 非空,最长 100 | 业务线 |
| `relatedMainContract` | Body | String | 是 | 非空,最长 100 | 关联主合同引用 |
| `signDate` | Body | String | 是 | `yyyy-MM-dd` | 签署日期 |
| `autoRenew` | Body | Boolean | 是 | `true` / `false` | 自动续约 |
| `startDate` | Body | String | 是 | `yyyy-MM-dd` | 有效期开始 |
| `endDate` | Body | String | 是 | 不早于 `startDate` | 有效期结束 |
| `scanFileUrl` | Body | String | 是 | 非空,最长 1000 | 合同附件永久地址 |
| `expectedUpdateTime` | Body | String/null | 否 | `yyyy-MM-dd HH:mm:ss` | 传入时必须匹配合同当前版本 |
| `contractType` / `amount` / `pricingMode` / `settleCycle` / `remark` | Body | 对应类型/null | 否 | 与新增接口一致 | 完整替换时原样回传需保留的可选值 |
`changeReason` 已从请求模型移除,不要再由编辑表单组装。
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| `code` / `message` / `success` | Integer / String / Boolean | 成功为 `200` / `成功` / `true` |
| `data.contractId` | String | 原合同 ID |
| `data.contractName` / `contractNo` / `status` / `businessLine` | String | 编辑后的必填文本字段 |
| `data.relatedMainContract` / `scanFileUrl` | String | 关联主合同 / 合同附件地址 |
| `data.signDate` / `startDate` / `endDate` | String | 编辑后的日期 |
| `data.autoRenew` | Boolean | 编辑后的自动续约值 |
| `data.contractType` / `amount` / `pricingMode` / `settleCycle` / `remark` | 对应类型/null | 编辑后的可选字段 |
| `data.updateTime` | String | 新合同版本,供下一次编辑或删除使用 |
#### 请求示例
```json
{
"contractName": "年度服务合同(续签)",
"contractNo": "HT-2026-001-A",
"status": "ACTIVE",
"businessLine": "旅行服务",
"relatedMainContract": "MAIN-2026-001",
"signDate": "2026-09-07",
"autoRenew": true,
"startDate": "2026-09-07",
"endDate": "2028-09-06",
"scanFileUrl": "https://files.example.com/contracts/demo-a.pdf",
"contractType": "FRAME",
"amount": "1500.00",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"remark": "续签",
"expectedUpdateTime": "2026-09-07 20:00:00"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"contractId": "2097000000000000001",
"contractName": "年度服务合同(续签)",
"contractNo": "HT-2026-001-A",
"contractType": "FRAME",
"signDate": "2026-09-07",
"startDate": "2026-09-07",
"endDate": "2028-09-06",
"businessLine": "旅行服务",
"relatedMainContract": "MAIN-2026-001",
"autoRenew": true,
"amount": "1500.00",
"pricingMode": "按团结算",
"settleCycle": "MONTHLY",
"status": "ACTIVE",
"scanFileUrl": "https://files.example.com/contracts/demo-a.pdf",
"remark": "续签",
"updateTime": "2026-09-07 20:00:01"
},
"success": true
}
```
#### 空数据 / 降级响应
没有空成功数据。历史合同仍可读取;编辑历史合同前须先补齐十项必填字段。
#### 错误响应
```json
{"code":400,"message":"合同附件不能为空","data":null,"success":false}
```
#### 业务边界
- 编辑仍是完整替换;十项必填字段必须全部提交,可选字段需按是否保留正确回传。
- `expectedUpdateTime` 可省略,提交时应使用详情返回的合同版本;版本过期返回 `395014`。
- 删除接口不在本次变化内,删除仍必须提交 `changeReason` 并产生删除审计。
## 四、契约约束与正确调用方式
1. 新增、编辑表单将十项字段全部标为必填,尤其不得把 `autoRenew=false` 当空值。
2. 将 `contractNo` 的页面标签统一改为“合同编号”。
3. 移除新增、编辑的登记原因输入与校验,请求体不再组装 `changeReason`。
4. 编辑时从详情的 `contracts[]` 完整回填并提交;隐藏的可选字段继续原样保留。
5. 删除合同继续沿用原因弹框和原 DELETE 请求体,不要一并移除。
## 五、数据库行为
新增、编辑成功后保存完整合同值,但不新增供应商变更审计;校验失败时合同与审计均不变化。删除仍为软删除并保留原删除审计。本次无表结构或历史数据迁移。
## 六、边界行为
- 缺少任一必填字段:`code=400`、`success=false`,不会写入或修改合同。
- 未登录:`code=401`;无写权限:`395002`;合同不存在或归属错误:`395051`;日期倒置:`395054`;无实际变化:`395057`。
- 历史合同可能含空字段,读取不受影响;再次编辑时须补齐十项。
- 旧请求额外携带 `changeReason` 不会失败,但后端不保存该值,也不产生新增、编辑审计。
## 六.6、修改前后对比
| 字段 / 行为 | 修改前 | 修改后 |
|---|---|---|
| 十项业务字段 | 均可省略或清空 | 新增、编辑全部必填 |
| `contractNo` 页面文案 | 补合同编号 | 合同编号 |
| 新增、编辑 `changeReason` | 必填 | 不需要;旧请求多传兼容忽略 |
| 新增、编辑供应商变更审计 | 产生审计 | 不产生审计 |
| 删除原因与审计 | 原因必填并产生审计 | 不变 |
## 六.7、影响评估
- **是否破坏向后兼容**:对缺少十项中任一字段的旧新增、编辑请求是收紧变化;旧请求多传 `changeReason` 仍兼容。
- **前端是否必须同步上线**:是。必须调整必填、文案和新增/编辑原因交互。
- **前端 workaround 清理点**:删除新增、编辑原因弹框及 `changeReason` 组装;保留删除原因流程。
## 七、不影响范围
- 仅影响管理后台供应商合同新增、编辑。
- 不修改合同删除、供应商主体、收款账户、小程序接口、响应字段、数据库结构及历史合同读取。
## 八、测试环境已验证
- POST 与 PUT 各对十项字段逐项省略,共 20 个请求均返回 `code=400`,失败路径零写入。
- 不带 `changeReason` 的新增、编辑均成功;`autoRenew=false` 正常保存,旧请求多传 `changeReason` 仍兼容。
- 新增、编辑对应的供应商变更审计均为 0;临时合同已删除,原删除审计保持存在。
**当前状态:后端已部署并验证;待前端处理。**
## 十、相关文档
- [Issue #7290](https://git.1814.love:8443/wx/HL/issues/7290)
- [PR #7298](https://git.1814.love:8443/wx/HL/pulls/7298)
- [被本文覆盖的合同旧口径 #6842](https://git.1814.love:8443/wx/HL/issues/6842)
## 关联 / 联系人
### 链接
- **Issue**: [#7290](https://git.1814.love:8443/wx/HL/issues/7290)
- **PR**: [#7298](https://git.1814.love:8443/wx/HL/pulls/7298)
- **Merge commit**: [29ee134f330ca9524d6e74090a80aea3594b334e](https://git.1814.love:8443/wx/HL/commit/29ee134f330ca9524d6e74090a80aea3594b334e)
### 联系人
- **后端负责人**: @lc

某些文件未显示,因为此 diff 中更改的文件太多 显示更多