diff --git a/changelogs-v2/2026-08/29_6544_供应商合同独立登记-新增接口-管理后台.md b/changelogs-v2/2026-08/29_6544_供应商合同独立登记-新增接口-管理后台.md new file mode 100644 index 00000000..d1a1d3fa --- /dev/null +++ b/changelogs-v2/2026-08/29_6544_供应商合同独立登记-新增接口-管理后台.md @@ -0,0 +1,523 @@ +--- +schema: "hl-changelog/v2" +ticket: "6544" +title: "供应商合同独立登记" +consumer: "admin" +author: "lc(GIT)" +change_type: "新增接口" +backend_status: "deployed" +gateway_status: "verified" +frontend_status: "pending" +frontend_owner: "mmg" +frontend_ref: "pending" +target_release: "v2.1" +verified_at: "2026-08-29" +status_note: "PR #6559 已合并 dev-v3(提交 b95c15b0e,#6544);hl-resource-service 8-28 构建已部署 TEST 并含独立合同端点,真实 SUPER_ADMIN 身份完成新增/更新/删除/版本围栏/错误码抽查。审批提交候选 v2 起不再携带 contracts,合同由独立接口维护。已知 B1:当前 TEST 构建 create 返回的 updateTime 为内存值,亚秒被全局序列化截断而 DB DATETIME(0) 四舍五入,约半数场景立即用其 update 会 395014——修复已合入 #6591(写后回读),待部署后 create 返回值即数据库真实秒级版本。" +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` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `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 +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 +} +``` + +必填字段缺失或长度超限返回 `400`(#6591 部署后改为 395052/395053 等冻结错误码,文案不变): + +```json +{ + "code": 400, + "message": "合同名称不能为空", + "success": false, + "data": null +} +``` + +#### 业务边界 + +- 权限:可信管理员口令 + `ADMIN`/`FINANCE`/`SUPER_ADMIN` 允许的写角色 + `supplier:update` 平台权限;任一门禁失败不产生数据库写。 +- 合同登记只写 `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` + +与新增相同,`updateTime` 返回新版本(严格晚于旧版本),请用该值继续后续围栏。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| `contractId` | String | 合同 ID(字符串) | +| (其余字段) | - | 同新增出参,`updateTime` 为新并发版本 | + +#### 请求示例 + +```http +PUT /admin/supplier/items/2091381911266967553/contracts/2093378327078154241/update +Authorization: Bearer +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` + +成功时 `data` 为 null: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `code` | Integer | 恒为 `200` 表示成功 | +| `data` | null | 删除接口无返回体 | + +#### 请求示例 + +```http +DELETE /admin/supplier/items/2091381911266967553/contracts/2093378327078154241/del +Authorization: Bearer +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(#6544 合并构建)**:登记(add)响应中的 `updateTime` 来自内存填充值,亚秒被全局序列化截断,而数据库 DATETIME(0) 四舍五入进位——约一半场景下该返回值比数据库真实版本早 1 秒,立即用其 update/del 会返回 `395014`。修复 `#6591`(写后读回,返回数据库真实版本)已合并待部署;部署前若遇到 `395014`,请重新拉详情取真实 `updateTime` 再重试。 + +## 五、数据库行为 + +- 写操作仅影响 `supplier_contract` 一行(新增 insert / 更新 update / 删除软删),以及 `supplier_change_log` 一条对应合同审计事实(`fieldName=contract`,前后完整快照差异)。 +- 三个写接口均不修改 `supplier_main`(供应商主体版本不变),不进审批流,不写缓存、MQ 或跨服务数据。 +- 更新与删除仅在版本围栏通过后产生写;版本不匹配时不产生任何数据库副作用。 +- 软删除使用统一 `deleted_at` 逻辑删除,物理行保留;查询与详情自动过滤。 + +## 六、边界行为 + +- 未登录/登录失效:业务码 `401`;角色或平台权限不足:`403`,不查询不写入。 +- `supplierId`/`contractId` 非法(非数字、超长、<=0):`400`;供应商不存在或已软删除:`395001`。 +- 已归档供应商:`395031`(写入一律拒绝)。 +- 未知或非生命周期状态供应商:`395005`(状态机门禁先行失败)。 +- 版本不匹配:`395014`;请求无实际变化:`400`(#6591 部署后为 `395057`)。 +- 删除草稿供应商仍有未删除合同:`395058`「已有合同登记,请先独立删除合同」(#6591 部署后;当前构建为 395005)。 +- 参数校验错误码:当前构建走 `400` 文案;#6591 部署后为 395052/395053/395054/395055/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:#6544 由 #6559 合入 dev-v3(提交 `b95c15b0e`);每日审查修复 #6591 已合入(提交 `9eb9c96c0`,待部署)。 +- TEST 部署证实:`hl-resource-service` jar(8-28 09:48 构建)含独立合同端点与校验器,双实例 8-28 21:03 重启运行中。 +- 真实 Gateway(127.0.0.1:8080,SUPER_ADMIN)抽查:登记成功并返回字符串 ID/金额/秒级版本;版本围栏过期 `395014`;删除成功软删(`deleted_at` 落库);不存在供应商 `395001` 正确;参数缺 `expectedUpdateTime` 返回 400。 +- 已知缺口(已修复未部署):登记返回值与数据库版本亚秒错位问题(B1,#6591 写后回读)在 TEST 实测复现,属当前构建已确认缺陷,非契约差异。 +- 数据清理:验收使用的自建测试合同均已通过接口软删自清理,未改动同事业务数据。 + +## 九、相关历史 PR + +| PR | Issue | 说明 | 是否仍有效 | +|---|---|---|---| +| #6559 | #6544 | 独立合同登记三写接口 + 边界拆分 | ✅ 本次主功能 | +| #6558 | 每日审查 | 合同模块每日审查修复 7 项(写后读回/错误码/VO 文档/金额比较) | ✅ 待部署 | +| #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) +- 管理端接入:详情页「合同」区改为直接调 add/update/del 三接口;提交审批的候选不再携带 contracts。 + +## 撤回 + +1. 管理端停止请求三个 contract 写路径,并清除详情页合同编辑入口。 +2. 从最新 `dev-v3` 建独立回退分支,对主功能合并执行 `git revert -m 1 --no-edit b95c15b0e`,验证后经独立 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) +- **Merge commit**: [`b95c15b0e`](https://git.1814.love:8443/wx/HL/commit/b95c15b0e3) / [`9eb9c96c0`](https://git.1814.love:8443/wx/HL/commit/9eb9c96c09975a3ccf4966a257cc77bdf9f743b9) + +### 联系人 + +- **后端负责人**: @lc \ No newline at end of file