完善合同气瓶绑定约束与候选过滤
- 明确合同状态、气瓶归属、停用、报废和重复绑定失败原因 - 根据当前合同过滤可绑定气瓶,排除无效及重复候选项 - 从合同列表进入绑定页时锁定预填合同,防止切换上下文 - 切换通用新建页合同后清空旧气瓶并重新加载候选项 - 增加前后端回归检查并同步中文操作日志
This commit is contained in:
@@ -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 验证关键启用失败原因保持清晰中文。
|
// TestContractActivationErrorMessages 验证关键启用失败原因保持清晰中文。
|
||||||
func TestContractActivationErrorMessages(t *testing.T) {
|
func TestContractActivationErrorMessages(t *testing.T) {
|
||||||
want := map[error]string{
|
want := map[error]string{
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ var (
|
|||||||
errContractNotEffective = errors.New("合同尚未到生效时间,暂时不能启用")
|
errContractNotEffective = errors.New("合同尚未到生效时间,暂时不能启用")
|
||||||
errContractExpired = errors.New("合同已到期,请调整到期时间或续签后再启用")
|
errContractExpired = errors.New("合同已到期,请调整到期时间或续签后再启用")
|
||||||
errContractNoProduct = 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{}) }
|
func ListGasorderContract(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderContract{}) }
|
||||||
@@ -424,14 +429,36 @@ func BindGasorderContractProduct(ctx *gin.Context) {
|
|||||||
common.RespondRecordError(ctx, err)
|
common.RespondRecordError(ctx, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if contract.ContractStatus != common.StatusDraft && contract.ContractStatus != common.StatusActive {
|
if contract.ContractStatus != common.StatusDraft {
|
||||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
infra.Response.Error(ctx, errBindingContractStatus)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var product models.ProductInfo
|
var product models.ProductInfo
|
||||||
if err := impl.DBService.Where("identity = ?", request.ProductIdentity).First(&product).Error; err != nil ||
|
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 {
|
common.RespondRecordError(ctx, err)
|
||||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
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
|
return
|
||||||
}
|
}
|
||||||
var productType models.ProductType
|
var productType models.ProductType
|
||||||
|
|||||||
@@ -28,12 +28,31 @@ var productLifecycleStatuses = map[int]bool{
|
|||||||
|
|
||||||
func ProductInfoHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
|
func ProductInfoHandlers(relations ...common.ResourceRelation) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) {
|
||||||
fields := []string{"code", "name", "params", "produced_at", "action", "reason", "remark"}
|
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) { createProductInfo(ctx, fields, relations) },
|
||||||
func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductInfo{}) },
|
func(ctx *gin.Context) { common.GetResource(ctx, &models.ProductInfo{}) },
|
||||||
func(ctx *gin.Context) { updateProductInfo(ctx, fields, relations) }
|
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) {
|
func createProductInfo(ctx *gin.Context, fields []string, relations []common.ResourceRelation) {
|
||||||
values, err := common.PrepareResourceValues(ctx, &models.ProductInfo{}, fields, relations)
|
values, err := common.PrepareResourceValues(ctx, &models.ProductInfo{}, fields, relations)
|
||||||
if err != nil || !validParamsText(values["params"]) {
|
if err != nil || !validParamsText(values["params"]) {
|
||||||
|
|||||||
@@ -14,11 +14,16 @@
|
|||||||
- 终止非生效合同也返回明确状态原因。
|
- 终止非生效合同也返回明确状态原因。
|
||||||
- 仅透传预定义业务错误;数据库及其他内部异常继续使用通用参数错误,避免泄露内部信息。
|
- 仅透传预定义业务错误;数据库及其他内部异常继续使用通用参数错误,避免泄露内部信息。
|
||||||
- 增加业务错误白名单和中文消息回归测试。
|
- 增加业务错误白名单和中文消息回归测试。
|
||||||
|
- 明确区分绑定气瓶时的合同状态、气瓶停用、气瓶报废、用户归属不一致和重复绑定原因。
|
||||||
|
- 后端同步限制仅草稿合同允许绑定,与列表按钮可见规则保持一致。
|
||||||
|
- 合同气瓶表单按当前合同过滤候选项,仅展示合同用户所属、已启用、未报废且未在该合同重复绑定的气瓶。
|
||||||
|
- 切换合同时清空原气瓶并重新加载候选项;后端业务校验继续作为安全兜底。
|
||||||
|
|
||||||
## 行为变化
|
## 行为变化
|
||||||
|
|
||||||
- 变更前:所有启用前置条件失败均提示“请求参数不正确,请检查填写内容”。
|
- 变更前:所有启用前置条件失败均提示“请求参数不正确,请检查填写内容”。
|
||||||
- 变更后:页面直接显示具体失败项及处理建议,例如“合同尚未绑定有效气瓶,请先绑定气瓶后再启用”。
|
- 变更后:页面直接显示具体失败项及处理建议,例如“合同尚未绑定有效气瓶,请先绑定气瓶后再启用”。
|
||||||
|
- 绑定失败时会提示具体处理原因,例如“所选气瓶不属于该合同用户,请选择合同用户所属的气瓶”。
|
||||||
|
|
||||||
## 代码变更
|
## 代码变更
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
- 在配送合同列表的操作列增加“绑定气瓶”按钮,仅草稿状态(`contract_status=0`)显示。
|
- 在配送合同列表的操作列增加“绑定气瓶”按钮,仅草稿状态(`contract_status=0`)显示。
|
||||||
- 合同已启用或处于其他非草稿状态时隐藏绑定按钮,详情页不再展示该入口。
|
- 合同已启用或处于其他非草稿状态时隐藏绑定按钮,详情页不再展示该入口。
|
||||||
- 跳转合同气瓶新建页时,通过既有关系预填协议带入当前合同唯一标识。
|
- 跳转合同气瓶新建页时,通过既有关系预填协议带入当前合同唯一标识。
|
||||||
|
- 从合同列表行进入时锁定预填合同,以可读名称展示且禁止切换到其他合同;直接进入通用新建页时仍允许选择合同。
|
||||||
- 携带当前详情页返回地址,使创建完成后可回到原合同。
|
- 携带当前详情页返回地址,使创建完成后可回到原合同。
|
||||||
- 保留合同气瓶隐藏子路由、后端接口及权限模型,不改变现有公共接口。
|
- 保留合同气瓶隐藏子路由、后端接口及权限模型,不改变现有公共接口。
|
||||||
- 增加静态回归检查,固定入口展示范围、目标路由、合同预填字段和返回路径。
|
- 增加静态回归检查,固定入口展示范围、目标路由、合同预填字段和返回路径。
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"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",
|
"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-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",
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ const detailSource = readFileSync(
|
|||||||
new URL('../src/views/resource/ResourceRecordPage.vue', import.meta.url),
|
new URL('../src/views/resource/ResourceRecordPage.vue', import.meta.url),
|
||||||
'utf8',
|
'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, /definition\.name === 'gasorder_contract' && Number\(record\.contract_status\) === 0/, '绑定入口必须仅展示在草稿合同列表行');
|
||||||
assert.match(listSource, />\s*绑定气瓶\s*</, '草稿合同操作列必须展示“绑定气瓶”按钮');
|
assert.match(listSource, />\s*绑定气瓶\s*</, '草稿合同操作列必须展示“绑定气瓶”按钮');
|
||||||
@@ -21,5 +25,7 @@ assert.match(listSource, /relation_key: 'gasorder_contract_identity'/, '绑定
|
|||||||
assert.match(listSource, /owner_identity: String\(row\.identity \?\? ''\)/, '绑定入口必须预填所选合同唯一标识');
|
assert.match(listSource, /owner_identity: String\(row\.identity \?\? ''\)/, '绑定入口必须预填所选合同唯一标识');
|
||||||
assert.match(listSource, /return_to: route\.fullPath/, '绑定完成后必须支持返回合同列表');
|
assert.match(listSource, /return_to: route\.fullPath/, '绑定完成后必须支持返回合同列表');
|
||||||
assert.doesNotMatch(detailSource, />\s*绑定气瓶\s*</, '合同详情页不应继续展示绑定按钮');
|
assert.doesNotMatch(detailSource, />\s*绑定气瓶\s*</, '合同详情页不应继续展示绑定按钮');
|
||||||
|
assert.match(detailSource, /definition\.value\.name === 'gasorder_contract_product'[\s\S]*field\.key === 'gasorder_contract_identity'[\s\S]*route\.query\.relation_key === 'gasorder_contract_identity'[\s\S]*Boolean\(route\.query\.owner_identity\)/, '从合同列表进入时必须锁定预填合同');
|
||||||
|
assert.match(resourcesSource, /readonlyRelationText: true, displayRelationLabel: true/, '锁定合同必须以可读名称展示');
|
||||||
|
|
||||||
console.log('合同气瓶绑定入口检查通过:仅草稿合同列表行可见,已启用合同和详情页均隐藏。');
|
console.log('合同气瓶绑定入口检查通过:仅草稿合同列表行可见,进入后锁定所选合同。');
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 功能:检查合同气瓶表单按当前合同加载可绑定气瓶。
|
||||||
|
* 版本:v1.0.0
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const resources = readFileSync(new URL('../src/api/resources.ts', import.meta.url), 'utf8');
|
||||||
|
const linkage = readFileSync(new URL('../src/views/resource/use-resource-relation-linkage.ts', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
assert.match(resources, /filterKey: 'contract_identity', filterOnly: true/, '气瓶字段必须按当前合同筛选');
|
||||||
|
assert.match(resources, /请先选择合同,再选择该合同用户可绑定的气瓶/, '气瓶字段必须解释筛选范围');
|
||||||
|
assert.match(linkage, /if \(linkage\.filterOnly\) return ''/, '纯筛选联动不得执行父标识校验');
|
||||||
|
|
||||||
|
console.log('合同气瓶候选过滤检查通过:气瓶下拉按当前合同加载可绑定项。');
|
||||||
@@ -31,6 +31,8 @@ export type ResourceRelationLinkage = {
|
|||||||
optionParentKey: string;
|
optionParentKey: string;
|
||||||
filterKey: string;
|
filterKey: string;
|
||||||
backfillParent?: boolean;
|
backfillParent?: boolean;
|
||||||
|
/** 仅使用父字段筛选候选项,不校验候选项中的父标识。 */
|
||||||
|
filterOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResourceField = {
|
export type ResourceField = {
|
||||||
@@ -468,7 +470,15 @@ export const resources: ResourceUiDefinition[] = [
|
|||||||
{ name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12] } },
|
{ name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12] } },
|
||||||
{ name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } },
|
{ name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } },
|
||||||
], { canCreate: true, canEdit: true }),
|
], { canCreate: true, canEdit: true }),
|
||||||
define('gasorder_contract_product', '合同气瓶', 'append_only', [relation('gasorder_contract_identity', '/gasorder_contract', true), relation('product_info_identity', '/product_info', true), f('unit_price')], 'list', [
|
define('gasorder_contract_product', '合同气瓶', 'append_only', [relation('gasorder_contract_identity', '/gasorder_contract', true, {
|
||||||
|
readonlyRelationText: true, displayRelationLabel: true,
|
||||||
|
}), relation('product_info_identity', '/product_info', true, {
|
||||||
|
placeholder: '请先选择合同,再选择该合同用户可绑定的气瓶',
|
||||||
|
relationLinkage: {
|
||||||
|
parentKey: 'gasorder_contract_identity', optionParentKey: '',
|
||||||
|
filterKey: 'contract_identity', filterOnly: true,
|
||||||
|
},
|
||||||
|
}), f('unit_price')], 'list', [
|
||||||
{ name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason },
|
{ name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason },
|
||||||
]),
|
]),
|
||||||
define('gasorder_contract_revision', '合同修订记录', 'readonly', []),
|
define('gasorder_contract_revision', '合同修订记录', 'readonly', []),
|
||||||
|
|||||||
@@ -301,7 +301,11 @@ const readonlyKeys = computed(() =>
|
|||||||
.filter(
|
.filter(
|
||||||
(field) =>
|
(field) =>
|
||||||
field.readonlyOnCreate ||
|
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)
|
.map((field) => field.key)
|
||||||
: mode.value === 'edit'
|
: mode.value === 'edit'
|
||||||
|
|||||||
@@ -162,7 +162,11 @@ export function useResourceRelationLinkage(
|
|||||||
return;
|
return;
|
||||||
const linkage = active.childField.relationLinkage;
|
const linkage = active.childField.relationLinkage;
|
||||||
const childIdentity = relationIdentity(form[active.childField.key]);
|
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 =
|
const option =
|
||||||
findOption(active.childField.relation, childIdentity) ??
|
findOption(active.childField.relation, childIdentity) ??
|
||||||
(await relations.ensure(active.childField.relation, childIdentity));
|
(await relations.ensure(active.childField.relation, childIdentity));
|
||||||
@@ -187,6 +191,7 @@ export function useResourceRelationLinkage(
|
|||||||
return;
|
return;
|
||||||
const linkage = active.childField.relationLinkage;
|
const linkage = active.childField.relationLinkage;
|
||||||
if (!childIdentity) return;
|
if (!childIdentity) return;
|
||||||
|
if (linkage.filterOnly) return;
|
||||||
const option =
|
const option =
|
||||||
findOption(active.childField.relation, childIdentity) ??
|
findOption(active.childField.relation, childIdentity) ??
|
||||||
(await relations.ensure(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)
|
if (!active?.childField.relationLinkage || !active.childField.relation)
|
||||||
return '';
|
return '';
|
||||||
const linkage = active.childField.relationLinkage;
|
const linkage = active.childField.relationLinkage;
|
||||||
|
if (linkage.filterOnly) return '';
|
||||||
const parentIdentity = relationIdentity(form[linkage.parentKey]);
|
const parentIdentity = relationIdentity(form[linkage.parentKey]);
|
||||||
const childIdentity = relationIdentity(form[active.childField.key]);
|
const childIdentity = relationIdentity(form[active.childField.key]);
|
||||||
const option = findOption(active.childField.relation, childIdentity);
|
const option = findOption(active.childField.relation, childIdentity);
|
||||||
|
|||||||
Reference in New Issue
Block a user