优化合同气瓶绑定与启用失败提示
- 将绑定气瓶入口移至合同列表,仅草稿合同显示 - 自动预填所选合同并保留返回路径 - 细分附件、有效期、气瓶绑定和状态校验失败原因 - 增加前后端回归检查及中文操作日志
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,16 @@ var gasorderCreatorModels = map[string]any{
|
|||||||
"delivery": &models.DeliveryBasic{}, "gas": &models.GasBasic{},
|
"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 ListGasorderContract(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderContract{}) }
|
||||||
func GetGasorderContract(ctx *gin.Context) { getGasorderContract(ctx) }
|
func GetGasorderContract(ctx *gin.Context) { getGasorderContract(ctx) }
|
||||||
func ListGasorderContractProduct(ctx *gin.Context) {
|
func ListGasorderContractProduct(ctx *gin.Context) {
|
||||||
@@ -337,24 +347,30 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if action == "activate" && contract.ContractStatus != common.StatusDraft && contract.ContractStatus != common.StatusTerminated {
|
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 {
|
if action == "terminate" && contract.ContractStatus != common.StatusActive {
|
||||||
return errors.New("contract cannot be terminated")
|
return errContractCannotTerminate
|
||||||
}
|
}
|
||||||
if target == common.StatusActive {
|
if target == common.StatusActive {
|
||||||
attachmentPath, attachmentErr := contractAttachmentPath(contract.FileURI, false)
|
attachmentPath, attachmentErr := contractAttachmentPath(contract.FileURI, false)
|
||||||
if attachmentErr != nil || !validStoredContractPDF(attachmentPath) {
|
if attachmentErr != nil || !validStoredContractPDF(attachmentPath) {
|
||||||
return errors.New("contract has no valid attachment")
|
return errContractAttachment
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if contract.EffectiveAt.After(now) || (contract.ExpiredAt != nil && !contract.ExpiredAt.After(now)) {
|
if contract.EffectiveAt.After(now) {
|
||||||
return errors.New("contract outside effective period")
|
return errContractNotEffective
|
||||||
|
}
|
||||||
|
if contract.ExpiredAt != nil && !contract.ExpiredAt.After(now) {
|
||||||
|
return errContractExpired
|
||||||
}
|
}
|
||||||
var productCount int64
|
var productCount int64
|
||||||
if err := tx.Model(&models.GasorderContractProduct{}).
|
if err := tx.Model(&models.GasorderContractProduct{}).
|
||||||
Where("gasorder_contract_id = ? AND unbound_at IS NULL", contract.ID).Count(&productCount).Error; err != nil || productCount == 0 {
|
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 {
|
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
|
return tx.Create(contractRevision(contract, action, request.Reason, operatorIdentity, operatorName)).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
infra.Response.Success(ctx, gin.H{"updated": true, "contract_status": target})
|
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 {
|
func contractRevision(contract models.GasorderContract, action, reason, operatorIdentity, operatorName string) *models.GasorderContractRevision {
|
||||||
return &models.GasorderContractRevision{
|
return &models.GasorderContractRevision{
|
||||||
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable},
|
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable},
|
||||||
|
|||||||
37
docs/操作日志_合同启用失败原因明确化_20260812.md
Normal file
37
docs/操作日志_合同启用失败原因明确化_20260812.md
Normal file
@@ -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` 由通用英文变为具体中文,前端现有客户端会直接展示。
|
||||||
|
- 内部数据库错误不会透传,安全边界保持不变。
|
||||||
41
docs/操作日志_合同气瓶绑定入口_20260812.md
Normal file
41
docs/操作日志_合同气瓶绑定入口_20260812.md
Normal file
@@ -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 个模块。
|
||||||
|
|
||||||
|
## 风险评估
|
||||||
|
|
||||||
|
- 影响仅限配送合同详情页顶部操作区。
|
||||||
|
- 继续使用既有隐藏新建路由和通用关系预填机制,风险较低。
|
||||||
|
- 后端仍会校验气瓶归属、状态和是否报废,前端入口不会绕过业务约束。
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"account-roles:check": "node scripts/check-account-role-presentation.mjs",
|
"account-roles:check": "node scripts/check-account-role-presentation.mjs",
|
||||||
"avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs",
|
"avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs",
|
||||||
"contract-attachment:check": "node scripts/check-contract-attachment-control.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-organization:check": "node scripts/check-staff-organization-linkage.mjs",
|
||||||
"staff-relations:check": "node scripts/check-staff-relation-policy.mjs",
|
"staff-relations:check": "node scripts/check-staff-relation-policy.mjs",
|
||||||
"user-address-display:check": "node scripts/check-user-address-relation-display.mjs",
|
"user-address-display:check": "node scripts/check-user-address-relation-display.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*</, '草稿合同操作列必须展示“绑定气瓶”按钮');
|
||||||
|
assert.match(listSource, /name: 'user-contract-products-create'/, '绑定入口必须跳转合同气瓶新建页');
|
||||||
|
assert.match(listSource, /relation_key: 'gasorder_contract_identity'/, '绑定入口必须指定合同关系字段');
|
||||||
|
assert.match(listSource, /owner_identity: String\(row\.identity \?\? ''\)/, '绑定入口必须预填所选合同唯一标识');
|
||||||
|
assert.match(listSource, /return_to: route\.fullPath/, '绑定完成后必须支持返回合同列表');
|
||||||
|
assert.doesNotMatch(detailSource, />\s*绑定气瓶\s*</, '合同详情页不应继续展示绑定按钮');
|
||||||
|
|
||||||
|
console.log('合同气瓶绑定入口检查通过:仅草稿合同列表行可见,已启用合同和详情页均隐藏。');
|
||||||
@@ -107,6 +107,12 @@
|
|||||||
<a-button v-if="definition.accountManagement" size="mini" type="primary" @click="manageAccounts(record)">账户管理</a-button>
|
<a-button v-if="definition.accountManagement" size="mini" type="primary" @click="manageAccounts(record)">账户管理</a-button>
|
||||||
<a-button v-if="definition.name === 'staff_account'" size="mini" @click="viewCredentials(record)">查看资质</a-button>
|
<a-button v-if="definition.name === 'staff_account'" size="mini" @click="viewCredentials(record)">查看资质</a-button>
|
||||||
<a-button size="mini" @click="openRecord('detail', record)">详情</a-button>
|
<a-button size="mini" @click="openRecord('detail', record)">详情</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="definition.name === 'gasorder_contract' && Number(record.contract_status) === 0"
|
||||||
|
size="mini"
|
||||||
|
type="primary"
|
||||||
|
@click="bindContractProduct(record)"
|
||||||
|
>绑定气瓶</a-button>
|
||||||
<a-tooltip v-if="canEdit" :content="recordEditReason(record) || '编辑记录'">
|
<a-tooltip v-if="canEdit" :content="recordEditReason(record) || '编辑记录'">
|
||||||
<a-button size="mini" :disabled="Boolean(recordEditReason(record))" @click="openRecord('edit', record)">编辑</a-button>
|
<a-button size="mini" :disabled="Boolean(recordEditReason(record))" @click="openRecord('edit', record)">编辑</a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
@@ -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() {
|
function returnManagedList() {
|
||||||
const target = safeReturnPath(route.query.return_to);
|
const target = safeReturnPath(route.query.return_to);
|
||||||
return router.push(target || { name: String(route.meta.activeMenu ?? '') });
|
return router.push(target || { name: String(route.meta.activeMenu ?? '') });
|
||||||
|
|||||||
Reference in New Issue
Block a user