--- schema: "hl-changelog/v2" ticket: "7932" title: "团期核团:核团面板、保存、提交核算、重新核算、开票、导出核单与核单节点下钻" consumer: "admin" author: "jw(GIT)" change_type: "新增接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "pending" frontend_owner: "" frontend_ref: "" target_release: "" verified_at: "" status_note: "新增团期详情「核团验团」Tab 所需 7 个接口(GB-ADM-050/051/052/052b/054/055/056),全部挂在 /v3/admin/order/group-batch/{groupBatchId}/ 下,网关已有路由覆盖。权限用新增的 group-batch:audit:view/edit/allocate/invoice 四个码(不是 group-batch:finance:*)。验团归档 /settle 的行为变更见同日另一份 changelog(18_7932_团期验团归档前置核团-修改接口-管理后台.md)。后端 PR #7940/#7944/#7943/#7955 已合并 dev-v3(aed07cc3c)并部署 TEST。" updated_at: "2026-09-18" base: "dev-v3" --- # order-v3: ✨ 团期核团——核团面板、保存、提交核算、重新核算、开票、导出核单、核单节点下钻 > **存放目录**: `changelogs-v2/{YYYY-MM}/`(管理后台,二期 order-v3) > > **服务**: hl-order-service-v3(order-v3) > **PR**: #7940(050 读接口)、#7944(051/052/052b + 验团改造)、#7943(056 节点下钻)、#7955(054 开票、055 导出、权限码切换) > **Issue**: #7932 > **日期**: 2026-09-18 > **影响范围**: 团期详情「核团验团」Tab(原型 `BatchAuditPanel`) --- ## ⚠️ 关键变化 1. **新增 7 个接口**,支撑「核团验团」Tab:看面板 → 改用量 / 改价保存 → 提交核算(定稿)→ 需要时重新核算 → 开票 → 导出核单;另有一个核单按行程节点逐户下钻。 2. **路径、错误码、并发字段都和原型 / 旧文档 `12-核算与验团.html` 不一样**,以本文为准(见第四节对照表):路径是 `/v3/admin/order/group-batch/{groupBatchId}/...`,错误码是 589xxx,并发用 `expectedVersion`。 3. **权限用新的四个码 `group-batch:audit:*`**,**不要**再用 `group-batch:finance:*` 控制核团按钮显隐(见第四节权限表)。 4. **验团按钮不在本文**:验团仍调既有 `POST .../settle`,但它现在要求核团先完成定稿,这是**已上线接口的行为变更**,见同日修改接口 changelog。 --- ## 一、背景 团期出团回来后,运营要把一个团 8 个科目(住宿 / 车辆 / 景区娱乐 / 用餐 / 导游 / 摄影 / 其他支出 / 其他收入)的成本按固定分摊口径落到每一户,算出逐户成本与毛利,定稿后再验团归档,期间可按户开票、导出核单。 - 成本金额从第 1 层订单核单与第 2 层团期共享成本**带出**为默认值;运营主要改的是逐户用量(房间数、人数、是否参加、乘车分组),需要时也可以改单价 / 总额(偏离带出值须写改价原因)。 - 收入是**应收口径且已扣本户实退**,不是实收现金。 - 状态机:未开始 `NOT_STARTED` →(已返团首次打开面板)录入中 `DRAFT` →(提交核算)已核算 `ALLOCATED` →(验团)已验团 `CHECKED`;`ALLOCATED` 可经「重新核算」退回 `DRAFT`;`CHECKED` 可经「验团反确认」退回 `ALLOCATED`。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 核团面板(GB-ADM-050) | GET | `/v3/admin/order/group-batch/{groupBatchId}/audit` | 新增 | 已返团首次打开会自动带出成本、建草稿 | | 2 | 保存核团录入(GB-ADM-051) | PUT | `/v3/admin/order/group-batch/{groupBatchId}/audit` | 新增 | 科目行 + 逐户用量全集保存,金额服务端重算 | | 3 | 提交核算(GB-ADM-052) | POST | `/v3/admin/order/group-batch/{groupBatchId}/audit/allocate` | 新增 | 录入中 → 已核算,写逐户定稿 | | 4 | 重新核算(GB-ADM-052b) | POST | `/v3/admin/order/group-batch/{groupBatchId}/audit/reallocate` | 新增 | 已核算 → 录入中,清空定稿(已开票的团拒绝) | | 5 | 核团按户开票(GB-ADM-054) | POST | `/v3/admin/order/group-batch/{groupBatchId}/audit/invoice` | 新增 | 已核算 / 已验团时给本团一户申请普票 | | 6 | 导出核单(GB-ADM-055) | GET | `/v3/admin/order/group-batch/{groupBatchId}/audit/export` | 新增 | CSV 附件,**不是** Result 信封 | | 7 | 核单按行程节点逐户下钻(GB-ADM-056) | POST | `/v3/admin/order/group-batch/{groupBatchId}/settlement/node-lines` | 新增 | 只读;nodeIds 取自团期行程汇总 | --- ## 三、接口详情 ### 1. 核团面板(GB-ADM-050) `GET /v3/admin/order/group-batch/{groupBatchId}/audit` **VO**: `Result` #### 使用场景 打开团期详情「核团验团」Tab 时调用,一次拿全面板数据:两个状态(核团 / 团期)、三个合计、科目行、逐户用量、逐户分摊、提示、人员结算,以及「提交核算」按钮能否点(`readyToAllocate` / `blockingOrderIds`)。每次写接口成功后也建议重新调一次刷新面板。 **注意这是「读接口带写」**:团期已返团(出行完毕 / 核单中 / 已验团)且还没有核团记录时,第一次调用会自动带出成本、建一份「录入中」草稿;未返团只返回 `NOT_STARTED`,不建任何数据。 **权限**:`group-batch:audit:view`。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long(传字符串) | ✅ | 团期 ID | 团期详情里的 `groupBatchId` | 无 Query、无请求体。 #### 出参 金额字段一律是**字符串**(如 `"1200.00"`),ID 一律是**字符串**;时间格式 `yyyy-MM-dd HH:mm:ss`。 | 字段 | 类型 | 说明 | |------|------|------| | auditStatus | String | 核团状态:`NOT_STARTED` 未开始 / `DRAFT` 录入中 / `ALLOCATED` 已核算 / `CHECKED` 已验团 | | batchStatus | String | 团期状态透传(如 `REVIEWING` 核单中、`SETTLED` 已验团) | | auditId | String | 核团 ID;`NOT_STARTED` 为 null | | subCount | Integer | 参与核算户数(录入中 = 当前在团户数;已定稿 = 定稿快照) | | peopleCount | Integer | 参与核算总人数(口径同上) | | totalCost | String | 整团成本 | | totalRevenue | String | 整团收入(应收口径,已扣实退) | | grossProfit | String | 整团毛利 = 收入 − 成本 | | readyToAllocate | Boolean | 是否满足提交核算前置(所有在团户第 1 层核单已定稿);false 时「提交核算」置灰 | | blockingOrderIds | String[] | 第 1 层核单还没定稿的子订单 ID;空数组 = 可提交 | | primaryPayeeName | String | 主报账人姓名(录入中取团期当前主报账人;已定稿取定稿快照),可为 null | | allocatedAt | String | 提交核算时间,可为 null | | checkedAt | String | 验团时间,可为 null | | checkNote | String | 验团意见,可为 null | | version | Integer | 乐观锁版本;**下一次写请求原样回传为 `expectedVersion`** | | items[] | Object[] | 科目行,见下 | | items[].itemId | String | 科目行 ID | | items[].category | String | 科目:`HOUSE` 住宿 / `VEHICLE` 车辆 / `ACTIVITY` 景区娱乐 / `MEAL` 用餐 / `GUIDE` 导游 / `PHOTO` 摄影 / `OTHER_EXPENSE` 其他支出 / `OTHER_INCOME` 其他收入 | | items[].itemName | String | 科目名 | | items[].dayNo | Integer | 第几天 / 第几晚,无日归属为 null | | items[].unitPrice | String | 单价(单价型 `HOUSE`/`ACTIVITY`/`MEAL` 才有值,否则 null) | | items[].totalAmount | String | 总额(总额型 `VEHICLE`/`GUIDE`/`PHOTO`/`OTHER_EXPENSE`/`OTHER_INCOME` 才有值,否则 null) | | items[].allocRule | String | 分摊口径:`PER_ROOM_NIGHT` 按各户用房数 / `PER_HEAD_CHECKED` 勾选参加后按人数 / `PER_VEHICLE_GROUP` 按乘车分组内户数均分 / `PER_ORDER_AVG` 按户平均 | | items[].allocGroup | String | 分摊分组(车科目 `BUS` / `SUV`),可为 null | | items[].budgetAmount | String | 带出源金额,只供对比(改价是否「偏离」就是跟它比) | | items[].changeReason | String | **读接口恒为 null**(改价原因只进团期时间线,不回显) | | items[].seq | Integer | 排序 | | details[] | Object[] | 逐户逐项用量,见下 | | details[].detailId | String | 用量行 ID | | details[].itemId | String | 科目行 ID | | details[].orderId | String | 子订单 ID | | details[].quantity | Number | 用量(房 = 间数;景娱 / 餐 = 人数;总额型为 null)。**数字,不是字符串** | | details[].participated | Boolean | 是否参加(false = 本户该科目记 0,只影响单价型科目) | | details[].allocGroup | String | 本户分组(车科目 `BUS` / `SUV`),可为 null | | details[].amount | String | 本户该科目金额(服务端按分摊口径算,前端不要自己算) | | details[].note | String | 例外备注 | | allocs[] | Object[] | 逐户分摊(录入中 = 实时试算;已核算 / 已验团 = 定稿快照),见下 | | allocs[].orderId | String | 子订单 ID | | allocs[].contactName | String | 联系人 | | allocs[].peopleCount | Integer | 本户人数 | | allocs[].roomCount | Integer | 本户用房数 | | allocs[].revenueAmount | String | 本户收入 = 应收 − 本户实退 + 其他收入分摊 | | allocs[].costAmount | String | 本户成本合计 | | allocs[].grossProfit | String | 本户毛利 | | allocs[].roundingBearer | Boolean | 是否承担均分科目的尾差(分摊除不尽的几分钱记在这一户);可为 null | | allocs[].costBreakdown | Object | 本户成本构成,键 = `HOUSE`/`VEHICLE`/`ACTIVITY`/`MEAL`/`GUIDE`/`PHOTO`/`OTHER_EXPENSE`(7 个成本科目,不含其他收入),值为金额字符串 | | allocs[].note | String | 本户备注 | | warnings[] | Object[] | 结构化提示,**不阻断**,见下 | | warnings[].code | String | 提示码,见第六.5节 | | warnings[].level | String | 级别,目前只有 `WARN`;以后可能加 `INFO`,按字符串兼容 | | warnings[].itemId | String | 关联科目行 ID,可为 null | | warnings[].orderId | String | 关联子订单 ID,可为 null | | warnings[].message | String | 提示文案,可直接展示 | | staffSettlements[] | Object[] | 人员结算(第 1 层导游 / 摄影费用行,只展示不可改) | | staffSettlements[].orderId | String | 所属子订单 ID | | staffSettlements[].staffId | String | 人员 ID | | staffSettlements[].staffName | String | 人员姓名 | | staffSettlements[].staffRole | String | `GUIDE` 导游 / `PHOTOGRAPHER` 摄影 | | staffSettlements[].payableAmount | String | 应付金额 | | staffSettlements[].settleStatus | String | 转账状态,可为 null | | staffSettlements[].settledAt | String | 转账日期 `yyyy-MM-dd`,可为 null | | staffSettlements[].transferRef | String | 转账凭证号,可为 null | #### 请求示例 ```http GET /v3/admin/order/group-batch/2100857935637663745/audit Authorization: Bearer ``` 无请求体。 #### 响应示例 (TEST 真实响应,2026-09-18,已返团团期首次打开;`items` 11 行 / `details` 33 行只保留前几行) ```json { "code": 200, "message": "成功", "data": { "auditStatus": "DRAFT", "batchStatus": "REVIEWING", "auditId": "2100858806698135554", "subCount": 3, "peopleCount": 6, "totalCost": "3467.00", "totalRevenue": "16200.00", "grossProfit": "12733.00", "readyToAllocate": false, "blockingOrderIds": ["2100857936799485953"], "primaryPayeeName": null, "allocatedAt": null, "checkedAt": null, "checkNote": null, "version": 0, "items": [ { "itemId": "2100858806698135555", "category": "HOUSE", "itemName": "D1 阿尔山成悦大酒店 标准间", "dayNo": 1, "unitPrice": "300.00", "totalAmount": null, "allocRule": "PER_ROOM_NIGHT", "allocGroup": null, "budgetAmount": "1250.00", "changeReason": null, "seq": 1 }, { "itemId": "2100858806698135559", "category": "VEHICLE", "itemName": "车辆(团期共享·整团大巴)", "dayNo": null, "unitPrice": null, "totalAmount": "1000.00", "allocRule": "PER_VEHICLE_GROUP", "allocGroup": "BUS", "budgetAmount": "1000.00", "changeReason": null, "seq": 2 } ], "details": [ { "detailId": "2100858806698135556", "itemId": "2100858806698135555", "orderId": "2100857935461502977", "quantity": 1.00, "participated": true, "allocGroup": null, "amount": "300.00", "note": null } ], "allocs": [ { "orderId": "2100857935461502977", "contactName": "核团甲", "peopleCount": 2, "roomCount": 1, "revenueAmount": "5400.00", "costAmount": "1055.68", "grossProfit": "4344.32", "roundingBearer": true, "costBreakdown": { "HOUSE": "300.00", "VEHICLE": "333.34", "ACTIVITY": "289.00", "MEAL": "100.00", "GUIDE": "0.00", "PHOTO": "0.00", "OTHER_EXPENSE": "33.34" }, "note": null } ], "warnings": [ { "code": "CATEGORY_AMOUNT_MISMATCH", "level": "WARN", "itemId": "2100858806698135555", "orderId": null, "message": "科目「D1 阿尔山成悦大酒店 标准间」按单价 × 用量算得 1200.00,与第 1 层核单实际成本 1250.00 不一致" } ], "staffSettlements": [] }, "success": true } ``` #### 空数据 / 降级响应 未返团(团期还没到「出行完毕」):只返回两个状态,其余为空,**不建数据**(按代码整理的形态,`batchStatus` 为示意值)。 ```json { "code": 200, "message": "成功", "data": { "auditStatus": "NOT_STARTED", "batchStatus": "RECRUITING", "auditId": null, "subCount": null, "peopleCount": null, "totalCost": null, "totalRevenue": null, "grossProfit": null, "readyToAllocate": false, "blockingOrderIds": [], "version": null, "items": [], "details": [], "allocs": [], "warnings": [], "staffSettlements": [] }, "success": true } ``` 已返团但两层都没有可带出的成本(TEST 真实:团期 B):`items=[]`、`details=[]`,`totalCost="0.00"`,`allocs` 仍按户列出收入。运营通过 GB-ADM-051 新增科目行。 读第 1 层数据失败时不报错,降级为 `warnings[]` 里的 `SOURCE_UNAVAILABLE` / `ALLOC_TRIAL_FAILED` 提示。 #### 错误响应 ```json { "code": 589507, "message": "无操作权限(当前角色未授予团期权限,或该团期不在您名下)", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 589507 | 缺 `group-batch:audit:view`(被拒时不建任何数据) | | 589500 | 团期不存在 | | 401 | 未登录(网关拦截) | #### 业务边界 - 两个人同时第一次打开同一团:只会建一份草稿,两边拿到同一个 `auditId`(TEST 并发实测)。 - 已核算 / 已验团时读的是定稿快照,第 1 层之后再改也不会变;若第 1 层核单在定稿后被重开,会出 `SOURCE_FINALIZED_REOPENED` 提示。 - 在团户与录入不一致(例如定稿前有户退团)会出 `HOUSEHOLD_SET_CHANGED` 提示。 - `items[].changeReason` 永远是 null,不要拿它回显改价原因。 --- ### 2. 保存核团录入(GB-ADM-051) `PUT /v3/admin/order/group-batch/{groupBatchId}/audit` **VO**: `GroupBatchAuditSaveReqVO` → `Result` #### 使用场景 「核团验团」Tab 录入中状态下点「保存」:提交**全部**科目行和**全部**逐户用量(全集对账:带 `itemId` 的覆盖、不带的新增、库里有而本次没提交的删除)。服务端重算每户金额、`version + 1`。 **权限**:`group-batch:audit:edit`。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long(传字符串) | ✅ | 团期 ID | | | expectedVersion | Body | Integer | ✅ | ≥ 0 | 取自 GB-ADM-050 或上一次写接口返回的 `version` | | items | Body | Object[] | ✅ | ≤ 500 行;没有传 `[]` | 科目行全集 | | items[].itemId | Body | String | 否 | 须为本团现有科目行 | 空 = 新增 | | items[].category | Body | String | ✅ | 8 个科目枚举之一 | 决定计价方式 | | items[].itemName | Body | String | ✅ | ≤ 128 字 | 科目名 | | items[].dayNo | Body | Integer | 否 | - | 第几天 / 第几晚 | | items[].unitPrice | Body | String/Number | 条件 | ≥ 0,整数位 ≤ 10、小数 ≤ 2 | **只有单价型(HOUSE/ACTIVITY/MEAL)填**,此时 totalAmount 必须空 | | items[].totalAmount | Body | String/Number | 条件 | ≥ 0,整数位 ≤ 10、小数 ≤ 2 | **只有总额型填**,此时 unitPrice 必须空 | | items[].changeReason | Body | String | 条件 | ≤ 256 字 | 改了单价 / 总额、且改后整项金额 ≠ `budgetAmount` 时**必填** | | items[].allocRule | Body | String | ✅ | 须与科目匹配;`PER_HEAD_AVG` 不可用 | 分摊口径 | | items[].allocGroup | Body | String | 否 | ≤ 32 字 | 车科目 `BUS` / `SUV` | | items[].seq | Body | Integer | ✅ | - | 排序 | | details | Body | Object[] | ✅ | ≤ 20000 行;没有传 `[]` | 逐户用量全集,按 (itemId, orderId) 对账 | | details[].itemId | Body | String | ✅ | 须是本次 items 里带 itemId 的行 | | | details[].orderId | Body | String | ✅ | 须为本团在团户,否则 589572 | | | details[].quantity | Body | Number | 否 | ≥ 0,整数位 ≤ 8、小数 ≤ 2 | 房 = 间数、景娱 / 餐 = 人数;总额型不填;空 = 缺省用量 | | details[].participated | Body | Boolean | ✅ | - | false = 本户该科目记 0 | | details[].allocGroup | Body | String | 否 | ≤ 32 字 | 空 = 随科目行 | | details[].note | Body | String | 否 | ≤ 256 字 | 例外备注 | **没有 `details[].amount`**:逐户金额服务端算,传了也收不到。 #### 出参 | 字段 | 类型 | 说明 | |------|------|------| | groupBatchId | String | 团期 ID | | auditStatus | String | 写入后的核团状态(保存后恒为 `DRAFT`) | | auditStatusText | String | 中文名(录入中 / 已核算 / 已验团) | | batchStatus | String | 团期状态透传 | | readyToAllocate | Boolean | 写入后重新判定的提交核算前置 | | blockingOrderIds | String[] | 第 1 层未定稿的子订单 ID | | version | Integer | 写入后的新版本(已 +1),下次写原样回传 | | warnings[] | Object[] | 结构同 GB-ADM-050 `warnings[]`(这里主要是 `CATEGORY_AMOUNT_MISMATCH`) | #### 请求示例 (TEST 实测请求的结构,只列一行科目与一行用量;实际须提交全集) ```http PUT /v3/admin/order/group-batch/2100857935637663745/audit Authorization: Bearer Content-Type: application/json { "expectedVersion": 1, "items": [ { "itemId": "2100858806698135595", "category": "OTHER_EXPENSE", "itemName": "其他支出(逐户核单带出)", "dayNo": null, "unitPrice": null, "totalAmount": "120.00", "changeReason": "杂费追加 20 元已与财务确认", "allocRule": "PER_ORDER_AVG", "allocGroup": null, "seq": 11 } ], "details": [ { "itemId": "2100858806698135591", "orderId": "2100857936799485953", "quantity": 1, "participated": false, "allocGroup": null, "note": null } ] } ``` #### 响应示例 (TEST 真实响应,2026-09-18,把一户的用餐改为不参加) ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2100857935637663745", "auditStatus": "DRAFT", "auditStatusText": "录入中", "batchStatus": "REVIEWING", "readyToAllocate": false, "blockingOrderIds": ["2100857936799485953"], "version": 1, "warnings": [ { "code": "CATEGORY_AMOUNT_MISMATCH", "level": "WARN", "itemId": "2100858806698135555", "orderId": null, "message": "科目「D1 阿尔山成悦大酒店 标准间」按单价 × 用量算得 1200.00,与第 1 层核单实际成本 1250.00 不一致" } ] }, "success": true } ``` #### 空数据 / 降级响应 写接口,无空数据形态。任何一条校验失败整次保存回滚,科目行、用量、合计、版本都不变(TEST 已核对零写入)。 ```json { "code": 589573, "message": "核团数据已被他人修改(当前版本 3,提交版本 2),请刷新后重试", "data": null, "success": false } ``` #### 错误响应 (TEST 真实响应:改了总额、偏离带出值但没填原因) ```json { "code": 100001, "message": "参数非法: 科目「其他支出(逐户核单带出)」改价后金额 120.00 偏离带出值 100.00,请填写改价原因", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 589507 | 缺 `group-batch:audit:edit` | | 589500 | 团期不存在 | | 589567 | 还没有核团记录(先调 GB-ADM-050 生成草稿) | | 589568 | 不在录入中(已核算须先重新核算;已验团不可改),message 写明「当前 X,需要 Y」 | | 589569 | 科目填法不对:单价型填了总额 / 总额型填了单价 / 两者同填或同空 / 科目与分摊口径不匹配 / 枚举值非法 | | 589570 | 有金额的均分科目没有可分摊的户(如车科目分组里没有一户),message 带科目与金额 | | 589572 | `details[].orderId` 不是本团在团户 | | 589573 | `expectedVersion` 与当前版本不一致(别人刚保存过),message 带两个版本号 | | 100001 | 改价偏离带出值未填原因;`itemId` 不属本团 / 重复提交;用量引用了不在本次 items 里的科目行;同一 (itemId, orderId) 重复 | | 400 | 请求体注解校验失败(必填缺失、长度 / 金额超限、负数),message 为具体字段提示 | #### 业务边界 - **全集语义**:漏传的科目行 / 用量行会被删除。前端必须把面板上的全部行带回来,不能只传改动的行。 - 新增的科目行(不带 itemId)没法在同一次请求里被 details 引用,服务端会给每个在团户自动补一行缺省用量(参加、缺省用量、分组随科目行);要改这些户的用量,保存后按 GB-ADM-050 返回的新 itemId 再保存一次。 - 改价判定:单价或总额与库里不同 = 改价。改后整项金额仍等于 `budgetAmount` 不用填原因;只改用量不算改价。改价会记一条团期时间线(含改前改后金额与原因)。 - 每次成功保存 `version + 1`,前端要用回执里的新 `version` 覆盖本地值。 --- ### 3. 提交核算(GB-ADM-052) `POST /v3/admin/order/group-batch/{groupBatchId}/audit/allocate` **VO**: `GroupBatchAuditAllocateReqVO` → `Result` #### 使用场景 录入无误后点「确认核算 / 提交核算」:按库里当前的录入逐户分摊并定稿,状态 `DRAFT → ALLOCATED`。定稿后录入只读,要改须先「重新核算」。 **权限**:`group-batch:audit:allocate`。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long(传字符串) | ✅ | 团期 ID | | | expectedVersion | Body | Integer | ✅ | ≥ 0 | 取自 GB-ADM-050 / 上次写接口的 `version` | 不接受任何金额、用量入参(按库里的录入定稿)。 #### 出参 | 字段 | 类型 | 说明 | |------|------|------| | groupBatchId | String | 团期 ID | | auditStatus | String | 恒为 `ALLOCATED` | | subCount | Integer | 参与核算户数(定稿快照) | | peopleCount | Integer | 参与核算总人数 | | totalCost | String | 整团成本 | | totalRevenue | String | 整团收入(应收口径,已扣实退) | | grossProfit | String | 整团毛利 | | allocatedAt | String | 提交核算时间 | | roundingDiff | String | 各均分科目尾差之和 | | version | Integer | 写入后的新版本(已 +1),重新核算时原样回传 | | allocs[] | Object[] | 逐户定稿,结构同 GB-ADM-050 `allocs[]`(含 `roundingBearer`) | #### 请求示例 ```http POST /v3/admin/order/group-batch/2100857935637663745/audit/allocate Authorization: Bearer Content-Type: application/json { "expectedVersion": 3 } ``` #### 响应示例 (TEST 真实响应,2026-09-18;三户中只列第一户) ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2100857935637663745", "auditStatus": "ALLOCATED", "subCount": 3, "peopleCount": 6, "totalCost": "3437.00", "totalRevenue": "16200.00", "grossProfit": "12763.00", "allocatedAt": "2026-09-18 16:21:19", "roundingDiff": "0.01", "version": 4, "allocs": [ { "orderId": "2100857935461502977", "contactName": "核团甲", "peopleCount": 2, "roomCount": 1, "revenueAmount": "5400.00", "costAmount": "1062.34", "grossProfit": "4337.66", "roundingBearer": true, "costBreakdown": { "HOUSE": "300.00", "VEHICLE": "333.34", "ACTIVITY": "289.00", "MEAL": "100.00", "GUIDE": "0.00", "PHOTO": "0.00", "OTHER_EXPENSE": "40.00" }, "note": null } ] }, "success": true } ``` #### 空数据 / 降级响应 写接口,无空数据形态。被拒时零写入,状态仍为录入中。 ```json { "code": 100502, "message": "提交核算处理中,请勿重复提交", "data": null, "success": false } ``` #### 错误响应 (TEST 真实响应:有一户第 1 层核单未定稿) ```json { "code": 589568, "message": "核团当前状态不允许该操作:提交核算需要所有在团订单第 1 层核单已定稿,未定稿订单:2100857936799485953", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 589507 | 缺 `group-batch:audit:allocate` | | 589500 | 团期不存在 | | 589567 | 还没有核团记录 | | 589568 | 不在录入中;或本团没有在团户;或有户第 1 层核单未定稿(message 列出订单 ID) | | 589569 | 库里有取值非法的科目行 | | 589570 | 逐户合计与整团合计对不平,message 带「Σ逐户成本 / 整团成本,Σ逐户收入 / 整团收入」四个数 | | 589573 | 版本不一致 | | 100502 | 同一团期 5 秒内重复提交(防双击,第二次被拒,不是返回上一次结果) | #### 业务边界 - 先看 GB-ADM-050 的 `readyToAllocate`,false 时按钮置灰并提示 `blockingOrderIds`,避免盲提交。 - 均分科目除不尽时,尾差记在该科目可分摊户中订单 ID 最小的一户(`roundingBearer=true`),逐户合计精确等于整团合计。 - 用餐 / 景娱按「是否参加」计;车辆、导游、摄影、其他支出、其他收入不看「是否参加」。 --- ### 4. 重新核算(GB-ADM-052b) `POST /v3/admin/order/group-batch/{groupBatchId}/audit/reallocate` **VO**: `GroupBatchAuditReallocateReqVO` → `Result` #### 使用场景 已核算后发现要改:点「重新核算」,状态 `ALLOCATED → DRAFT`,清空逐户定稿(旧定稿整组写进团期时间线留底),录入恢复可编辑。**本团任一子订单已开票时拒绝**,须与财务走人工处理。 **权限**:`group-batch:audit:allocate`。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long(传字符串) | ✅ | 团期 ID | | | expectedVersion | Body | Integer | ✅ | ≥ 0 | 取自 GB-ADM-050 / 提交核算回执的 `version` | #### 出参 结构同 GB-ADM-051 回执。 | 字段 | 类型 | 说明 | |------|------|------| | groupBatchId | String | 团期 ID | | auditStatus | String | 恒为 `DRAFT` | | auditStatusText | String | 录入中 | | batchStatus | String | 团期状态透传 | | readyToAllocate | Boolean | 提交核算前置 | | blockingOrderIds | String[] | 第 1 层未定稿的子订单 ID | | version | Integer | 新版本(已 +1) | | warnings[] | Object[] | 恒为空数组 | #### 请求示例 ```http POST /v3/admin/order/group-batch/2100857935637663745/audit/reallocate Authorization: Bearer Content-Type: application/json { "expectedVersion": 4 } ``` #### 响应示例 (TEST 真实响应,2026-09-18) ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2100857935637663745", "auditStatus": "DRAFT", "auditStatusText": "录入中", "batchStatus": "REVIEWING", "readyToAllocate": true, "blockingOrderIds": [], "version": 5, "warnings": [] }, "success": true } ``` #### 空数据 / 降级响应 写接口,无空数据形态。被拒时零写入,定稿保持不变。 ```json { "code": 100502, "message": "重新核算处理中,请勿重复提交", "data": null, "success": false } ``` #### 错误响应 (TEST 真实响应:本团有一户已开票) ```json { "code": 589571, "message": "已开票冲突:本团子订单 2100857936799485953 已开票,禁止重新核算,请先与财务确认发票处理后走人工调整", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 589507 | 缺 `group-batch:audit:allocate` | | 589500 | 团期不存在 | | 589567 | 还没有核团记录 | | 589568 | 不在已核算(录入中无需重算;已验团须先验团反确认),message 写明当前 / 需要 | | 589571 | 本团有子订单已开票,message 列出订单 ID | | 589573 | 版本不一致 | | 100502 | 同一团期 5 秒内重复提交 | #### 业务边界 - 已验团(CHECKED)不能直接重新核算:先调既有 `POST .../settle/reopen`(验团反确认)退回已核算,再重新核算。 - 清空的旧定稿不会丢,写在团期时间线里(事件 `BATCH_AUDIT_REALLOCATE`)。 --- ### 5. 核团按户开票(GB-ADM-054) `POST /v3/admin/order/group-batch/{groupBatchId}/audit/invoice` **VO**: `GroupBatchInvoiceReqVO` → `Result` #### 使用场景 核团「开票」弹窗:已核算或已验团时,为本团某一户申请发票。复用系统既有订单发票,发票类型由服务端固定为**增值税普通发票**,开票内容固定「旅游服务费」,金额在后续开票(出票)环节按本户应收计算。 **权限**:`group-batch:audit:invoice`。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long(传字符串) | ✅ | 团期 ID | | | orderId | Body | String | ✅ | 须为本团在团户 | 子订单 ID | | titleType | Body | String | ✅ | `PERSONAL` / `COMPANY` | 个人 / 单位抬头 | | title | Body | String | ✅ | ≤ 128 字 | 抬头(公司全称或个人姓名) | | taxNo | Body | String | 条件 | ≤ 32 字 | `titleType=COMPANY` 时必填 | | email | Body | String | ✅ | 邮箱格式,≤ 128 字 | 收件邮箱 | **不传 `expectedVersion`、不传 `invoiceType`**(开票不改核团版本;发票类型服务端固定)。 #### 出参 结构同 GB-ADM-051 回执;`version` 为原值(开票不改版本)。 | 字段 | 类型 | 说明 | |------|------|------| | groupBatchId | String | 团期 ID | | auditStatus | String | `ALLOCATED` 或 `CHECKED`(开票时的核团状态) | | auditStatusText | String | 已核算 / 已验团 | | batchStatus | String | 团期状态透传 | | readyToAllocate | Boolean | 提交核算前置 | | blockingOrderIds | String[] | 第 1 层未定稿的子订单 ID | | version | Integer | 当前版本(不变) | | warnings[] | Object[] | 恒为空数组 | #### 请求示例 ```http POST /v3/admin/order/group-batch/2100857935637663745/audit/invoice Authorization: Bearer Content-Type: application/json { "orderId": "2100857935461502977", "titleType": "PERSONAL", "title": "核团甲", "taxNo": null, "email": "guest@example.com" } ``` #### 响应示例 (TEST 实测:财务角色为一户开个人抬头发票 → 200,auditStatus/version 不变;已验团态开票同样 200) ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2100857935637663745", "auditStatus": "ALLOCATED", "auditStatusText": "已核算", "batchStatus": "REVIEWING", "readyToAllocate": true, "blockingOrderIds": [], "version": 9, "warnings": [] }, "success": true } ``` #### 空数据 / 降级响应 写接口,无空数据形态。被拒时不产生发票申请。 ```json { "code": 589568, "message": "核团当前状态不允许该操作:当前「录入中」,需要「已核算」或「已验团」;请先提交核算后再开票", "data": null, "success": false } ``` #### 错误响应 (TEST 真实响应:选了别的团的订单) ```json { "code": 589572, "message": "所选订单不属于本团期的核团范围", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 589507 | 缺 `group-batch:audit:invoice` | | 589500 | 团期不存在 | | 581514 | 单位抬头没填税号(沿用发票域错误码) | | 589572 | 订单不是本团在团户 | | 589567 | 还没有核团记录 | | 589568 | 核团不在已核算 / 已验团 | | 589571 | 该户已有有效发票(message 带订单 ID) | | 400 | 请求体校验失败(抬头类型非法、邮箱格式错、超长等) | | 其他发票域码 | 订单状态不满足开票条件等,message 原样透出 | #### 业务边界 - 一户只能有一张有效发票;已开票的团不能再「重新核算」(见接口 4)。 - 前端不需要、也不能选发票类型(专票所需的开户行 / 账号等本接口不收)。 - 税号、邮箱按原样保存,不做脱敏展示改造。 --- ### 6. 导出核单(GB-ADM-055) `GET /v3/admin/order/group-batch/{groupBatchId}/audit/export` **VO**: `text/csv 附件(AuditCsvExport,不套 Result 信封)` #### 使用场景 核团 Tab「导出核单」按钮。按浏览器下载文件处理(`responseType: 'blob'`),文件名从 `Content-Disposition` 取。录入中也可以导出,此时导出的是试算值:文件第一行与文件名都带「未定稿」。 **权限**:`group-batch:audit:view`。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long(传字符串) | ✅ | 团期 ID | | #### 出参 成功时 HTTP 200,响应头 `Content-Type: text/csv; charset=utf-8`,`Content-Disposition: attachment; filename=""; filename*=UTF-8''`;正文 UTF-8 带 BOM、行尾 CRLF。 | 字段 | 类型 | 说明 | |------|------|------| | 第 1 行(仅录入中) | 文本 | `未定稿:以下为录入中的试算值,提交核算后以定稿为准` | | 说明行 | 文本 | `说明:收入为应收口径(已扣实退);金额单位:元` | | 表头 | 14 列 | `子订单号,联系人,人数,用房数,住宿,车辆,景区娱乐,用餐,导游,摄影,其他支出,成本合计,收入,毛利` | | 逐户行 | 14 列 | 按订单 ID 升序,数字与 GB-ADM-050 面板一致 | | 合计行 | 14 列 | 首列「合计」;成本合计 / 收入 / 毛利 = 整团合计 | | 文件名 | String | `核单-<期号>.csv`;录入中为 `核单-<期号>-未定稿.csv` | #### 请求示例 ```http GET /v3/admin/order/group-batch/2100857935637663745/audit/export Authorization: Bearer ``` 无请求体。 #### 响应示例 (TEST 真实响应,2026-09-18,已核算团期;响应头如下,正文是 CSV 而不是 JSON) ```json { "httpStatus": 200, "Content-Type": "text/csv;charset=utf-8", "Content-Disposition": "attachment; filename=\"%E6%A0%B8%E5%8D%95-Q202610202100857901416419330.csv\"; filename*=UTF-8''%E6%A0%B8%E5%8D%95-Q202610202100857901416419330.csv", "decodedFileName": "核单-Q202610202100857901416419330.csv" } ``` CSV 正文: ```text 说明:收入为应收口径(已扣实退);金额单位:元 子订单号,联系人,人数,用房数,住宿,车辆,景区娱乐,用餐,导游,摄影,其他支出,成本合计,收入,毛利 2100857935461502977,核团甲,2,1,300.00,333.34,289.00,100.00,0.00,0.00,40.00,1062.34,5400.00,4337.66 2100857936304635906,核团乙,3,2,600.00,333.33,289.00,150.00,0.00,0.00,40.00,1412.33,8100.00,6687.67 2100857936799485953,核团丙,1,1,300.00,333.33,289.00,0.00,0.00,0.00,40.00,962.33,2700.00,1737.67 合计,,6,4,1200.00,1000.00,867.00,250.00,0.00,0.00,120.00,3437.00,16200.00,12763.00 ``` #### 空数据 / 降级响应 录入中导出(TEST 真实):文件名 `核单-Q202610242100859128720101378-未定稿.csv`,第一行为「未定稿:以下为录入中的试算值,提交核算后以定稿为准」,其余同上。 ```json { "httpStatus": 200, "Content-Type": "text/csv;charset=utf-8", "decodedFileName": "核单-Q202610242100859128720101378-未定稿.csv" } ``` #### 错误响应 失败时**不下载文件**,而是返回普通 JSON 错误(HTTP 200 + `Result` 信封)。前端用 blob 接收时,要先判断 `Content-Type` 是否为 `application/json`,是则解析出 message 提示。 (TEST 真实响应:团期还没有核团记录) ```json { "code": 589567, "message": "该团期尚未进入核团:请先打开核团面板生成核算草稿后再导出", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 589507 | 缺 `group-batch:audit:view` | | 589500 | 团期不存在 | | 589567 | 还没有核团记录(导出不会像面板那样自动建草稿) | | 589568 | 录入中且按当前录入试算失败,message 带失败原因 | | 589517 | 户数超过 2000 行上限 | #### 业务边界 - 只读,不建数据、不改状态。 - 联系人等文本以 `=` `+` `-` `@` 开头时会前置单引号,防止 Excel 当公式执行。 --- ### 7. 核单按行程节点逐户下钻(GB-ADM-056) `POST /v3/admin/order/group-batch/{groupBatchId}/settlement/node-lines` **VO**: `GroupBatchSettlementNodeLinesReqVO` → `Result` #### 使用场景 团期行程汇总(GB-ADM-018)某一项点开「核单明细」:把该项的 `days[].nodes[].nodeIds` 原样传进来,返回全团每户在这些节点上的核单门票行。只读、无副作用;用 POST 只是因为 nodeIds 可能很多。 **权限**:`group-batch:audit:view`(与核团面板同码)。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | groupBatchId | Path | Long(传字符串) | ✅ | 团期 ID | | | nodeIds | Body | String[] | ✅ | 1~200 个;重复自动去重 | 取自 GB-ADM-018 `days[].nodes[].nodeIds` | 不接受 `orderIds`:本团户由服务端按 groupBatchId 自己查。 #### 出参 **注意**:本接口的金额字段是**数字**(不是字符串),与核团其余接口不同。 | 字段 | 类型 | 说明 | |------|------|------| | groupBatchId | String | 团期 ID | | nodeName | String | 项目名(核单行景点名众数,没有核单行时回退行程节点名),可为 null | | lineCount | Integer | 命中核单行数(只算 `coverage=OK`) | | confirmedCount | Integer | 其中已逐行确认的行数 | | totalSellAmount | Number | 全团该项客户成交合计(只算 `OK` 行) | | totalActualCost | Number | 全团该项实际成本合计(只算 `OK` 行) | | householdCount | Integer | 请求节点涉及的户数 | | settledHouseholdCount | Integer | 其中已核单的户数;合计只覆盖这些户,前端要摆在合计旁边 | | unmatchedNodeIds | String[] | 不属于本团的节点 ID(已忽略、不报错),正常为空 | | rows[] | Object[] | 逐户明细,按 dayNumber、orderId 升序;每个 (户, 节点) 至少一行 | | rows[].orderId | String | 子订单 ID | | rows[].orderNo | String | 子订单编号 | | rows[].customerName | String | 联系人 | | rows[].coverage | String | `OK` 有核单行 / `PENDING` 该户还没核单(不是没花钱)/ `NOT_APPLICABLE` 该节点类型核单不按节点归集(置灰「无核单明细」,**不要显示成 ¥0**)/ `MISSING` 已核单但找不到该节点的行 | | rows[].nodeId | String | 行程节点 ID | | rows[].nodeType | String | 节点类型(如 `SCENIC`、`RESTAURANT`) | | rows[].dayNumber | Integer | 第几天 | | rows[].dayDate | String | 行程日 `yyyy-MM-dd` | | rows[].settlementId | String | 核单行 ID;仅 `OK` 有值 | | rows[].ticketCount | Integer | 购票数量;仅 `OK` 有值 | | rows[].sellPrice | Number | 客户成交单价;仅 `OK` 可能有值 | | rows[].totalAmount | Number | 行金额;仅 `OK` 可能有值 | | rows[].plannedCost | Number | 计划成本;仅 `OK` 有值 | | rows[].actualCost | Number | 实际成本;仅 `OK` 有值 | | rows[].paymentMethod | String | `SIGNED` / `COMPANY_PAID` / `CASH_PAID`;仅 `OK` 有值 | | rows[].settlementConfirmStatus | String | `UNCONFIRMED` / `CONFIRMED`;仅 `OK` 有值 | | rows[].terminated | Boolean | 该户是否出行中终止(服务端不过滤,前端结合 endDayNumber 解释) | | rows[].endDayNumber | Integer | 终止截断天,未终止为 null | `coverage` 不是 `OK` 时,所有金额字段都是 **null**(不是 0)。 #### 请求示例 ```http POST /v3/admin/order/group-batch/2100857935637663745/settlement/node-lines Authorization: Bearer Content-Type: application/json { "nodeIds": ["2100857935742521346", "2100857935755104258", "2100859129223335939"] } ``` #### 响应示例 (TEST 真实响应,2026-09-18;6 行只列 2 行,最后一个 nodeId 属于别的团) ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2100857935637663745", "nodeName": "海拉尔国家森林公园", "lineCount": 3, "confirmedCount": 3, "totalSellAmount": 0, "totalActualCost": 87.00, "householdCount": 3, "settledHouseholdCount": 3, "unmatchedNodeIds": ["2100859129223335939"], "rows": [ { "orderId": "2100857935461502977", "orderNo": "HL20260918160218408", "customerName": "核团甲", "coverage": "OK", "nodeId": "2100857935742521346", "nodeType": "SCENIC", "dayNumber": 1, "dayDate": "2026-10-20", "settlementId": "2100858609570041858", "ticketCount": 1, "sellPrice": null, "totalAmount": null, "plannedCost": 29.00, "actualCost": 29.00, "paymentMethod": "CASH_PAID", "settlementConfirmStatus": "CONFIRMED", "terminated": false, "endDayNumber": null }, { "orderId": "2100857935461502977", "orderNo": "HL20260918160218408", "customerName": "核团甲", "coverage": "NOT_APPLICABLE", "nodeId": "2100857935755104258", "nodeType": "RESTAURANT", "dayNumber": 1, "dayDate": "2026-10-20", "settlementId": null, "ticketCount": null, "sellPrice": null, "totalAmount": null, "plannedCost": null, "actualCost": null, "paymentMethod": null, "settlementConfirmStatus": null, "terminated": false, "endDayNumber": null } ] }, "success": true } ``` #### 空数据 / 降级响应 传入的节点全都不属于本团(TEST 真实):`rows=[]`,节点列进 `unmatchedNodeIds`,不报错。 ```json { "code": 200, "message": "成功", "data": { "groupBatchId": "2100857935637663745", "rows": [], "unmatchedNodeIds": ["2100859129223335939"] }, "success": true } ``` #### 错误响应 (TEST 真实响应:传了 201 个节点) ```json { "code": 400, "message": "nodeIds 一次最多 200 个", "data": null, "success": false } ``` | code | 触发条件 | |------|----------| | 400 | `nodeIds` 为空(「nodeIds 不能为空」)或超过 200 个 | | 589507 | 缺 `group-batch:audit:view` | | 589500 | 团期不存在 | #### 业务边界 - 合计只累加 `coverage=OK` 的行;有 `PENDING` 户时合计偏小是正常的,要同时展示 `settledHouseholdCount / householdCount`。 - 本接口金额来自订单核单,和行程汇总里的参考价不同源,对不上是正常的。 --- ## 四、契约约束与正确调用方式 ### 权限码(jw 2026-09-18 裁决) | 权限码 | 管哪些接口 | 默认授予角色 | |--------|------------|--------------| | `group-batch:audit:view` | GB-ADM-050 面板、055 导出、056 节点下钻 | 团期管理员、财务、管理员 | | `group-batch:audit:edit` | GB-ADM-051 保存 | 团期管理员、管理员 | | `group-batch:audit:allocate` | GB-ADM-052 提交核算、052b 重新核算 | 团期管理员、管理员 | | `group-batch:audit:invoice` | GB-ADM-054 开票 | 财务、管理员 | - 缺码统一返回 **589507**。超级管理员全部放行。 - **核团按钮显隐不要再用 `group-batch:finance:view` / `group-batch:finance:advance`**(旧文档写法已作废)。 - **验团 / 验团反确认(`POST .../settle`、`.../settle/reopen`)不走上面四个码**,仍按角色:超级管理员 / 管理员 / 财务。团期管理员能核算但不能验团。 ### 与原型 / 旧文档 `12-核算与验团.html` 的出入(以本文为准) | 项 | 原型 / 旧文档 | 实际 | |----|---------------|------| | 路径 | `/admin/group-batch/items/{batchId}/audit*` | `/v3/admin/order/group-batch/{groupBatchId}/audit*` | | 路径参数 | `batchId` | `groupBatchId` | | 错误码 | 595xxx | 589xxx(595022→589567、595025→589568、595023→589569、595024→589570、595026→589571、595034→589572、595001→589500、595006→589573) | | 并发字段 | `expectedUpdateTime` | `expectedVersion`(整数,取 GB-ADM-050 的 `version`) | | 提示 | `warnings: string[]` | `warnings: [{code, level, itemId, orderId, message}]` | | 权限 | `group-batch:finance:*` | `group-batch:audit:*` 四码 | | 开票入参 | 含 `expectedVersion`、`invoiceType` | 两个都不传,发票类型服务端固定普票 | | 导出 | 定稿后才能导 | 录入中也能导,首行与文件名标「未定稿」 | | 无成本源的团 | 预置科目模板 | 首次打开 `items=[]`,经 GB-ADM-051 新增科目行 | | `items[].changeReason` | 回显改价原因 | 读接口恒 null,原因只进时间线 | | 提交核算回执 | 无版本 | 带 `version`,重新核算时回传 | | 验团 | 独立 `/audit/check` | 没有这个接口;验团走既有 `POST .../settle`(见修改接口 changelog) | ### ✅ 正确 / ❌ 错误 用法 | 场景 | 做法 | |------|------| | ✅ 每次写请求 | 带上最近一次拿到的 `version` 作为 `expectedVersion`,成功后用回执里的新 `version` 覆盖 | | ✅ 收到 589573 | 提示「数据已被他人修改」,重新调 GB-ADM-050 刷新后再操作 | | ✅ 保存 | 提交全部科目行 + 全部用量行 | | ❌ 只提交改动过的行 | 漏掉的行会被删掉 | | ❌ 前端自己算逐户金额 / 合计 | 以服务端 `amount` / `allocs` 为准 | | ❌ 单价型科目传 `totalAmount` | 589569 | | ✅ 错误提示 | 589567/589568/589570/589571/589573 的 message 自带定位信息(当前状态、差额、订单号、版本号),直接展示 message | --- ## 五、数据库行为 - 无新表、无 Flyway(核团四张表已由 #7868 建好);新增 4 个权限码的种子数据(user-service)。 - GB-ADM-050:已返团且无核团记录时,首次读取会建一份录入中草稿(主行 + 带出的科目行 + 逐户用量);未返团不写。 - GB-ADM-051:覆盖 / 新增 / 删除科目行与用量行,重算金额与三个合计,版本 +1;改价写一条团期时间线。 - GB-ADM-052:写逐户定稿,状态改已核算,版本 +1,写一条时间线。 - GB-ADM-052b:清空逐户定稿,状态回录入中,版本 +1,旧定稿整组写进时间线。 - GB-ADM-054:写一条订单发票申请(复用既有订单发票),不改核团数据与版本。 - GB-ADM-055 / 056:只读。 - 任何接口被拒时整笔回滚,零写入(TEST 逐条核对)。 --- ## 六、边界行为 - 未登录 → 401(网关拦截)。 - 缺权限码 → 589507,且不产生任何写入(包括 GB-ADM-050 不会建草稿)。 - 团期未返团 → GB-ADM-050 返回 `NOT_STARTED`;其余写接口 / 导出返回 589567。 - 并发编辑 → 后提交者 589573;并发双击提交核算 / 重新核算 → 第二次 100502。 - 已验团 → 保存 / 提交核算 / 重新核算均 589568;开票仍可。 - 第 1 层数据读取失败 → GB-ADM-050 不报错,出 `SOURCE_UNAVAILABLE` 提示。 ## 六.5、枚举 / 数据字典 ### auditStatus(核团状态) **所属字段**: GB-ADM-050 / 051 / 052 / 052b / 054 出参 `auditStatus` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `NOT_STARTED` | 未开始 | 团期未返团,只在 GB-ADM-050 出现 | | `DRAFT` | 录入中 | 可保存、可提交核算 | | `ALLOCATED` | 已核算 | 定稿;可重新核算、开票、验团 | | `CHECKED` | 已验团 | 只读;可开票;验团反确认退回已核算 | ### category(科目) **所属字段**: `items[].category` | **类型**: `String` | 值 | 中文 | 计价 | 默认分摊口径 | |----|------|------|--------------| | `HOUSE` | 住宿 | 单价 | `PER_ROOM_NIGHT` | | `VEHICLE` | 车辆 | 总额 | `PER_VEHICLE_GROUP` | | `ACTIVITY` | 景区娱乐 | 单价 | `PER_HEAD_CHECKED` | | `MEAL` | 用餐 | 单价 | `PER_HEAD_CHECKED` | | `GUIDE` | 导游 | 总额 | `PER_ORDER_AVG` | | `PHOTO` | 摄影 | 总额 | `PER_ORDER_AVG` | | `OTHER_EXPENSE` | 其他支出 | 总额 | `PER_ORDER_AVG` | | `OTHER_INCOME` | 其他收入 | 总额 | `PER_ORDER_AVG`(计入收入) | ### allocRule(分摊口径) **所属字段**: `items[].allocRule` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `PER_ROOM_NIGHT` | 按房 | 单价 × 本户房间数(不参加记 0) | | `PER_HEAD_CHECKED` | 勾选按人 | 单价 × 本户人数(不参加记 0) | | `PER_VEHICLE_GROUP` | 按乘车组 | 总额 ÷ 同分组户数,不看是否参加 | | `PER_ORDER_AVG` | 按户均分 | 总额 ÷ 在团户数,不看是否参加 | | `PER_HEAD_AVG` | 按人均分 | 一期不可用,保存传入返回 589569 | ### warnings[].code(提示码) **所属字段**: `warnings[].code` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `CATEGORY_AMOUNT_MISMATCH` | 带出金额不一致 | 单价 × 用量与第 1 层实际成本不一致,可能需要改价 | | `SOURCE_FINALIZED_REOPENED` | 第 1 层已重开 | 定稿后第 1 层核单被重开或重新定稿 | | `HOUSEHOLD_SET_CHANGED` | 在团户变化 | 在团户与核团录入不一致 | | `ALLOC_TRIAL_FAILED` | 试算失败 | 按当前录入无法试算(如对不平),面板逐户为空 | | `SOURCE_UNAVAILABLE` | 第 1 层读取失败 | 第 1 层核单事实读取失败 | | `ITEM_CODE_INVALID` | 科目行枚举非法 | 库里科目行取值非法 | ### coverage(节点下钻覆盖状态) **所属字段**: GB-ADM-056 `rows[].coverage` | **类型**: `String` | 值 | 中文 | 说明 | |----|------|------| | `OK` | 有核单行 | 金额字段有值 | | `PENDING` | 未核单 | 该户还没提交核单,不是没花钱 | | `NOT_APPLICABLE` | 无核单明细 | 该节点类型不按节点归集,置灰,不显示 ¥0 | | `MISSING` | 缺行 | 已核单但没有该节点的行(多为核单后行程改动) | --- ## 七、不影响范围 - **仅影响**: 团期详情「核团验团」Tab 的 7 个新接口。 - **零影响**: - 订单侧第 1 层核单(核团只读取、不回写) - 团期共享成本录入 / 列表 / 汇总(`.../settlement/cost`、`.../settlement/summary`) - 既有订单发票接口 - 网关配置(沿用已有 `/v3/admin/**` 路由) --- ## 八、测试环境已验证 被测版本:hl-order-service-v3 = dev-v3(#7932 PR-1~PR-3b,合并提交 `aed07cc3c`);user-service 权限码种子 `V20260918.932` 已于 2026-09-18 17:58 执行。经网关实测,造数:团期 A(3 户,走完整链路)、B(无成本源)、C(并发首读)、D(判权)、E(无核团行)。 ``` AC-1 GET 050 首读:库中 0 行 → 建 DRAFT 主行 + 11 科目行 + 33 用量行,响应与库逐行一致 ✓ 并发首读(两请求同时):只建 1 行,两边 auditId 相同 ✓ AC-2 PUT 051 改一户用餐不参加 → 该户该项 50.00→0.00,其余户不变,version 0→1 ✓ AC-3 改总额 100→120 不填原因 → 100001 零写入;填原因 → 200,时间线记 BATCH_AUDIT_PRICE_OVERRIDE ✓ AC-17 同一 expectedVersion 写两次 → 第二次 589573「当前版本 3,提交版本 2」,零写入 ✓ AC-10 单价型传总额 / 两者同填 / 两者同空 / 总额型传单价 → 均 589569,零写入 ✓ AC-13 用量里带他团订单 → 589572,零写入 ✓ C1 一户第 1 层未定稿提交核算 → 589568 列出订单号,零写入;定稿后 readyToAllocate=true ✓ AC-4/7 POST 052 → ALLOCATED,Σ逐户成本 3437.00 = 整团,Σ收入 16200.00 = 整团,尾差户标记正确 ✓ AC-8 已核算 PUT → 589568;POST 052b → DRAFT,旧定稿 3 户写进时间线,之后可再保存 ✓ AC-9 一户已开票时 052b → 589571 列出订单号,定稿不清空 ✓ AC-12 已验团时保存 / 提交 / 重算 → 均 589568 ✓ AC-15 GET 055:已核算导出 6 行、合计行 = 整团三数、逐户与库一致;录入中文件名与首行带「未定稿」 ✓ 无核团行导出 → 589567 ✓ AC-13 054 财务开个人抬头 → 200,发票为增值税普通发票 / 旅游服务费 / REQUESTED,核团版本不变 ✓ 同户再开 / 已有有效发票户 → 589571;他团订单 → 589572;单位抬头缺税号 → 581514 ✓ 录入中 → 589568;无核团行 → 589567;已验团态开票 → 200 ✓ AC-15 导出 BOM(EF BB BF)、全 CRLF、14 列表头顺序、合计行 = 库中整团合计 ✓ AC-16 POST 056:OK / NOT_APPLICABLE / PENDING 三种 coverage 正确,非 OK 金额全 null; 他团节点进 unmatchedNodeIds、rows=[];201 个 → 400;空 → 400 ✓ 权限 user-service 种子:audit:view→管理员/财务/团期管理员;edit、allocate→管理员/团期管理员; invoice→管理员/财务 ✓ 逐角色实测(种子执行后等过 10 分钟缓存):团期管理员 050/051/052/052b/055/056 通过、054 → 589507; 财务 050/054/055/056 通过、051/052/052b → 589507;定制师、运营全部 589507; 团期管理员对团期 C 真实保存成功(version 0→1) ✓ ``` --- ## 十、相关文档 - 关联 Issue: [wx/HL#7932](https://git.1814.love:8443/wx/HL/issues/7932) - 关联 PR: [#7940](https://git.1814.love:8443/wx/HL/pulls/7940)、[#7944](https://git.1814.love:8443/wx/HL/pulls/7944)、[#7943](https://git.1814.love:8443/wx/HL/pulls/7943)、[#7955](https://git.1814.love:8443/wx/HL/pulls/7955) - 同日修改接口:`changelogs-v2/2026-09/18_7932_团期验团归档前置核团-修改接口-管理后台.md`(验团 `/settle` 行为变更) ## 关联 / 联系人 ### 链接 - **Issue**: [#7932](https://git.1814.love:8443/wx/HL/issues/7932) - **PR**: [#7940](https://git.1814.love:8443/wx/HL/pulls/7940)、[#7944](https://git.1814.love:8443/wx/HL/pulls/7944)、[#7943](https://git.1814.love:8443/wx/HL/pulls/7943)、[#7955](https://git.1814.love:8443/wx/HL/pulls/7955) - **Merge commit**: [aed07cc3c](https://git.1814.love:8443/wx/HL/commit/aed07cc3c)(最后一个 PR) ### 联系人 - **后端负责人**: @jw - **前端负责人**: @mmg