--- 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` | 字段 | 类型 | 说明 | |---|---|---| | `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` | 字段 | 类型 | 说明 | |---|---|---| | `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` | 字段 | 类型 | 说明 | |---|---|---| | `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` | 字段 | 类型 | 说明 | |---|---|---| | `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 ``` #### 响应示例 ```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 仍必填。