diff --git a/backend/api/internal/logic/platform/gasorder/contract_change_error_test.go b/backend/api/internal/logic/platform/gasorder/contract_change_error_test.go new file mode 100644 index 0000000..32868e7 --- /dev/null +++ b/backend/api/internal/logic/platform/gasorder/contract_change_error_test.go @@ -0,0 +1,43 @@ +// 功能:验证合同生命周期业务错误可安全、明确地返回前端。 +// 版本:v1.0.0 +package gasorder + +import ( + "errors" + "testing" +) + +// TestContractChangeBusinessErrors 验证所有允许透传的合同业务原因。 +func TestContractChangeBusinessErrors(t *testing.T) { + businessErrors := []error{ + errContractCannotActivate, + errContractCannotTerminate, + errContractAttachment, + errContractNotEffective, + errContractExpired, + errContractNoProduct, + } + for _, businessErr := range businessErrors { + if !isContractChangeBusinessError(businessErr) { + t.Fatalf("业务错误未被识别:%s", businessErr) + } + } + if isContractChangeBusinessError(errors.New("database connection failed")) { + t.Fatal("内部错误不应透传给前端") + } +} + +// TestContractActivationErrorMessages 验证关键启用失败原因保持清晰中文。 +func TestContractActivationErrorMessages(t *testing.T) { + want := map[error]string{ + errContractAttachment: "合同附件无效,请重新上传有效的 PDF 文件后再启用", + errContractNotEffective: "合同尚未到生效时间,暂时不能启用", + errContractExpired: "合同已到期,请调整到期时间或续签后再启用", + errContractNoProduct: "合同尚未绑定有效气瓶,请先绑定气瓶后再启用", + } + for businessErr, message := range want { + if businessErr.Error() != message { + t.Fatalf("错误提示不清晰:得到 %q,期望 %q", businessErr.Error(), message) + } + } +} diff --git a/backend/api/internal/logic/platform/gasorder/gasorder.go b/backend/api/internal/logic/platform/gasorder/gasorder.go index 0350221..925c24d 100644 --- a/backend/api/internal/logic/platform/gasorder/gasorder.go +++ b/backend/api/internal/logic/platform/gasorder/gasorder.go @@ -21,6 +21,16 @@ var gasorderCreatorModels = map[string]any{ "delivery": &models.DeliveryBasic{}, "gas": &models.GasBasic{}, } +// 合同生命周期业务错误会直接返回前端,避免把可处理原因模糊成参数错误。 +var ( + errContractCannotActivate = errors.New("当前合同状态不允许启用,请刷新页面后重试") + errContractCannotTerminate = errors.New("仅生效中的合同可以终止") + errContractAttachment = errors.New("合同附件无效,请重新上传有效的 PDF 文件后再启用") + errContractNotEffective = errors.New("合同尚未到生效时间,暂时不能启用") + errContractExpired = errors.New("合同已到期,请调整到期时间或续签后再启用") + errContractNoProduct = errors.New("合同尚未绑定有效气瓶,请先绑定气瓶后再启用") +) + func ListGasorderContract(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderContract{}) } func GetGasorderContract(ctx *gin.Context) { getGasorderContract(ctx) } func ListGasorderContractProduct(ctx *gin.Context) { @@ -337,24 +347,30 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) { return err } if action == "activate" && contract.ContractStatus != common.StatusDraft && contract.ContractStatus != common.StatusTerminated { - return errors.New("contract cannot be activated") + return errContractCannotActivate } if action == "terminate" && contract.ContractStatus != common.StatusActive { - return errors.New("contract cannot be terminated") + return errContractCannotTerminate } if target == common.StatusActive { attachmentPath, attachmentErr := contractAttachmentPath(contract.FileURI, false) if attachmentErr != nil || !validStoredContractPDF(attachmentPath) { - return errors.New("contract has no valid attachment") + return errContractAttachment } now := time.Now() - if contract.EffectiveAt.After(now) || (contract.ExpiredAt != nil && !contract.ExpiredAt.After(now)) { - return errors.New("contract outside effective period") + if contract.EffectiveAt.After(now) { + return errContractNotEffective + } + if contract.ExpiredAt != nil && !contract.ExpiredAt.After(now) { + return errContractExpired } var productCount int64 if err := tx.Model(&models.GasorderContractProduct{}). Where("gasorder_contract_id = ? AND unbound_at IS NULL", contract.ID).Count(&productCount).Error; err != nil || productCount == 0 { - return errors.New("contract has no active product") + if err != nil { + return err + } + return errContractNoProduct } } if err := tx.Model(&contract).Update("contract_status", target).Error; err != nil { @@ -364,12 +380,26 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) { return tx.Create(contractRevision(contract, action, request.Reason, operatorIdentity, operatorName)).Error }) if err != nil { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) + if isContractChangeBusinessError(err) { + infra.Response.Error(ctx, err) + } else { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + } return } infra.Response.Success(ctx, gin.H{"updated": true, "contract_status": target}) } +// isContractChangeBusinessError 仅允许预定义业务原因透传,避免泄露数据库内部错误。 +func isContractChangeBusinessError(err error) bool { + return errors.Is(err, errContractCannotActivate) || + errors.Is(err, errContractCannotTerminate) || + errors.Is(err, errContractAttachment) || + errors.Is(err, errContractNotEffective) || + errors.Is(err, errContractExpired) || + errors.Is(err, errContractNoProduct) +} + func contractRevision(contract models.GasorderContract, action, reason, operatorIdentity, operatorName string) *models.GasorderContractRevision { return &models.GasorderContractRevision{ Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, diff --git a/docs/操作日志_合同启用失败原因明确化_20260812.md b/docs/操作日志_合同启用失败原因明确化_20260812.md new file mode 100644 index 0000000..1748432 --- /dev/null +++ b/docs/操作日志_合同启用失败原因明确化_20260812.md @@ -0,0 +1,37 @@ +# 操作日志:合同启用失败原因明确化 + +操作时间:2026-08-12 +操作类型:修改 +影响模块:配送合同生命周期接口 + +## 操作前状态 + +合同启用的附件、有效期、气瓶绑定及状态校验失败后,接口统一返回“Invalid Argument”,前端只能提示“请求参数不正确,请检查填写内容”,业务人员无法定位处理方式。 + +## 具体操作 + +- 将合同状态不允许启用、附件无效、未到生效时间、合同已到期、未绑定有效气瓶拆分为明确中文原因。 +- 终止非生效合同也返回明确状态原因。 +- 仅透传预定义业务错误;数据库及其他内部异常继续使用通用参数错误,避免泄露内部信息。 +- 增加业务错误白名单和中文消息回归测试。 + +## 行为变化 + +- 变更前:所有启用前置条件失败均提示“请求参数不正确,请检查填写内容”。 +- 变更后:页面直接显示具体失败项及处理建议,例如“合同尚未绑定有效气瓶,请先绑定气瓶后再启用”。 + +## 代码变更 + +- `backend/api/internal/logic/platform/gasorder/gasorder.go`:细分合同生命周期业务错误并安全透传。 +- `backend/api/internal/logic/platform/gasorder/contract_change_error_test.go`:新增错误分类及提示文案测试。 + +## 验证结果 + +- `go test ./internal/logic/platform/gasorder/...`:通过。 +- `go test ./...`:通过;首次受沙箱 Go 构建缓存权限影响失败,授权后重新执行全部通过。 + +## 风险评估 + +- 不修改接口路径、请求字段、成功响应和合同状态流转规则,保持向下兼容。 +- 业务失败响应的 `message` 由通用英文变为具体中文,前端现有客户端会直接展示。 +- 内部数据库错误不会透传,安全边界保持不变。 diff --git a/docs/操作日志_合同气瓶绑定入口_20260812.md b/docs/操作日志_合同气瓶绑定入口_20260812.md new file mode 100644 index 0000000..3d0ff4c --- /dev/null +++ b/docs/操作日志_合同气瓶绑定入口_20260812.md @@ -0,0 +1,41 @@ +# 操作日志:合同气瓶绑定入口 + +操作时间:2026-08-12 +操作类型:扩展 +影响模块:平台总后台配送合同详情页 + +## 操作前状态 + +合同气瓶作为隐藏子资源复用合同管理权限,但配送合同详情页没有进入新建页的入口。业务人员只能手动输入隐藏路由,且需要再次选择当前合同。 + +## 具体操作 + +- 在配送合同列表的操作列增加“绑定气瓶”按钮,仅草稿状态(`contract_status=0`)显示。 +- 合同已启用或处于其他非草稿状态时隐藏绑定按钮,详情页不再展示该入口。 +- 跳转合同气瓶新建页时,通过既有关系预填协议带入当前合同唯一标识。 +- 携带当前详情页返回地址,使创建完成后可回到原合同。 +- 保留合同气瓶隐藏子路由、后端接口及权限模型,不改变现有公共接口。 +- 增加静态回归检查,固定入口展示范围、目标路由、合同预填字段和返回路径。 + +## 操作后状态 + +业务人员可从草稿配送合同所在行直接发起气瓶绑定;新建表单自动选中该合同。合同启用后不再提供绑定入口。合同气瓶仍是合同管理下的隐藏子资源,不增加侧边栏菜单。 + +## 代码变更 + +- `frontend/platform_admin/src/views/shared/CrudListPage.vue`:在草稿合同操作列增加绑定按钮和跳转方法。 +- `frontend/platform_admin/src/views/resource/ResourceRecordPage.vue`:移除详情页绑定入口。 +- `frontend/platform_admin/scripts/check-contract-product-entry.mjs`:新增静态回归检查。 +- `frontend/platform_admin/package.json`:注册回归检查命令。 + +## 验证结果 + +- `npm run contract-product-entry:check`:通过。 +- `npm run type:check`:通过。 +- `npm run build`:通过,Vite 成功构建 2620 个模块。 + +## 风险评估 + +- 影响仅限配送合同详情页顶部操作区。 +- 继续使用既有隐藏新建路由和通用关系预填机制,风险较低。 +- 后端仍会校验气瓶归属、状态和是否报废,前端入口不会绕过业务约束。 diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index 785907a..dda0a08 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -17,6 +17,7 @@ "account-roles:check": "node scripts/check-account-role-presentation.mjs", "avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs", "contract-attachment:check": "node scripts/check-contract-attachment-control.mjs", + "contract-product-entry:check": "node scripts/check-contract-product-entry.mjs", "staff-organization:check": "node scripts/check-staff-organization-linkage.mjs", "staff-relations:check": "node scripts/check-staff-relation-policy.mjs", "user-address-display:check": "node scripts/check-user-address-relation-display.mjs", diff --git a/frontend/platform_admin/scripts/check-contract-product-entry.mjs b/frontend/platform_admin/scripts/check-contract-product-entry.mjs new file mode 100644 index 0000000..fc8b217 --- /dev/null +++ b/frontend/platform_admin/scripts/check-contract-product-entry.mjs @@ -0,0 +1,25 @@ +/** + * 功能:检查配送合同详情页保留可发现的气瓶绑定入口与合同预填参数。 + * 版本:v1.0.0 + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const listSource = readFileSync( + new URL('../src/views/shared/CrudListPage.vue', import.meta.url), + 'utf8', +); +const detailSource = readFileSync( + new URL('../src/views/resource/ResourceRecordPage.vue', import.meta.url), + 'utf8', +); + +assert.match(listSource, /definition\.name === 'gasorder_contract' && Number\(record\.contract_status\) === 0/, '绑定入口必须仅展示在草稿合同列表行'); +assert.match(listSource, />\s*绑定气瓶\s*\s*绑定气瓶\s*账户管理 查看资质 详情 + 绑定气瓶 编辑 @@ -370,6 +376,18 @@ function viewCredentials(row: ResourceRow) { }); } +/** 从草稿合同列表进入合同气瓶新建页,并预填所选合同。 */ +function bindContractProduct(row: ResourceRow) { + return router.push({ + name: 'user-contract-products-create', + query: { + relation_key: 'gasorder_contract_identity', + owner_identity: String(row.identity ?? ''), + return_to: route.fullPath, + }, + }); +} + function returnManagedList() { const target = safeReturnPath(route.query.return_to); return router.push(target || { name: String(route.meta.activeMenu ?? '') });