docs(changelog): 补充 #6654 供应商合同与结算契约
changelog-filename-gate / validate (push) Successful in 2s

这个提交包含在:
lc
2026-08-29 16:15:01 +08:00
父节点 b54c4cc8d7
当前提交 eb628f56b0
@@ -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: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
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
- **前端状态**: 待前端处理