修复配送订单创建方合同约束
This commit is contained in:
@@ -34,6 +34,11 @@ var (
|
|||||||
errBindingProductScrapped = errors.New("所选气瓶已报废,不能绑定到合同")
|
errBindingProductScrapped = errors.New("所选气瓶已报废,不能绑定到合同")
|
||||||
errBindingProductOwner = errors.New("所选气瓶不属于该合同用户,请选择合同用户所属的气瓶")
|
errBindingProductOwner = errors.New("所选气瓶不属于该合同用户,请选择合同用户所属的气瓶")
|
||||||
errBindingProductDuplicate = errors.New("所选气瓶已绑定到该合同,请勿重复绑定")
|
errBindingProductDuplicate = errors.New("所选气瓶已绑定到该合同,请勿重复绑定")
|
||||||
|
errOrderCreatorUser = errors.New("创建方必须是配送合同的签约用户")
|
||||||
|
errOrderCreatorGas = errors.New("创建方必须是配送合同的签约气站")
|
||||||
|
errOrderCreatorNoDelivery = errors.New("所选合同未指定配送点,不能以配送点作为创建方")
|
||||||
|
errOrderCreatorDelivery = errors.New("创建方必须是配送合同指定的配送点")
|
||||||
|
errOrderCreatorStaff = errors.New("所选工作人员不属于配送合同的履约组织或账号未启用")
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListGasorderContract 按明确用途收窄合同候选;合同管理列表不带用途参数时保持全量查询。
|
// ListGasorderContract 按明确用途收窄合同候选;合同管理列表不带用途参数时保持全量查询。
|
||||||
@@ -588,11 +593,16 @@ func CreateGasorderBasic(ctx *gin.Context) {
|
|||||||
if !contractEligibleForOrder(contract, now) {
|
if !contractEligibleForOrder(contract, now) {
|
||||||
return errors.New("contract is not active")
|
return errors.New("contract is not active")
|
||||||
}
|
}
|
||||||
if request.CreatorType == "user" && creatorID != contract.UserAccountID {
|
var creatorStaff *models.StaffAccount
|
||||||
return errors.New("user cannot create an order for another user")
|
if request.CreatorType == "staff" {
|
||||||
|
var staff models.StaffAccount
|
||||||
|
if err := tx.Where("id = ? AND status = ?", creatorID, common.StatusEnable).First(&staff).Error; err != nil {
|
||||||
|
return errOrderCreatorStaff
|
||||||
}
|
}
|
||||||
if request.CreatorType == "gas" && creatorID != contract.GasBasicID {
|
creatorStaff = &staff
|
||||||
return errors.New("gas station does not own the contract")
|
}
|
||||||
|
if err := gasorderCreatorValidationError(contract, request.CreatorType, creatorID, creatorStaff); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
var address models.UserAddress
|
var address models.UserAddress
|
||||||
if err := tx.Where("identity = ? AND user_account_id = ?", request.UserAddressIdentity, contract.UserAccountID).First(&address).Error; err != nil {
|
if err := tx.Where("identity = ? AND user_account_id = ?", request.UserAddressIdentity, contract.UserAccountID).First(&address).Error; err != nil {
|
||||||
@@ -659,12 +669,60 @@ func CreateGasorderBasic(ctx *gin.Context) {
|
|||||||
common.RespondCreatedResource(ctx, order)
|
common.RespondCreatedResource(ctx, order)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if isGasorderCreatorBusinessError(err) {
|
||||||
|
infra.Response.Error(ctx, err)
|
||||||
|
} else {
|
||||||
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
common.RespondCreatedResource(ctx, order)
|
common.RespondCreatedResource(ctx, order)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gasorderCreatorValidationError 校验平台代建订单中的业务创建方必须属于当前配送合同。
|
||||||
|
// 参数:contract 为当前生效合同,creatorType/creatorID 为提交的创建方,staff 仅在工作人员类型时传入。
|
||||||
|
// 返回值:组合合法返回 nil,否则返回可安全展示的中文业务错误。
|
||||||
|
func gasorderCreatorValidationError(contract models.GasorderContract, creatorType string, creatorID uint64, staff *models.StaffAccount) error {
|
||||||
|
switch creatorType {
|
||||||
|
case "user":
|
||||||
|
if creatorID != contract.UserAccountID {
|
||||||
|
return errOrderCreatorUser
|
||||||
|
}
|
||||||
|
case "gas":
|
||||||
|
if creatorID != contract.GasBasicID {
|
||||||
|
return errOrderCreatorGas
|
||||||
|
}
|
||||||
|
case "delivery":
|
||||||
|
if contract.DeliveryBasicID == 0 {
|
||||||
|
return errOrderCreatorNoDelivery
|
||||||
|
}
|
||||||
|
if creatorID != contract.DeliveryBasicID {
|
||||||
|
return errOrderCreatorDelivery
|
||||||
|
}
|
||||||
|
case "staff":
|
||||||
|
if staff == nil || staff.ID != creatorID || staff.Status != common.StatusEnable || staff.GasBasicID != contract.GasBasicID {
|
||||||
|
return errOrderCreatorStaff
|
||||||
|
}
|
||||||
|
if contract.DeliveryBasicID > 0 {
|
||||||
|
if staff.DeliveryBasicID != contract.DeliveryBasicID {
|
||||||
|
return errOrderCreatorStaff
|
||||||
|
}
|
||||||
|
} else if staff.DeliveryBasicID != 0 {
|
||||||
|
return errOrderCreatorStaff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isGasorderCreatorBusinessError 限制仅透传预定义创建方错误,避免数据库细节泄露给前端。
|
||||||
|
func isGasorderCreatorBusinessError(err error) bool {
|
||||||
|
return errors.Is(err, errOrderCreatorUser) ||
|
||||||
|
errors.Is(err, errOrderCreatorGas) ||
|
||||||
|
errors.Is(err, errOrderCreatorNoDelivery) ||
|
||||||
|
errors.Is(err, errOrderCreatorDelivery) ||
|
||||||
|
errors.Is(err, errOrderCreatorStaff)
|
||||||
|
}
|
||||||
|
|
||||||
// contractEligibleForOrder 校验合同当前确实处于可履约时间窗口。
|
// contractEligibleForOrder 校验合同当前确实处于可履约时间窗口。
|
||||||
func contractEligibleForOrder(contract models.GasorderContract, now time.Time) bool {
|
func contractEligibleForOrder(contract models.GasorderContract, now time.Time) bool {
|
||||||
return contract.ContractStatus == common.StatusActive &&
|
return contract.ContractStatus == common.StatusActive &&
|
||||||
|
|||||||
@@ -110,6 +110,58 @@ func TestGasorderCreatorTypesCoverEveryConfirmedOrigin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGasorderCreatorValidationError 验证四类创建方均被严格限制在当前配送合同履约范围内。
|
||||||
|
func TestGasorderCreatorValidationError(t *testing.T) {
|
||||||
|
contract := models.GasorderContract{UserAccountID: 10, GasBasicID: 20, DeliveryBasicID: 30}
|
||||||
|
validStaff := &models.StaffAccount{
|
||||||
|
Entity: models.Entity{ID: 40, Status: common.StatusEnable},
|
||||||
|
GasBasicID: 20, DeliveryBasicID: 30,
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
contract models.GasorderContract
|
||||||
|
creatorType string
|
||||||
|
creatorID uint64
|
||||||
|
staff *models.StaffAccount
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"合同用户", contract, "user", 10, nil, nil},
|
||||||
|
{"跨合同用户", contract, "user", 11, nil, errOrderCreatorUser},
|
||||||
|
{"合同气站", contract, "gas", 20, nil, nil},
|
||||||
|
{"跨合同气站", contract, "gas", 21, nil, errOrderCreatorGas},
|
||||||
|
{"合同配送点", contract, "delivery", 30, nil, nil},
|
||||||
|
{"跨合同配送点", contract, "delivery", 31, nil, errOrderCreatorDelivery},
|
||||||
|
{"合同未指定配送点", models.GasorderContract{UserAccountID: 10, GasBasicID: 20}, "delivery", 30, nil, errOrderCreatorNoDelivery},
|
||||||
|
{"合同配送点工作人员", contract, "staff", 40, validStaff, nil},
|
||||||
|
{"跨配送点工作人员", contract, "staff", 40, &models.StaffAccount{Entity: models.Entity{ID: 40, Status: common.StatusEnable}, GasBasicID: 20, DeliveryBasicID: 31}, errOrderCreatorStaff},
|
||||||
|
{"停用工作人员", contract, "staff", 40, &models.StaffAccount{Entity: models.Entity{ID: 40, Status: common.StatusDisable}, GasBasicID: 20, DeliveryBasicID: 30}, errOrderCreatorStaff},
|
||||||
|
{"气站直属工作人员", models.GasorderContract{UserAccountID: 10, GasBasicID: 20}, "staff", 40, &models.StaffAccount{Entity: models.Entity{ID: 40, Status: common.StatusEnable}, GasBasicID: 20}, nil},
|
||||||
|
{"无配送点合同拒绝下属配送点人员", models.GasorderContract{UserAccountID: 10, GasBasicID: 20}, "staff", 40, validStaff, errOrderCreatorStaff},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := gasorderCreatorValidationError(test.contract, test.creatorType, test.creatorID, test.staff); got != test.want {
|
||||||
|
t.Fatalf("创建方校验结果 = %v,期望 %v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGasorderCreatorBusinessErrorsAreSafe 验证仅预定义中文创建方错误允许返回客户端。
|
||||||
|
func TestGasorderCreatorBusinessErrorsAreSafe(t *testing.T) {
|
||||||
|
for _, businessError := range []error{
|
||||||
|
errOrderCreatorUser, errOrderCreatorGas, errOrderCreatorNoDelivery,
|
||||||
|
errOrderCreatorDelivery, errOrderCreatorStaff,
|
||||||
|
} {
|
||||||
|
if !isGasorderCreatorBusinessError(businessError) {
|
||||||
|
t.Fatalf("创建方业务错误未被识别:%v", businessError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isGasorderCreatorBusinessError(gorm.ErrInvalidDB) {
|
||||||
|
t.Fatal("数据库错误不应透传给客户端")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnbindWritesIntegerGenericStatusAndReason(t *testing.T) {
|
func TestUnbindWritesIntegerGenericStatusAndReason(t *testing.T) {
|
||||||
updates := gasorderUnbindUpdates(" contract ended ", time.Now())
|
updates := gasorderUnbindUpdates(" contract ended ", time.Now())
|
||||||
status, ok := updates["status"].(int)
|
status, ok := updates["status"].(int)
|
||||||
|
|||||||
@@ -202,7 +202,7 @@
|
|||||||
|
|
||||||
创建订单时必须提交唯一请求号、有效合同、创建方、用户地址、合同气瓶集合、联系人和联系方式。后端根据合同价格和默认配送费计算订单金额,不接受前端自行决定最终金额。
|
创建订单时必须提交唯一请求号、有效合同、创建方、用户地址、合同气瓶集合、联系人和联系方式。后端根据合同价格和默认配送费计算订单金额,不接受前端自行决定最终金额。
|
||||||
|
|
||||||
平台创建订单时,创建方类型支持用户、工作人员、配送站和气站。创建方候选必须根据已选类型分别加载用户账户、工作人员账户、配送站或气站;未选择类型时禁用创建方字段,切换类型时清空原创建方并重新加载候选。候选以用户名或机构名称等业务名称展示,不直接要求管理员识别唯一标识。
|
平台创建订单时,创建方类型支持用户、工作人员、配送站和气站,但创建方必须受所选配送合同约束。用户、气站和合同指定的配送站由合同自动带出并只读展示;合同未指定配送站时不得选择配送站创建方。工作人员候选仅包含启用账号:合同指定配送站时限定为该气站、该配送站的工作人员,合同未指定配送站时限定为该气站直属工作人员。切换创建方类型或配送合同时必须清空旧创建方并重新匹配;切换合同还必须清空旧地址和合同气瓶。候选和自动带出值均以业务名称展示,不要求管理员识别唯一标识。服务端必须执行相同的合同归属校验,并对跨合同主体返回明确中文错误。
|
||||||
|
|
||||||
创建订单页面必须先选择配送合同,收货地址下拉仅展示该合同签约用户的地址。地址选项以完整地址为主文案并标记默认地址;存在默认地址时自动选中,切换合同时清空原地址。若合同用户没有可用地址,页面应明确引导先维护用户地址并禁止提交。
|
创建订单页面必须先选择配送合同,收货地址下拉仅展示该合同签约用户的地址。地址选项以完整地址为主文案并标记默认地址;存在默认地址时自动选中,切换合同时清空原地址。若合同用户没有可用地址,页面应明确引导先维护用户地址并禁止提交。
|
||||||
|
|
||||||
|
|||||||
62
docs/项目文档_配送订单创建方合同约束_v1.1.md
Normal file
62
docs/项目文档_配送订单创建方合同约束_v1.1.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# 配送订单创建方合同约束 v1.1
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
本次修复平台总后台创建气体配送订单时,创建方候选与配送合同相互独立的问题。旧实现允许管理员选择任意用户、气站、配送点或工作人员,错误组合直到提交后才收到泛化参数错误;配送点和工作人员还缺少完整的服务端合同归属校验。
|
||||||
|
|
||||||
|
## 2. 业务规则
|
||||||
|
|
||||||
|
- 用户创建方只能是合同签约用户,由合同自动带出并只读展示。
|
||||||
|
- 气站创建方只能是合同签约气站,由合同自动带出并只读展示。
|
||||||
|
- 配送点创建方只能是合同指定配送点;合同未指定配送点时禁止使用该类型。
|
||||||
|
- 合同指定配送点时,工作人员候选仅包含同时属于合同气站、合同配送点的启用人员。
|
||||||
|
- 合同未指定配送点时,工作人员候选仅包含合同气站直属的启用人员。
|
||||||
|
- 切换配送合同时,收货地址、合同气瓶和原创建方全部清空;保留创建方类型并按新合同重新匹配。
|
||||||
|
- 平台登录管理员是操作人,不自动作为订单业务创建方。
|
||||||
|
- 前端候选限制与后端最终校验同时生效,伪造请求不能绕过。
|
||||||
|
- 本次不修改数据库结构和业务数据。
|
||||||
|
|
||||||
|
## 3. 核心文件
|
||||||
|
|
||||||
|
- `frontend/platform_admin/src/api/resources.ts`:声明合同上下文、固定主体映射和工作人员组织筛选规则。
|
||||||
|
- `frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts`:负责合同切换、主体自动带出、工作人员候选过滤和提交前校验。
|
||||||
|
- `frontend/platform_admin/src/views/resource/ResourceFieldForm.vue`:在合同或类型未选择时禁用创建方并展示操作引导。
|
||||||
|
- `frontend/platform_admin/src/views/resource/ResourceRecordPage.vue`:展示合同约束说明,并将合同直接确定的主体设为只读。
|
||||||
|
- `backend/api/internal/logic/platform/gasorder/gasorder.go`:校验四类创建方与合同的归属关系,并返回安全的中文业务错误。
|
||||||
|
- `backend/api/internal/logic/platform/gasorder/gasorder_test.go`:覆盖合法组合、跨合同主体、无配送点合同、直属工作人员和停用工作人员。
|
||||||
|
|
||||||
|
## 4. 行为变化
|
||||||
|
|
||||||
|
| 场景 | 修改前 | 修改后 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 用户、气站创建方 | 可从全平台候选中选择 | 合同自动带出,只读 |
|
||||||
|
| 配送点创建方 | 可选择任意配送点 | 仅合同指定配送点;未指定时禁止 |
|
||||||
|
| 工作人员创建方 | 可选择任意工作人员 | 仅合同履约组织内的启用人员 |
|
||||||
|
| 切换合同 | 地址、气瓶联动,创建方不联动 | 地址、气瓶、创建方全部重新匹配 |
|
||||||
|
| 伪造跨合同请求 | 用户和气站部分拦截,配送点和人员缺口 | 四种类型均由服务端拒绝 |
|
||||||
|
| 错误提示 | 请求参数不正确 | 返回具体的合同归属中文原因 |
|
||||||
|
|
||||||
|
## 5. 操作日志
|
||||||
|
|
||||||
|
操作时间:2026-08-14
|
||||||
|
|
||||||
|
操作类型:修复、扩展
|
||||||
|
|
||||||
|
影响模块:平台总后台、气体配送订单创建接口
|
||||||
|
|
||||||
|
操作前状态:创建方仅按类型加载全平台候选;合同不参与筛选;配送点和工作人员缺少服务端合同一致性校验。
|
||||||
|
|
||||||
|
具体操作:扩展动态关系配置以支持合同上下文、固定主体和组织筛选;补充创建页说明、只读展示及切换清理;新增后端四类创建方校验和中文业务错误;增加专项检查与单元测试。
|
||||||
|
|
||||||
|
操作后状态:创建方与配送合同保持一致,错误组合无法在正常页面选中,伪造请求也会被后端拒绝。
|
||||||
|
|
||||||
|
验证结果:执行前端创建方专项检查、TypeScript 类型检查、生产构建及后端 gasorder 单元测试;详细结果以本次提交记录为准。
|
||||||
|
|
||||||
|
风险评估:动态合同约束仅在显式配置的订单创建方字段启用,其他动态关系保持原行为;数据库和公共接口字段保持兼容。
|
||||||
|
|
||||||
|
## 6. 维护指南
|
||||||
|
|
||||||
|
- 新增创建方类型时,必须同时声明前端候选来源、合同归属规则和后端校验。
|
||||||
|
- 不得只依赖前端过滤,所有组织归属规则必须在服务端重复校验。
|
||||||
|
- 合同模型新增履约主体时,应同步检查创建方、地址和合同气瓶的切换清理逻辑。
|
||||||
|
- 中文业务错误只允许透传预定义错误,数据库错误继续使用通用脱敏响应。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 功能:静态检查配送订单创建方类型与业务主体候选的动态联动。
|
* 功能:静态检查配送订单创建方类型与业务主体候选的动态联动。
|
||||||
* 版本:v1.0.0
|
* 版本:v1.1.0
|
||||||
*/
|
*/
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
@@ -10,6 +10,7 @@ const form = readFileSync(new URL('../src/views/resource/ResourceFieldForm.vue',
|
|||||||
const linkage = readFileSync(new URL('../src/views/resource/use-resource-relation-linkage.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, /parentKey: 'creator_type'/, '创建方必须由创建方类型驱动');
|
assert.match(resources, /parentKey: 'creator_type'/, '创建方必须由创建方类型驱动');
|
||||||
|
assert.match(resources, /contextParentKey: 'gasorder_contract_identity'/, '创建方必须同时受配送合同约束');
|
||||||
for (const mapping of [
|
for (const mapping of [
|
||||||
"user: '/user_account'",
|
"user: '/user_account'",
|
||||||
"staff: '/staff_account'",
|
"staff: '/staff_account'",
|
||||||
@@ -19,10 +20,23 @@ for (const mapping of [
|
|||||||
assert.ok(resources.includes(mapping), `创建方动态资源缺少映射:${mapping}`);
|
assert.ok(resources.includes(mapping), `创建方动态资源缺少映射:${mapping}`);
|
||||||
}
|
}
|
||||||
assert.match(resources, /label: '创建方'/, '创建方字段必须使用业务名称');
|
assert.match(resources, /label: '创建方'/, '创建方字段必须使用业务名称');
|
||||||
assert.match(resources, /创建方类型已变更,请重新选择创建方/, '切换类型时缺少清空提示');
|
for (const fixedIdentity of [
|
||||||
assert.match(form, /field\.dynamicRelation && !relationResource\(field\)/, '未选择类型时必须禁用创建方');
|
"user: 'user_account_identity'",
|
||||||
|
"gas: 'gas_basic_identity'",
|
||||||
|
"delivery: 'delivery_basic_identity'",
|
||||||
|
]) {
|
||||||
|
assert.ok(resources.includes(fixedIdentity), `合同固定创建方缺少映射:${fixedIdentity}`);
|
||||||
|
}
|
||||||
|
assert.match(resources, /delivery_basic_unassigned/, '无配送点合同时工作人员必须限定为气站直属人员');
|
||||||
|
assert.match(resources, /所选合同未指定配送点,不能以配送点作为创建方/, '合同缺少配送点时必须给出明确提示');
|
||||||
|
assert.match(resources, /创建方类型已变更,已按当前合同重新匹配创建方/, '切换类型时缺少重新匹配提示');
|
||||||
|
assert.match(resources, /relationFilters: \{ status: '1' \}/, '工作人员候选必须限定为启用状态');
|
||||||
|
assert.match(form, /field\.dynamicRelation[\s\S]*?contextParentKey/, '未选择合同时必须禁用创建方');
|
||||||
assert.match(form, /v-else-if="field\.type === 'select'"[\s\S]*?emit\('change-relation'/, '普通选择框未触发动态联动');
|
assert.match(form, /v-else-if="field\.type === 'select'"[\s\S]*?emit\('change-relation'/, '普通选择框未触发动态联动');
|
||||||
assert.match(linkage, /reloadDynamicOptions/, '动态关系候选未接入加载流程');
|
assert.match(linkage, /reloadDynamicOptions/, '动态关系候选未接入加载流程');
|
||||||
|
assert.match(linkage, /fixedIdentityKeys/, '合同唯一创建方未接入自动带出');
|
||||||
|
assert.match(linkage, /scopeFilters/, '工作人员候选未接入合同组织筛选');
|
||||||
|
assert.match(linkage, /contextDynamicChildren/, '切换合同时未重新匹配创建方');
|
||||||
assert.match(linkage, /form\[childField\.key\] = ''/, '切换类型时未清空旧创建方');
|
assert.match(linkage, /form\[childField\.key\] = ''/, '切换类型时未清空旧创建方');
|
||||||
|
|
||||||
console.log('配送订单创建方检查通过:四类创建方按类型动态加载,切换类型会清空旧值。');
|
console.log('配送订单创建方检查通过:合同主体自动带出,工作人员按履约组织筛选,切换合同会重新匹配。');
|
||||||
|
|||||||
@@ -45,6 +45,21 @@ export type ResourceDynamicRelation = {
|
|||||||
parentLabel: string;
|
parentLabel: string;
|
||||||
resources: Record<string, string>;
|
resources: Record<string, string>;
|
||||||
parentChangeMessage?: string;
|
parentChangeMessage?: string;
|
||||||
|
/** 额外业务上下文,例如配送合同;上下文变化时重新限定动态候选。 */
|
||||||
|
contextParentKey?: string;
|
||||||
|
/** 按类型从上下文选项直接取得唯一主体,适用于合同用户、气站和配送点。 */
|
||||||
|
fixedIdentityKeys?: Record<string, string>;
|
||||||
|
/** 上下文缺少固定主体时展示的业务提示。 */
|
||||||
|
missingIdentityMessages?: Record<string, string>;
|
||||||
|
/** 按类型把上下文字段转换为服务端精确筛选参数。 */
|
||||||
|
scopeFilters?: Record<string, Array<{
|
||||||
|
filterKey: string;
|
||||||
|
optionKey: string;
|
||||||
|
required?: boolean;
|
||||||
|
emptyFilterKey?: string;
|
||||||
|
emptyFilterValue?: string;
|
||||||
|
}>>;
|
||||||
|
contextChangeMessage?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResourceField = {
|
export type ResourceField = {
|
||||||
@@ -533,7 +548,18 @@ export const resources: ResourceUiDefinition[] = [
|
|||||||
{ 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', []),
|
||||||
define('gasorder_basic', '气体配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true, { label: '配送合同', placeholder: '请选择当前可履约的生效合同', relationFilters: { candidate: 'order' }, relationStrictFilter: true, relationInvalidMessage: '所选配送合同已不可履约,已清空,请重新选择', relationEmptyText: '暂无可下单的生效合同,请先启用或续签配送合同' }), f('creator_type', { required: true, type: 'select', options: resourceSearchEnumOptions('gasorder_basic', 'creator_type') }), f('creator_identity', { label: '创建方', required: true, placeholder: '请选择创建方', relationEmptyText: '当前类型暂无可用的创建方数据', dynamicRelation: { parentKey: 'creator_type', parentLabel: '创建方类型', resources: { user: '/user_account', staff: '/staff_account', delivery: '/delivery_basic', gas: '/gas_basic' }, parentChangeMessage: '创建方类型已变更,请重新选择创建方' } }), relation('user_address_identity', '/user_address', true, {
|
define('gasorder_basic', '气体配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true, { label: '配送合同', placeholder: '请选择当前可履约的生效合同', relationFilters: { candidate: 'order' }, relationStrictFilter: true, relationInvalidMessage: '所选配送合同已不可履约,已清空,请重新选择', relationEmptyText: '暂无可下单的生效合同,请先启用或续签配送合同' }), f('creator_type', { required: true, type: 'select', options: resourceSearchEnumOptions('gasorder_basic', 'creator_type') }), f('creator_identity', { label: '创建方', required: true, placeholder: '请先选择配送合同和创建方类型', readonlyRelationText: true, relationFilters: { status: '1' }, relationEmptyText: '当前合同下暂无可用的创建方', dynamicRelation: {
|
||||||
|
parentKey: 'creator_type', parentLabel: '创建方类型', contextParentKey: 'gasorder_contract_identity',
|
||||||
|
resources: { user: '/user_account', staff: '/staff_account', delivery: '/delivery_basic', gas: '/gas_basic' },
|
||||||
|
fixedIdentityKeys: { user: 'user_account_identity', gas: 'gas_basic_identity', delivery: 'delivery_basic_identity' },
|
||||||
|
missingIdentityMessages: { delivery: '所选合同未指定配送点,不能以配送点作为创建方' },
|
||||||
|
scopeFilters: { staff: [
|
||||||
|
{ filterKey: 'gas_basic_identities', optionKey: 'gas_basic_identity', required: true },
|
||||||
|
{ filterKey: 'delivery_basic_identities', optionKey: 'delivery_basic_identity', emptyFilterKey: 'delivery_basic_unassigned', emptyFilterValue: 'true' },
|
||||||
|
] },
|
||||||
|
parentChangeMessage: '创建方类型已变更,已按当前合同重新匹配创建方',
|
||||||
|
contextChangeMessage: '配送合同已变更,创建方已按新合同重新匹配',
|
||||||
|
} }), relation('user_address_identity', '/user_address', true, {
|
||||||
label: '收货地址', placeholder: '请先选择配送合同,再选择该合同用户的收货地址',
|
label: '收货地址', placeholder: '请先选择配送合同,再选择该合同用户的收货地址',
|
||||||
relationOptionLabelKey: 'address', relationOptionDefaultKey: 'is_default',
|
relationOptionLabelKey: 'address', relationOptionDefaultKey: 'is_default',
|
||||||
relationAutoSelectKey: 'is_default', relationEmptyText: '该合同用户暂无收货地址,请先维护用户地址',
|
relationAutoSelectKey: 'is_default', relationEmptyText: '该合同用户暂无收货地址,请先维护用户地址',
|
||||||
|
|||||||
@@ -206,7 +206,12 @@ function relationRequiresParent(field: ResourceField) {
|
|||||||
return Boolean(
|
return Boolean(
|
||||||
(linkage?.requiresParent &&
|
(linkage?.requiresParent &&
|
||||||
!String(model.value[linkage.parentKey] ?? '').trim()) ||
|
!String(model.value[linkage.parentKey] ?? '').trim()) ||
|
||||||
(field.dynamicRelation && !relationResource(field)),
|
(field.dynamicRelation &&
|
||||||
|
(!relationResource(field) ||
|
||||||
|
(field.dynamicRelation.contextParentKey &&
|
||||||
|
!String(
|
||||||
|
model.value[field.dynamicRelation.contextParentKey] ?? '',
|
||||||
|
).trim()))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +219,12 @@ function relationRequiresParent(field: ResourceField) {
|
|||||||
function relationPlaceholder(field: ResourceField) {
|
function relationPlaceholder(field: ResourceField) {
|
||||||
if (!relationRequiresParent(field)) return field.placeholder;
|
if (!relationRequiresParent(field)) return field.placeholder;
|
||||||
if (field.dynamicRelation) {
|
if (field.dynamicRelation) {
|
||||||
|
if (
|
||||||
|
field.dynamicRelation.contextParentKey &&
|
||||||
|
!String(model.value[field.dynamicRelation.contextParentKey] ?? '').trim()
|
||||||
|
) {
|
||||||
|
return '请先选择配送合同';
|
||||||
|
}
|
||||||
return `请先选择${field.dynamicRelation.parentLabel}`;
|
return `请先选择${field.dynamicRelation.parentLabel}`;
|
||||||
}
|
}
|
||||||
return field.relationLinkage?.parentKey === 'gasorder_contract_identity'
|
return field.relationLinkage?.parentKey === 'gasorder_contract_identity'
|
||||||
@@ -225,6 +236,14 @@ function relationPlaceholder(field: ResourceField) {
|
|||||||
function relationEmptyText(field: ResourceField) {
|
function relationEmptyText(field: ResourceField) {
|
||||||
if (relationRequiresParent(field)) {
|
if (relationRequiresParent(field)) {
|
||||||
if (field.dynamicRelation) {
|
if (field.dynamicRelation) {
|
||||||
|
if (
|
||||||
|
field.dynamicRelation.contextParentKey &&
|
||||||
|
!String(
|
||||||
|
model.value[field.dynamicRelation.contextParentKey] ?? '',
|
||||||
|
).trim()
|
||||||
|
) {
|
||||||
|
return '请先选择配送合同';
|
||||||
|
}
|
||||||
return `请先选择${field.dynamicRelation.parentLabel}`;
|
return `请先选择${field.dynamicRelation.parentLabel}`;
|
||||||
}
|
}
|
||||||
const parentName =
|
const parentName =
|
||||||
|
|||||||
@@ -124,6 +124,14 @@
|
|||||||
>
|
>
|
||||||
当前归属只能选择库房、气站、配送点或用户中的一个;选择新归属时会自动清空其他归属。
|
当前归属只能选择库房、气站、配送点或用户中的一个;选择新归属时会自动清空其他归属。
|
||||||
</a-alert>
|
</a-alert>
|
||||||
|
<a-alert
|
||||||
|
v-if="mode === 'create' && definition.name === 'gasorder_basic'"
|
||||||
|
class="workflow-alert"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
>
|
||||||
|
创建方受配送合同约束:用户、气站和配送点由合同自动带出;工作人员仅可选择合同履约组织内的启用人员。
|
||||||
|
</a-alert>
|
||||||
<a-alert
|
<a-alert
|
||||||
v-if="relationUnavailableMessage"
|
v-if="relationUnavailableMessage"
|
||||||
class="workflow-alert"
|
class="workflow-alert"
|
||||||
@@ -346,6 +354,13 @@ const readonlyKeys = computed(() =>
|
|||||||
.filter(
|
.filter(
|
||||||
(field) =>
|
(field) =>
|
||||||
field.readonlyOnCreate ||
|
field.readonlyOnCreate ||
|
||||||
|
(field.key === 'creator_identity' &&
|
||||||
|
Boolean(
|
||||||
|
field.dynamicRelation?.fixedIdentityKeys?.[
|
||||||
|
String(form.creator_type ?? '')
|
||||||
|
],
|
||||||
|
) &&
|
||||||
|
Boolean(form.gasorder_contract_identity)) ||
|
||||||
(field.staffRelation?.lockPrefilled && Boolean(form[field.key])) ||
|
(field.staffRelation?.lockPrefilled && Boolean(form[field.key])) ||
|
||||||
(definition.value.name === 'gasorder_contract_product' &&
|
(definition.value.name === 'gasorder_contract_product' &&
|
||||||
field.key === 'gasorder_contract_identity' &&
|
field.key === 'gasorder_contract_identity' &&
|
||||||
|
|||||||
@@ -60,15 +60,72 @@ export function useResourceRelationLinkage(
|
|||||||
: field;
|
: field;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取动态关系所依赖的业务上下文选项,例如当前选择的配送合同。 */
|
||||||
|
function dynamicContextOption(field: ResourceField) {
|
||||||
|
const contextKey = field.dynamicRelation?.contextParentKey;
|
||||||
|
if (!contextKey) return undefined;
|
||||||
|
const contextField = configuredFields.value.find(
|
||||||
|
(item) => item.key === contextKey,
|
||||||
|
);
|
||||||
|
const contextIdentity = relationIdentity(form[contextKey]);
|
||||||
|
if (!contextField?.relation || !contextIdentity) return undefined;
|
||||||
|
return findOption(contextField.relation, contextIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将上下文选项转换为动态候选接口需要的精确筛选参数。 */
|
||||||
|
function dynamicRequest(field: ResourceField) {
|
||||||
|
const dynamic = field.dynamicRelation;
|
||||||
|
const type = relationIdentity(dynamic ? form[dynamic.parentKey] : '');
|
||||||
|
const option = dynamicContextOption(field);
|
||||||
|
const filters: Record<string, string> = {};
|
||||||
|
for (const filter of dynamic?.scopeFilters?.[type] ?? []) {
|
||||||
|
const value = relationIdentity(option?.[filter.optionKey]);
|
||||||
|
if (value) {
|
||||||
|
filters[filter.filterKey] = value;
|
||||||
|
} else if (filter.emptyFilterKey) {
|
||||||
|
filters[filter.emptyFilterKey] = filter.emptyFilterValue ?? 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const identity = relationIdentity(form[field.key]);
|
||||||
|
return {
|
||||||
|
filters,
|
||||||
|
preserveIdentities: identity ? [identity] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 加载由父字段类型决定的关系候选。 */
|
/** 加载由父字段类型决定的关系候选。 */
|
||||||
async function reloadDynamicOptions(field: ResourceField) {
|
async function reloadDynamicOptions(field: ResourceField) {
|
||||||
const resolved = resolvedField(field);
|
const resolved = resolvedField(field);
|
||||||
if (!resolved.relation) return;
|
if (!resolved.relation) return;
|
||||||
const identity = relationIdentity(form[field.key]);
|
const dynamic = field.dynamicRelation;
|
||||||
|
const type = relationIdentity(dynamic ? form[dynamic.parentKey] : '');
|
||||||
|
const contextIdentity = relationIdentity(
|
||||||
|
dynamic?.contextParentKey ? form[dynamic.contextParentKey] : '',
|
||||||
|
);
|
||||||
|
const contextOption = dynamicContextOption(field);
|
||||||
|
if (dynamic?.contextParentKey && (!contextIdentity || !contextOption)) {
|
||||||
|
form[field.key] = '';
|
||||||
|
relations.clear(resolved.relation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
relations.cancelSearch(resolved.relation);
|
relations.cancelSearch(resolved.relation);
|
||||||
await relations.loadField(resolved, '', {
|
const fixedIdentityKey = dynamic?.fixedIdentityKeys?.[type];
|
||||||
preserveIdentities: identity ? [identity] : [],
|
if (fixedIdentityKey) {
|
||||||
});
|
const fixedIdentity = relationIdentity(contextOption?.[fixedIdentityKey]);
|
||||||
|
form[field.key] = '';
|
||||||
|
relations.clear(resolved.relation);
|
||||||
|
if (!fixedIdentity) return;
|
||||||
|
await relations.ensure(resolved.relation, fixedIdentity);
|
||||||
|
if (
|
||||||
|
relationIdentity(form[dynamic.parentKey]) === type &&
|
||||||
|
relationIdentity(form[dynamic.contextParentKey as string]) ===
|
||||||
|
contextIdentity
|
||||||
|
) {
|
||||||
|
form[field.key] = fixedIdentity;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await relations.loadField(resolved, '', dynamicRequest(field));
|
||||||
}
|
}
|
||||||
|
|
||||||
function staffRequest() {
|
function staffRequest() {
|
||||||
@@ -119,9 +176,8 @@ export function useResourceRelationLinkage(
|
|||||||
if (!linkage) return {};
|
if (!linkage) return {};
|
||||||
const parentIdentity = relationIdentity(form[linkage.parentKey]);
|
const parentIdentity = relationIdentity(form[linkage.parentKey]);
|
||||||
const rawChildValue = form[linkageItem.childField.key];
|
const rawChildValue = form[linkageItem.childField.key];
|
||||||
const childIdentities = (Array.isArray(rawChildValue)
|
const childIdentities = (
|
||||||
? rawChildValue
|
Array.isArray(rawChildValue) ? rawChildValue : [rawChildValue]
|
||||||
: [rawChildValue]
|
|
||||||
)
|
)
|
||||||
.map(relationIdentity)
|
.map(relationIdentity)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -148,8 +204,8 @@ export function useResourceRelationLinkage(
|
|||||||
!relationIdentity(form[childField.key]) &&
|
!relationIdentity(form[childField.key]) &&
|
||||||
childField.relationAutoSelectKey
|
childField.relationAutoSelectKey
|
||||||
) {
|
) {
|
||||||
const defaultOption = (relations.options[resource] ?? []).find(
|
const defaultOption = (relations.options[resource] ?? []).find((option) =>
|
||||||
(option) => Boolean(option[childField.relationAutoSelectKey as string]),
|
Boolean(option[childField.relationAutoSelectKey as string]),
|
||||||
);
|
);
|
||||||
if (defaultOption) {
|
if (defaultOption) {
|
||||||
form[childField.key] = relationIdentity(defaultOption.identity);
|
form[childField.key] = relationIdentity(defaultOption.identity);
|
||||||
@@ -263,12 +319,18 @@ export function useResourceRelationLinkage(
|
|||||||
) {
|
) {
|
||||||
const childField = linkageItem.childField;
|
const childField = linkageItem.childField;
|
||||||
const linkage = childField.relationLinkage;
|
const linkage = childField.relationLinkage;
|
||||||
if (!linkage || !childField.relation || !childIdentity || linkage.filterOnly)
|
if (
|
||||||
|
!linkage ||
|
||||||
|
!childField.relation ||
|
||||||
|
!childIdentity ||
|
||||||
|
linkage.filterOnly
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
const option =
|
const option =
|
||||||
findOption(childField.relation, childIdentity) ??
|
findOption(childField.relation, childIdentity) ??
|
||||||
(await relations.ensure(childField.relation, childIdentity));
|
(await relations.ensure(childField.relation, childIdentity));
|
||||||
if (!option || relationIdentity(form[childField.key]) !== childIdentity) return;
|
if (!option || relationIdentity(form[childField.key]) !== childIdentity)
|
||||||
|
return;
|
||||||
if (linkage.backfillParent) {
|
if (linkage.backfillParent) {
|
||||||
const nextParent = optionParentIdentity(option, linkage.optionParentKey);
|
const nextParent = optionParentIdentity(option, linkage.optionParentKey);
|
||||||
const currentParent = relationIdentity(form[linkage.parentKey]);
|
const currentParent = relationIdentity(form[linkage.parentKey]);
|
||||||
@@ -310,7 +372,23 @@ export function useResourceRelationLinkage(
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
parentLinkages.map((item) => handleParentChange(item, identity)),
|
parentLinkages.map((item) => handleParentChange(item, identity)),
|
||||||
);
|
);
|
||||||
const childLinkage = active.find((item) => item.childField.key === field.key);
|
const contextDynamicChildren = configuredFields.value.filter(
|
||||||
|
(item) => item.dynamicRelation?.contextParentKey === field.key,
|
||||||
|
);
|
||||||
|
for (const childField of contextDynamicChildren) {
|
||||||
|
const previousIdentity = relationIdentity(form[childField.key]);
|
||||||
|
form[childField.key] = '';
|
||||||
|
await reloadDynamicOptions(childField);
|
||||||
|
if (previousIdentity) {
|
||||||
|
Message.info(
|
||||||
|
childField.dynamicRelation?.contextChangeMessage ??
|
||||||
|
'业务上下文已变更,已重新匹配关联数据',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const childLinkage = active.find(
|
||||||
|
(item) => item.childField.key === field.key,
|
||||||
|
);
|
||||||
if (childLinkage) await handleChildChange(childLinkage, identity);
|
if (childLinkage) await handleChildChange(childLinkage, identity);
|
||||||
if (
|
if (
|
||||||
field.key === 'gas_basic_identity' ||
|
field.key === 'gas_basic_identity' ||
|
||||||
@@ -325,10 +403,27 @@ export function useResourceRelationLinkage(
|
|||||||
function search(field: ResourceField, keyword: string) {
|
function search(field: ResourceField, keyword: string) {
|
||||||
const resolved = resolvedField(field);
|
const resolved = resolvedField(field);
|
||||||
if (!resolved.relation) return;
|
if (!resolved.relation) return;
|
||||||
const linkageItem = active.find((item) => item.childField.key === field.key);
|
const dynamic = field.dynamicRelation;
|
||||||
|
if (dynamic?.contextParentKey) {
|
||||||
|
const type = relationIdentity(form[dynamic.parentKey]);
|
||||||
|
if (!dynamicContextOption(field)) {
|
||||||
|
relations.clear(resolved.relation);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 合同直接确定的主体为只读值,不提供跨合同搜索入口。
|
||||||
|
if (dynamic.fixedIdentityKeys?.[type]) return;
|
||||||
|
relations.searchField(resolved, keyword, dynamicRequest(field));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const linkageItem = active.find(
|
||||||
|
(item) => item.childField.key === field.key,
|
||||||
|
);
|
||||||
if (linkageItem) {
|
if (linkageItem) {
|
||||||
const linkage = linkageItem.childField.relationLinkage;
|
const linkage = linkageItem.childField.relationLinkage;
|
||||||
if (linkage?.requiresParent && !relationIdentity(form[linkage.parentKey])) {
|
if (
|
||||||
|
linkage?.requiresParent &&
|
||||||
|
!relationIdentity(form[linkage.parentKey])
|
||||||
|
) {
|
||||||
relations.clear(resolved.relation);
|
relations.clear(resolved.relation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -344,6 +439,28 @@ export function useResourceRelationLinkage(
|
|||||||
|
|
||||||
/** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */
|
/** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */
|
||||||
function validationMessage() {
|
function validationMessage() {
|
||||||
|
for (const field of configuredFields.value.filter(
|
||||||
|
(item) => item.dynamicRelation?.contextParentKey,
|
||||||
|
)) {
|
||||||
|
const dynamic = field.dynamicRelation;
|
||||||
|
if (!dynamic) continue;
|
||||||
|
const type = relationIdentity(form[dynamic.parentKey]);
|
||||||
|
const contextOption = dynamicContextOption(field);
|
||||||
|
if (!type || !contextOption) continue;
|
||||||
|
const fixedIdentityKey = dynamic.fixedIdentityKeys?.[type];
|
||||||
|
if (fixedIdentityKey) {
|
||||||
|
const expected = relationIdentity(contextOption[fixedIdentityKey]);
|
||||||
|
if (!expected) {
|
||||||
|
return (
|
||||||
|
dynamic.missingIdentityMessages?.[type] ??
|
||||||
|
'当前合同缺少该类型的创建方'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (relationIdentity(form[field.key]) !== expected) {
|
||||||
|
return '创建方与当前配送合同不一致,请重新选择';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const linkageItem of active) {
|
for (const linkageItem of active) {
|
||||||
const childField = linkageItem.childField;
|
const childField = linkageItem.childField;
|
||||||
const linkage = childField.relationLinkage;
|
const linkage = childField.relationLinkage;
|
||||||
@@ -374,6 +491,24 @@ export function useResourceRelationLinkage(
|
|||||||
for (const field of configuredFields.value) {
|
for (const field of configuredFields.value) {
|
||||||
const linkage = field.relationLinkage;
|
const linkage = field.relationLinkage;
|
||||||
const resource = dynamicRelationResource(field, form);
|
const resource = dynamicRelationResource(field, form);
|
||||||
|
const dynamic = field.dynamicRelation;
|
||||||
|
if (dynamic?.contextParentKey) {
|
||||||
|
if (!relationIdentity(form[dynamic.contextParentKey])) continue;
|
||||||
|
const type = relationIdentity(form[dynamic.parentKey]);
|
||||||
|
const contextOption = dynamicContextOption(field);
|
||||||
|
const fixedIdentityKey = dynamic.fixedIdentityKeys?.[type];
|
||||||
|
if (
|
||||||
|
type &&
|
||||||
|
contextOption &&
|
||||||
|
fixedIdentityKey &&
|
||||||
|
!relationIdentity(contextOption[fixedIdentityKey])
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
dynamic.missingIdentityMessages?.[type] ??
|
||||||
|
'当前合同缺少该类型的创建方'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
!field.required ||
|
!field.required ||
|
||||||
!resource ||
|
!resource ||
|
||||||
|
|||||||
Reference in New Issue
Block a user