修复配送订单创建方合同约束

This commit is contained in:
czl231
2026-08-14 01:15:33 +08:00
parent 9c3abf76d5
commit 08f1a917b3
9 changed files with 407 additions and 26 deletions

View File

@@ -1,6 +1,6 @@
/**
* 功能:静态检查配送订单创建方类型与业务主体候选的动态联动。
* 版本v1.0.0
* 版本v1.1.0
*/
import assert from 'node:assert/strict';
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');
assert.match(resources, /parentKey: 'creator_type'/, '创建方必须由创建方类型驱动');
assert.match(resources, /contextParentKey: 'gasorder_contract_identity'/, '创建方必须同时受配送合同约束');
for (const mapping of [
"user: '/user_account'",
"staff: '/staff_account'",
@@ -19,10 +20,23 @@ for (const mapping of [
assert.ok(resources.includes(mapping), `创建方动态资源缺少映射:${mapping}`);
}
assert.match(resources, /label: '创建方'/, '创建方字段必须使用业务名称');
assert.match(resources, /创建方类型已变更,请重新选择创建方/, '切换类型时缺少清空提示');
assert.match(form, /field\.dynamicRelation && !relationResource\(field\)/, '未选择类型时必须禁用创建方');
for (const fixedIdentity of [
"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(linkage, /reloadDynamicOptions/, '动态关系候选未接入加载流程');
assert.match(linkage, /fixedIdentityKeys/, '合同唯一创建方未接入自动带出');
assert.match(linkage, /scopeFilters/, '工作人员候选未接入合同组织筛选');
assert.match(linkage, /contextDynamicChildren/, '切换合同时未重新匹配创建方');
assert.match(linkage, /form\[childField\.key\] = ''/, '切换类型时未清空旧创建方');
console.log('配送订单创建方检查通过:四类创建方按类型动态加载,切换类型会清空旧值。');
console.log('配送订单创建方检查通过:合同主体自动带出,工作人员按履约组织筛选,切换合同会重新匹配。');

View File

@@ -45,6 +45,21 @@ export type ResourceDynamicRelation = {
parentLabel: string;
resources: Record<string, 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 = {
@@ -533,7 +548,18 @@ export const resources: ResourceUiDefinition[] = [
{ name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason },
]),
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: '请先选择配送合同,再选择该合同用户的收货地址',
relationOptionLabelKey: 'address', relationOptionDefaultKey: 'is_default',
relationAutoSelectKey: 'is_default', relationEmptyText: '该合同用户暂无收货地址,请先维护用户地址',

View File

@@ -206,7 +206,12 @@ function relationRequiresParent(field: ResourceField) {
return Boolean(
(linkage?.requiresParent &&
!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) {
if (!relationRequiresParent(field)) return field.placeholder;
if (field.dynamicRelation) {
if (
field.dynamicRelation.contextParentKey &&
!String(model.value[field.dynamicRelation.contextParentKey] ?? '').trim()
) {
return '请先选择配送合同';
}
return `请先选择${field.dynamicRelation.parentLabel}`;
}
return field.relationLinkage?.parentKey === 'gasorder_contract_identity'
@@ -225,6 +236,14 @@ function relationPlaceholder(field: ResourceField) {
function relationEmptyText(field: ResourceField) {
if (relationRequiresParent(field)) {
if (field.dynamicRelation) {
if (
field.dynamicRelation.contextParentKey &&
!String(
model.value[field.dynamicRelation.contextParentKey] ?? '',
).trim()
) {
return '请先选择配送合同';
}
return `请先选择${field.dynamicRelation.parentLabel}`;
}
const parentName =

View File

@@ -124,6 +124,14 @@
>
当前归属只能选择库房气站配送点或用户中的一个选择新归属时会自动清空其他归属
</a-alert>
<a-alert
v-if="mode === 'create' && definition.name === 'gasorder_basic'"
class="workflow-alert"
type="info"
show-icon
>
创建方受配送合同约束用户气站和配送点由合同自动带出工作人员仅可选择合同履约组织内的启用人员
</a-alert>
<a-alert
v-if="relationUnavailableMessage"
class="workflow-alert"
@@ -346,6 +354,13 @@ const readonlyKeys = computed(() =>
.filter(
(field) =>
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])) ||
(definition.value.name === 'gasorder_contract_product' &&
field.key === 'gasorder_contract_identity' &&

View File

@@ -60,15 +60,72 @@ export function useResourceRelationLinkage(
: 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) {
const resolved = resolvedField(field);
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);
await relations.loadField(resolved, '', {
preserveIdentities: identity ? [identity] : [],
});
const fixedIdentityKey = dynamic?.fixedIdentityKeys?.[type];
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() {
@@ -119,9 +176,8 @@ export function useResourceRelationLinkage(
if (!linkage) return {};
const parentIdentity = relationIdentity(form[linkage.parentKey]);
const rawChildValue = form[linkageItem.childField.key];
const childIdentities = (Array.isArray(rawChildValue)
? rawChildValue
: [rawChildValue]
const childIdentities = (
Array.isArray(rawChildValue) ? rawChildValue : [rawChildValue]
)
.map(relationIdentity)
.filter(Boolean);
@@ -148,8 +204,8 @@ export function useResourceRelationLinkage(
!relationIdentity(form[childField.key]) &&
childField.relationAutoSelectKey
) {
const defaultOption = (relations.options[resource] ?? []).find(
(option) => Boolean(option[childField.relationAutoSelectKey as string]),
const defaultOption = (relations.options[resource] ?? []).find((option) =>
Boolean(option[childField.relationAutoSelectKey as string]),
);
if (defaultOption) {
form[childField.key] = relationIdentity(defaultOption.identity);
@@ -263,12 +319,18 @@ export function useResourceRelationLinkage(
) {
const childField = linkageItem.childField;
const linkage = childField.relationLinkage;
if (!linkage || !childField.relation || !childIdentity || linkage.filterOnly)
if (
!linkage ||
!childField.relation ||
!childIdentity ||
linkage.filterOnly
)
return;
const option =
findOption(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) {
const nextParent = optionParentIdentity(option, linkage.optionParentKey);
const currentParent = relationIdentity(form[linkage.parentKey]);
@@ -310,7 +372,23 @@ export function useResourceRelationLinkage(
await Promise.all(
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 (
field.key === 'gas_basic_identity' ||
@@ -325,10 +403,27 @@ export function useResourceRelationLinkage(
function search(field: ResourceField, keyword: string) {
const resolved = resolvedField(field);
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) {
const linkage = linkageItem.childField.relationLinkage;
if (linkage?.requiresParent && !relationIdentity(form[linkage.parentKey])) {
if (
linkage?.requiresParent &&
!relationIdentity(form[linkage.parentKey])
) {
relations.clear(resolved.relation);
return;
}
@@ -344,6 +439,28 @@ export function useResourceRelationLinkage(
/** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */
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) {
const childField = linkageItem.childField;
const linkage = childField.relationLinkage;
@@ -374,6 +491,24 @@ export function useResourceRelationLinkage(
for (const field of configuredFields.value) {
const linkage = field.relationLinkage;
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 (
!field.required ||
!resource ||