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 index 32868e7..20a35bd 100644 --- a/backend/api/internal/logic/platform/gasorder/contract_change_error_test.go +++ b/backend/api/internal/logic/platform/gasorder/contract_change_error_test.go @@ -27,6 +27,22 @@ func TestContractChangeBusinessErrors(t *testing.T) { } } +// TestContractBindingErrorMessages 验证绑定失败时给出可操作的中文原因。 +func TestContractBindingErrorMessages(t *testing.T) { + want := map[error]string{ + errBindingContractStatus: "仅草稿合同可以绑定气瓶", + errBindingProductDisabled: "所选气瓶已停用,不能绑定到合同", + errBindingProductScrapped: "所选气瓶已报废,不能绑定到合同", + errBindingProductOwner: "所选气瓶不属于该合同用户,请选择合同用户所属的气瓶", + errBindingProductDuplicate: "所选气瓶已绑定到该合同,请勿重复绑定", + } + for businessErr, message := range want { + if businessErr.Error() != message { + t.Fatalf("绑定错误提示不清晰:得到 %q,期望 %q", businessErr.Error(), message) + } + } +} + // TestContractActivationErrorMessages 验证关键启用失败原因保持清晰中文。 func TestContractActivationErrorMessages(t *testing.T) { want := map[error]string{ diff --git a/backend/api/internal/logic/platform/gasorder/gasorder.go b/backend/api/internal/logic/platform/gasorder/gasorder.go index 925c24d..0c906eb 100644 --- a/backend/api/internal/logic/platform/gasorder/gasorder.go +++ b/backend/api/internal/logic/platform/gasorder/gasorder.go @@ -29,6 +29,11 @@ var ( errContractNotEffective = errors.New("合同尚未到生效时间,暂时不能启用") errContractExpired = errors.New("合同已到期,请调整到期时间或续签后再启用") errContractNoProduct = errors.New("合同尚未绑定有效气瓶,请先绑定气瓶后再启用") + errBindingContractStatus = errors.New("仅草稿合同可以绑定气瓶") + errBindingProductDisabled = errors.New("所选气瓶已停用,不能绑定到合同") + errBindingProductScrapped = errors.New("所选气瓶已报废,不能绑定到合同") + errBindingProductOwner = errors.New("所选气瓶不属于该合同用户,请选择合同用户所属的气瓶") + errBindingProductDuplicate = errors.New("所选气瓶已绑定到该合同,请勿重复绑定") ) func ListGasorderContract(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderContract{}) } @@ -424,14 +429,36 @@ func BindGasorderContractProduct(ctx *gin.Context) { common.RespondRecordError(ctx, err) return } - if contract.ContractStatus != common.StatusDraft && contract.ContractStatus != common.StatusActive { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) + if contract.ContractStatus != common.StatusDraft { + infra.Response.Error(ctx, errBindingContractStatus) return } var product models.ProductInfo - if err := impl.DBService.Where("identity = ?", request.ProductIdentity).First(&product).Error; err != nil || - product.Status != common.StatusEnable || product.ProductStatus == common.StatusScrapped || product.UserAccountID != contract.UserAccountID { - infra.Response.Error(ctx, errcode.ErrInvalidArgument) + if err := impl.DBService.Where("identity = ?", request.ProductIdentity).First(&product).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + if product.Status != common.StatusEnable { + infra.Response.Error(ctx, errBindingProductDisabled) + return + } + if product.ProductStatus == common.StatusScrapped { + infra.Response.Error(ctx, errBindingProductScrapped) + return + } + if product.UserAccountID != contract.UserAccountID { + infra.Response.Error(ctx, errBindingProductOwner) + return + } + var activeBindingCount int64 + if err := impl.DBService.Model(&models.GasorderContractProduct{}). + Where("gasorder_contract_id = ? AND product_info_id = ? AND unbound_at IS NULL", contract.ID, product.ID). + Count(&activeBindingCount).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if activeBindingCount > 0 { + infra.Response.Error(ctx, errBindingProductDuplicate) return } var productType models.ProductType diff --git a/backend/api/internal/logic/platform/product/product.go b/backend/api/internal/logic/platform/product/product.go index c2d6781..efc7d9a 100644 --- a/backend/api/internal/logic/platform/product/product.go +++ b/backend/api/internal/logic/platform/product/product.go @@ -28,12 +28,31 @@ var productLifecycleStatuses = map[int]bool{ func ProductInfoHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { fields := []string{"code", "name", "params", "produced_at", "action", "reason", "remark"} - return func(ctx *gin.Context) { common.ListResource(ctx, &models.ProductInfo{}) }, + return listProductInfo, func(ctx *gin.Context) { createProductInfo(ctx, fields, relations) }, func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductInfo{}) }, func(ctx *gin.Context) { updateProductInfo(ctx, fields, relations) } } +// listProductInfo 按合同筛选可绑定气瓶;普通列表请求保持原有行为。 +func listProductInfo(ctx *gin.Context) { + contractIdentity := strings.TrimSpace(ctx.Query("contract_identity")) + if contractIdentity == "" { + common.ListResource(ctx, &models.ProductInfo{}) + return + } + var contract models.GasorderContract + if err := impl.DBService.Where("identity = ?", contractIdentity).First(&contract).Error; err != nil { + common.RespondRecordError(ctx, err) + return + } + common.ListPageFiltered[models.ProductInfo](ctx, func(query *gorm.DB) *gorm.DB { + return query. + Where("status = ? AND product_status <> ? AND user_account_id = ?", common.StatusEnable, common.StatusScrapped, contract.UserAccountID). + Where("NOT EXISTS (SELECT 1 FROM gasorder_contract_product WHERE gasorder_contract_product.product_info_id = product_info.id AND gasorder_contract_product.gasorder_contract_id = ? AND gasorder_contract_product.unbound_at IS NULL)", contract.ID) + }) +} + func createProductInfo(ctx *gin.Context, fields []string, relations []common.ResourceRelation) { values, err := common.PrepareResourceValues(ctx, &models.ProductInfo{}, fields, relations) if err != nil || !validParamsText(values["params"]) { diff --git a/docs/操作日志_合同启用失败原因明确化_20260812.md b/docs/操作日志_合同启用失败原因明确化_20260812.md index 1748432..8487358 100644 --- a/docs/操作日志_合同启用失败原因明确化_20260812.md +++ b/docs/操作日志_合同启用失败原因明确化_20260812.md @@ -14,11 +14,16 @@ - 终止非生效合同也返回明确状态原因。 - 仅透传预定义业务错误;数据库及其他内部异常继续使用通用参数错误,避免泄露内部信息。 - 增加业务错误白名单和中文消息回归测试。 +- 明确区分绑定气瓶时的合同状态、气瓶停用、气瓶报废、用户归属不一致和重复绑定原因。 +- 后端同步限制仅草稿合同允许绑定,与列表按钮可见规则保持一致。 +- 合同气瓶表单按当前合同过滤候选项,仅展示合同用户所属、已启用、未报废且未在该合同重复绑定的气瓶。 +- 切换合同时清空原气瓶并重新加载候选项;后端业务校验继续作为安全兜底。 ## 行为变化 - 变更前:所有启用前置条件失败均提示“请求参数不正确,请检查填写内容”。 - 变更后:页面直接显示具体失败项及处理建议,例如“合同尚未绑定有效气瓶,请先绑定气瓶后再启用”。 +- 绑定失败时会提示具体处理原因,例如“所选气瓶不属于该合同用户,请选择合同用户所属的气瓶”。 ## 代码变更 diff --git a/docs/操作日志_合同气瓶绑定入口_20260812.md b/docs/操作日志_合同气瓶绑定入口_20260812.md index 3d0ff4c..5504a36 100644 --- a/docs/操作日志_合同气瓶绑定入口_20260812.md +++ b/docs/操作日志_合同气瓶绑定入口_20260812.md @@ -13,6 +13,7 @@ - 在配送合同列表的操作列增加“绑定气瓶”按钮,仅草稿状态(`contract_status=0`)显示。 - 合同已启用或处于其他非草稿状态时隐藏绑定按钮,详情页不再展示该入口。 - 跳转合同气瓶新建页时,通过既有关系预填协议带入当前合同唯一标识。 +- 从合同列表行进入时锁定预填合同,以可读名称展示且禁止切换到其他合同;直接进入通用新建页时仍允许选择合同。 - 携带当前详情页返回地址,使创建完成后可回到原合同。 - 保留合同气瓶隐藏子路由、后端接口及权限模型,不改变现有公共接口。 - 增加静态回归检查,固定入口展示范围、目标路由、合同预填字段和返回路径。 diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index dda0a08..38f0687 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -18,6 +18,7 @@ "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", + "contract-product-filter:check": "node scripts/check-contract-product-filter.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 index fc8b217..2968149 100644 --- a/frontend/platform_admin/scripts/check-contract-product-entry.mjs +++ b/frontend/platform_admin/scripts/check-contract-product-entry.mjs @@ -13,6 +13,10 @@ const detailSource = readFileSync( new URL('../src/views/resource/ResourceRecordPage.vue', import.meta.url), 'utf8', ); +const resourcesSource = readFileSync( + new URL('../src/api/resources.ts', import.meta.url), + 'utf8', +); assert.match(listSource, /definition\.name === 'gasorder_contract' && Number\(record\.contract_status\) === 0/, '绑定入口必须仅展示在草稿合同列表行'); assert.match(listSource, />\s*绑定气瓶\s*\s*绑定气瓶\s* .filter( (field) => field.readonlyOnCreate || - (field.staffRelation?.lockPrefilled && Boolean(form[field.key])), + (field.staffRelation?.lockPrefilled && Boolean(form[field.key])) || + (definition.value.name === 'gasorder_contract_product' && + field.key === 'gasorder_contract_identity' && + route.query.relation_key === 'gasorder_contract_identity' && + Boolean(route.query.owner_identity)), ) .map((field) => field.key) : mode.value === 'edit' diff --git a/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts b/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts index e91646d..27b2ec7 100644 --- a/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts +++ b/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts @@ -162,7 +162,11 @@ export function useResourceRelationLinkage( return; const linkage = active.childField.relationLinkage; const childIdentity = relationIdentity(form[active.childField.key]); - if (childIdentity) { + if (linkage.filterOnly && childIdentity) { + form[active.childField.key] = ''; + Message.info('合同已变更,请重新选择该合同可绑定的气瓶'); + } + if (childIdentity && !linkage.filterOnly) { const option = findOption(active.childField.relation, childIdentity) ?? (await relations.ensure(active.childField.relation, childIdentity)); @@ -187,6 +191,7 @@ export function useResourceRelationLinkage( return; const linkage = active.childField.relationLinkage; if (!childIdentity) return; + if (linkage.filterOnly) return; const option = findOption(active.childField.relation, childIdentity) ?? (await relations.ensure(active.childField.relation, childIdentity)); @@ -249,6 +254,7 @@ export function useResourceRelationLinkage( if (!active?.childField.relationLinkage || !active.childField.relation) return ''; const linkage = active.childField.relationLinkage; + if (linkage.filterOnly) return ''; const parentIdentity = relationIdentity(form[linkage.parentKey]); const childIdentity = relationIdentity(form[active.childField.key]); const option = findOption(active.childField.relation, childIdentity);