完善合同气瓶绑定引导

This commit is contained in:
czl231
2026-08-14 20:10:09 +08:00
parent 14263416e0
commit 2cff8fb942
7 changed files with 82 additions and 7 deletions

View File

@@ -49,7 +49,7 @@ func TestContractActivationErrorMessages(t *testing.T) {
errContractAttachment: "合同附件无效,请重新上传有效的 PDF 文件后再启用", errContractAttachment: "合同附件无效,请重新上传有效的 PDF 文件后再启用",
errContractNotEffective: "合同尚未到生效时间,暂时不能启用", errContractNotEffective: "合同尚未到生效时间,暂时不能启用",
errContractExpired: "合同已到期,请调整到期时间或续签后再启用", errContractExpired: "合同已到期,请调整到期时间或续签后再启用",
errContractNoProduct: "合同尚未绑定有效气瓶,请先绑定气瓶后再启用", errContractNoProduct: "合同尚未绑定有效智能气阀。请先点击“绑定气阀”;若无可选设备,请到“智能气阀管理”将设备归属变更为合同用户,并确认设备已启用、未报废",
} }
for businessErr, message := range want { for businessErr, message := range want {
if businessErr.Error() != message { if businessErr.Error() != message {

View File

@@ -28,7 +28,7 @@ var (
errContractAttachment = errors.New("合同附件无效,请重新上传有效的 PDF 文件后再启用") errContractAttachment = errors.New("合同附件无效,请重新上传有效的 PDF 文件后再启用")
errContractNotEffective = errors.New("合同尚未到生效时间,暂时不能启用") errContractNotEffective = errors.New("合同尚未到生效时间,暂时不能启用")
errContractExpired = errors.New("合同已到期,请调整到期时间或续签后再启用") errContractExpired = errors.New("合同已到期,请调整到期时间或续签后再启用")
errContractNoProduct = errors.New("合同尚未绑定有效气瓶,请先绑定气瓶后再启用") errContractNoProduct = errors.New("合同尚未绑定有效智能气阀。请先点击“绑定气阀”;若无可选设备,请到“智能气阀管理”将设备归属变更为合同用户,并确认设备已启用、未报废")
errBindingContractStatus = errors.New("仅草稿合同可以绑定气瓶") errBindingContractStatus = errors.New("仅草稿合同可以绑定气瓶")
errBindingProductDisabled = errors.New("所选气瓶已停用,不能绑定到合同") errBindingProductDisabled = errors.New("所选气瓶已停用,不能绑定到合同")
errBindingProductScrapped = errors.New("所选气瓶已报废,不能绑定到合同") errBindingProductScrapped = errors.New("所选气瓶已报废,不能绑定到合同")

View File

@@ -185,7 +185,7 @@
合同气瓶必须属于合同用户、处于可用状态且未报废;订单只能使用有效合同中仍绑定的气瓶。 合同气瓶必须属于合同用户、处于可用状态且未报废;订单只能使用有效合同中仍绑定的气瓶。
合同气瓶绑定页面只允许选择草稿合同;已生效、已过期或已终止合同不进入绑定候选。合同管理列表仍展示全部生命周期状态。 合同气瓶绑定页面只允许选择草稿合同;已生效、已过期或已终止合同不进入绑定候选。绑定字段使用“智能气阀”业务名称。合同用户没有符合条件的智能气阀时,页面必须说明设备需先归属合同用户、处于启用且未报废状态,并提供前往“智能气阀管理”的站内入口。合同管理列表仍展示全部生命周期状态。
### 6.6 气体配送订单 ### 6.6 气体配送订单

View File

@@ -0,0 +1,35 @@
# 操作日志:合同气瓶空状态引导
操作时间2026-08-14
操作类型:修改、扩展
影响模块:平台总后台、配送合同、合同气瓶
## 操作前状态
草稿合同没有可绑定设备时,智能气阀下拉仅显示“暂无数据”。管理员无法得知设备必须先归属合同用户并处于启用、未报废状态;启用合同失败提示也没有完整说明处理顺序。
## 具体操作
- 将合同气瓶绑定表单字段标题从“智能气阀唯一标识”改为“智能气阀”。
- 空候选明确提示设备归属、启用状态和报废状态要求。
- 增加“前往智能气阀管理”站内入口。
- 启用合同失败时提示先绑定智能气阀;无候选时引导先变更设备归属。
- 保留服务端“设备必须属于合同用户”的最终校验,不允许跨用户绑定。
## 操作后状态
管理员可以从空状态直接进入智能气阀管理,完成“变更智能气阀归属”,返回合同列表绑定设备后再启用合同。
## 代码变更
- `backend/api/internal/logic/platform/gasorder/gasorder.go`:完善合同无有效智能气阀时的业务提示。
- `backend/api/internal/logic/platform/gasorder/contract_change_error_test.go`:固定中文错误信息。
- `frontend/platform_admin/src/api/resources.ts`:配置业务字段名、空状态说明和跳转入口。
- `frontend/platform_admin/src/views/resource/ResourceFieldForm.vue`:支持关系空状态站内操作按钮。
- `frontend/platform_admin/scripts/check-contract-product-entry.mjs`:增加字段名、说明和入口回归检查。
## 验证与风险
本次不修改数据库结构和数据,不放宽候选筛选及绑定校验。新增空状态操作为通用可选配置,未配置的其他关系字段保持原有行为。

View File

@@ -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';
@@ -17,6 +17,10 @@ const resourcesSource = readFileSync(
new URL('../src/api/resources.ts', import.meta.url), new URL('../src/api/resources.ts', import.meta.url),
'utf8', 'utf8',
); );
const formSource = readFileSync(
new URL('../src/views/resource/ResourceFieldForm.vue', 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*</, '草稿合同操作列必须展示“绑定气瓶”按钮');
@@ -27,5 +31,9 @@ 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(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/, '锁定合同必须以可读名称展示'); assert.match(resourcesSource, /readonlyRelationText: true, displayRelationLabel: true/, '锁定合同必须以可读名称展示');
assert.match(resourcesSource, /label: '智能气阀'/, '绑定表单不得继续显示“智能气阀唯一标识”');
assert.match(resourcesSource, /该合同用户暂无可绑定的智能气阀/, '空候选必须说明合同用户归属前置条件');
assert.match(resourcesSource, /routeName: 'product-info'/, '空候选必须提供智能气阀管理入口');
assert.match(formSource, /navigateRelationEmptyAction/, '关系空状态必须渲染可执行的站内引导');
console.log('合同气瓶绑定入口检查通过:草稿合同列表行可见,进入后锁定所选合同。'); console.log('合同气瓶绑定入口检查通过:草稿合同可绑定气阀,空候选会解释归属条件并跳转智能气阀管理。');

View File

@@ -95,6 +95,8 @@ export type ResourceField = {
relationAutoSelectKey?: string; relationAutoSelectKey?: string;
/** 关联候选为空时展示的业务引导。 */ /** 关联候选为空时展示的业务引导。 */
relationEmptyText?: string; relationEmptyText?: string;
/** 关联候选为空时提供的站内处理入口。 */
relationEmptyAction?: { label: string; routeName: string };
/** 该关联字段每次加载与搜索都必须携带的精确筛选参数。 */ /** 该关联字段每次加载与搜索都必须携带的精确筛选参数。 */
relationFilters?: Record<string, string>; relationFilters?: Record<string, string>;
/** 筛选结果中不存在当前值时立即清空,禁止通过详情补载绕过候选范围。 */ /** 筛选结果中不存在当前值时立即清空,禁止通过详情补载绕过候选范围。 */
@@ -541,7 +543,10 @@ export const resources: ResourceUiDefinition[] = [
relationInvalidMessage: '所选合同已不是草稿状态,已清空,请重新选择', relationInvalidMessage: '所选合同已不是草稿状态,已清空,请重新选择',
relationEmptyText: '暂无可绑定气瓶的草稿合同,请先新建配送合同', relationEmptyText: '暂无可绑定气瓶的草稿合同,请先新建配送合同',
}), relation('product_info_identity', '/product_info', true, { }), relation('product_info_identity', '/product_info', true, {
label: '智能气阀',
placeholder: '请先选择合同,再选择该合同用户可绑定的气瓶', placeholder: '请先选择合同,再选择该合同用户可绑定的气瓶',
relationEmptyText: '该合同用户暂无可绑定的智能气阀。请先到“智能气阀管理”将设备归属变更为该用户,并确认设备已启用、未报废。',
relationEmptyAction: { label: '前往智能气阀管理', routeName: 'product-info' },
relationLinkage: { relationLinkage: {
parentKey: 'gasorder_contract_identity', optionParentKey: '', parentKey: 'gasorder_contract_identity', optionParentKey: '',
filterKey: 'contract_identity', filterOnly: true, filterKey: 'contract_identity', filterOnly: true,

View File

@@ -1,6 +1,6 @@
<!-- <!--
功能渲染标准资源的新建编辑和业务动作字段控件 功能渲染标准资源的新建编辑和业务动作字段控件
版本v1.3.0 版本v1.4.0
--> -->
<template> <template>
<div class="field-grid"> <div class="field-grid">
@@ -114,7 +114,17 @@
}} }}
</a-option> </a-option>
<template #empty> <template #empty>
<div class="relation-empty">{{ relationEmptyText(field) }}</div> <div class="relation-empty">
<span>{{ relationEmptyText(field) }}</span>
<a-button
v-if="field.relationEmptyAction && !relationRequiresParent(field)"
type="text"
size="mini"
@click.stop="navigateRelationEmptyAction(field)"
>
{{ field.relationEmptyAction.label }}
</a-button>
</div>
</template> </template>
</a-select> </a-select>
<div <div
@@ -137,6 +147,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { relationOptionLabel } from '@/api/resource-display'; import { relationOptionLabel } from '@/api/resource-display';
import { dynamicRelationResource, type ResourceField } from '@/api/resources'; import { dynamicRelationResource, type ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules'; import type { ResourceRow } from '@/api/resource-page-rules';
@@ -181,6 +192,7 @@ const emit = defineEmits<{
'preview-attachment': []; 'preview-attachment': [];
}>(); }>();
const model = defineModel<Record<string, any>>({ required: true }); const model = defineModel<Record<string, any>>({ required: true });
const router = useRouter();
const disabledSet = computed(() => new Set(props.disabledKeys)); const disabledSet = computed(() => new Set(props.disabledKeys));
function isWide(field: ResourceField) { function isWide(field: ResourceField) {
@@ -267,6 +279,12 @@ function relationEmptyText(field: ResourceField) {
return field.relationEmptyText ?? '暂无数据'; return field.relationEmptyText ?? '暂无数据';
} }
/** 从空候选引导直接进入对应管理页面,避免管理员只能猜测前置操作。 */
function navigateRelationEmptyAction(field: ResourceField) {
if (!field.relationEmptyAction) return;
void router.push({ name: field.relationEmptyAction.routeName });
}
/** 为历史未知编码补充只读选项,避免下拉框退回显示原始值。 */ /** 为历史未知编码补充只读选项,避免下拉框退回显示原始值。 */
function selectOptions(field: ResourceField) { function selectOptions(field: ResourceField) {
const options = [...(field.options ?? [])]; const options = [...(field.options ?? [])];
@@ -309,6 +327,15 @@ function selectOptions(field: ResourceField) {
display: grid; display: grid;
gap: 6px; gap: 6px;
} }
.relation-empty {
display: grid;
justify-items: center;
gap: 4px;
padding: 8px 12px;
color: var(--color-text-3);
text-align: center;
white-space: normal;
}
.relation-identity { .relation-identity {
display: flex; display: flex;
gap: 8px; gap: 8px;