From 4d2acbaf46bf257e41f904310bc3efd0363fb640 Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Thu, 13 Aug 2026 23:38:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=85=8D=E9=80=81=E5=90=88?= =?UTF-8?q?=E5=90=8C=E5=80=99=E9=80=89=E8=8C=83=E5=9B=B4=E4=B8=8E=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../logic/platform/gasorder/gasorder.go | 38 ++++++++++- .../logic/platform/gasorder/gasorder_test.go | 61 ++++++++++++++++++ docs/05-平台总后台需求.md | 4 ++ docs/项目文档_配送合同候选范围修复_v1.0.md | 64 +++++++++++++++++++ frontend/platform_admin/package.json | 1 + .../scripts/check-contract-product-filter.mjs | 2 +- .../check-gasorder-contract-candidates.mjs | 20 ++++++ frontend/platform_admin/src/api/resources.ts | 11 +++- .../resource/use-resource-relation-linkage.ts | 50 +++++++++++++-- .../views/resource/use-resource-relations.ts | 2 + 10 files changed, 243 insertions(+), 10 deletions(-) create mode 100644 docs/项目文档_配送合同候选范围修复_v1.0.md create mode 100644 frontend/platform_admin/scripts/check-gasorder-contract-candidates.mjs diff --git a/backend/api/internal/logic/platform/gasorder/gasorder.go b/backend/api/internal/logic/platform/gasorder/gasorder.go index 00fcce2..7980302 100644 --- a/backend/api/internal/logic/platform/gasorder/gasorder.go +++ b/backend/api/internal/logic/platform/gasorder/gasorder.go @@ -36,8 +36,32 @@ var ( errBindingProductDuplicate = errors.New("所选气瓶已绑定到该合同,请勿重复绑定") ) -func ListGasorderContract(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderContract{}) } -func GetGasorderContract(ctx *gin.Context) { getGasorderContract(ctx) } +// ListGasorderContract 按明确用途收窄合同候选;合同管理列表不带用途参数时保持全量查询。 +func ListGasorderContract(ctx *gin.Context) { + candidate := strings.TrimSpace(ctx.Query("candidate")) + if candidate != "order" && candidate != "binding" { + common.ListResource(ctx, &models.GasorderContract{}) + return + } + now := time.Now() + common.ListPageFiltered[models.GasorderContract](ctx, func(query *gorm.DB) *gorm.DB { + return filterGasorderContractCandidates(query, candidate, now) + }) +} + +// filterGasorderContractCandidates 应用订单履约或草稿绑定场景的合同候选条件。 +func filterGasorderContractCandidates(query *gorm.DB, candidate string, now time.Time) *gorm.DB { + if candidate == "binding" { + return query.Where("contract_status = ?", common.StatusDraft) + } + return query.Where( + "contract_status = ? AND effective_at <= ? AND expired_at IS NOT NULL AND expired_at > ?", + common.StatusActive, + now, + now, + ) +} +func GetGasorderContract(ctx *gin.Context) { getGasorderContract(ctx) } func ListGasorderContractProduct(ctx *gin.Context) { common.ListResource(ctx, &models.GasorderContractProduct{}) } @@ -538,7 +562,7 @@ func CreateGasorderBasic(ctx *gin.Context) { return err } now := time.Now() - if contract.ContractStatus != common.StatusActive || contract.EffectiveAt.After(now) || (contract.ExpiredAt != nil && !contract.ExpiredAt.After(now)) { + if !contractEligibleForOrder(contract, now) { return errors.New("contract is not active") } if request.CreatorType == "user" && creatorID != contract.UserAccountID { @@ -618,6 +642,14 @@ func CreateGasorderBasic(ctx *gin.Context) { common.RespondCreatedResource(ctx, order) } +// contractEligibleForOrder 校验合同当前确实处于可履约时间窗口。 +func contractEligibleForOrder(contract models.GasorderContract, now time.Time) bool { + return contract.ContractStatus == common.StatusActive && + !contract.EffectiveAt.After(now) && + contract.ExpiredAt != nil && + contract.ExpiredAt.After(now) +} + func AssignGasorderBasic(ctx *gin.Context) { var request struct { DeliveryIdentity string `json:"delivery_basic_identity" binding:"required"` diff --git a/backend/api/internal/logic/platform/gasorder/gasorder_test.go b/backend/api/internal/logic/platform/gasorder/gasorder_test.go index 82af641..dc81855 100644 --- a/backend/api/internal/logic/platform/gasorder/gasorder_test.go +++ b/backend/api/internal/logic/platform/gasorder/gasorder_test.go @@ -8,8 +8,69 @@ import ( "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" ) +// TestContractEligibleForOrder 覆盖草稿、未生效、有效、到期、终止和缺少到期时间的合同。 +func TestContractEligibleForOrder(t *testing.T) { + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + past := now.Add(-time.Hour) + future := now.Add(time.Hour) + tests := []struct { + name string + contract models.GasorderContract + want bool + }{ + {"草稿", models.GasorderContract{ContractStatus: common.StatusDraft, EffectiveAt: past, ExpiredAt: &future}, false}, + {"未到生效时间", models.GasorderContract{ContractStatus: common.StatusActive, EffectiveAt: future, ExpiredAt: &future}, false}, + {"当前有效", models.GasorderContract{ContractStatus: common.StatusActive, EffectiveAt: past, ExpiredAt: &future}, true}, + {"已经到期", models.GasorderContract{ContractStatus: common.StatusActive, EffectiveAt: past, ExpiredAt: &now}, false}, + {"已经终止", models.GasorderContract{ContractStatus: common.StatusTerminated, EffectiveAt: past, ExpiredAt: &future}, false}, + {"缺少到期时间", models.GasorderContract{ContractStatus: common.StatusActive, EffectiveAt: past}, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := contractEligibleForOrder(test.contract, now); got != test.want { + t.Fatalf("contractEligibleForOrder() = %v, want %v", got, test.want) + } + }) + } +} + +// TestFilterGasorderContractCandidates 验证订单候选和绑定候选使用不同的服务端过滤条件。 +func TestFilterGasorderContractCandidates(t *testing.T) { + sqlDatabase, _, err := sqlmock.New() + if err != nil { + t.Fatalf("创建模拟数据库失败:%v", err) + } + defer sqlDatabase.Close() + database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{}) + if err != nil { + t.Fatalf("创建 GORM 数据库失败:%v", err) + } + now := time.Date(2026, 8, 13, 12, 0, 0, 0, time.UTC) + statement := func(candidate string) string { + return database.ToSQL(func(tx *gorm.DB) *gorm.DB { + var contracts []models.GasorderContract + return filterGasorderContractCandidates( + tx.Model(&models.GasorderContract{}), candidate, now, + ).Find(&contracts) + }) + } + orderSQL := statement("order") + for _, fragment := range []string{"contract_status = 11", "effective_at <=", "expired_at IS NOT NULL", "expired_at >"} { + if !strings.Contains(orderSQL, fragment) { + t.Fatalf("订单合同候选缺少 %q:%s", fragment, orderSQL) + } + } + bindingSQL := statement("binding") + if !strings.Contains(bindingSQL, "contract_status = 0") { + t.Fatalf("合同气瓶绑定候选未限定草稿:%s", bindingSQL) + } +} + func TestGasorderCreatorTypesCoverEveryConfirmedOrigin(t *testing.T) { for _, creatorType := range []string{"user", "staff", "delivery", "gas"} { if gasorderCreatorModels[creatorType] == nil { diff --git a/docs/05-平台总后台需求.md b/docs/05-平台总后台需求.md index 8868c41..7dcb2ba 100644 --- a/docs/05-平台总后台需求.md +++ b/docs/05-平台总后台需求.md @@ -185,6 +185,8 @@ 合同气瓶必须属于合同用户、处于可用状态且未报废;订单只能使用有效合同中仍绑定的气瓶。 +合同气瓶绑定页面只允许选择草稿合同;已生效、已过期或已终止合同不进入绑定候选。合同管理列表仍展示全部生命周期状态。 + ### 6.6 气体配送订单 | 资源 | 路径 | 模式 | 已实现能力 | @@ -202,6 +204,8 @@ 创建订单页面必须先选择配送合同,收货地址下拉仅展示该合同签约用户的地址。地址选项以完整地址为主文案并标记默认地址;存在默认地址时自动选中,切换合同时清空原地址。若合同用户没有可用地址,页面应明确引导先维护用户地址并禁止提交。 +订单合同候选必须同时满足“生效中、已到生效时间、尚未到期且到期时间有效”。草稿、未到生效时间、已到期和已终止合同不得进入订单候选;没有可履约合同时应引导管理员先启用或续签合同并禁止提交。 + 订单状态机如下: ```text diff --git a/docs/项目文档_配送合同候选范围修复_v1.0.md b/docs/项目文档_配送合同候选范围修复_v1.0.md new file mode 100644 index 0000000..aa2a4a5 --- /dev/null +++ b/docs/项目文档_配送合同候选范围修复_v1.0.md @@ -0,0 +1,64 @@ +# 项目文档_配送合同候选范围修复_v1.0 + +## 1. 项目概述 + +本次修复平台总后台业务表单直接加载全部配送合同的问题。合同管理列表需要展示完整生命周期,但创建订单和绑定合同气瓶属于受状态约束的业务操作,候选范围必须与服务端最终校验一致。 + +## 2. 业务规则 + +- 创建配送订单只显示当前可履约合同:状态为“生效中”、当前时间不早于生效时间、到期时间存在且当前时间早于到期时间。 +- 草稿、未到生效时间、已过期、已终止和缺少到期时间的合同不进入订单候选。 +- 绑定合同气瓶只显示草稿合同。 +- 合同管理列表不携带候选用途参数,继续展示全部合同。 +- 没有可用候选时,页面显示对应业务引导并禁止保存。 +- 页面重新加载后,严格筛选结果中不存在的旧合同选择会被清空。 +- 服务端创建订单和绑定气瓶时继续执行最终校验,前端筛选不构成安全控制。 + +## 3. 接口兼容 + +`GET /gasorder_contract` 新增可选查询参数 `candidate`: + +- 不传:合同管理全量列表,保持原行为。 +- `candidate=order`:仅返回当前可履约合同。 +- `candidate=binding`:仅返回草稿合同。 + +未新增数据库字段、表或迁移,也未修改合同数据。 + +## 4. 关键文件 + +```text +backend/api/internal/logic/platform/gasorder/gasorder.go # 合同候选过滤与订单最终校验 +backend/api/internal/logic/platform/gasorder/gasorder_test.go # 五类合同状态与查询条件测试 +frontend/platform_admin/src/api/resources.ts # 两类合同候选配置与中文提示 +frontend/platform_admin/src/views/resource/use-resource-relations.ts # 字段级筛选参数 +frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts # 失效选择清理和空候选阻断 +``` + +## 5. 变更记录 + +操作时间:2026-08-13 + +操作类型:修复 + +影响模块:配送合同、配送订单、合同气瓶 + +操作前状态:订单和合同气瓶表单均展示全部合同,用户选中后才由服务端拒绝。 + +操作后状态:不同业务表单只展示当前允许操作的合同,并继续保留服务端最终校验。 + +风险评估:管理列表和既有无参数调用保持兼容;候选接口只收窄可选数据,不修改数据事实。 + +## 6. 验证方式 + +```bash +cd backend/api +go test ./internal/logic/platform/gasorder +go test ./... +go vet ./... + +cd frontend/platform_admin +npm run gasorder-contract-candidates:check +npm run type:check +npm run contract:check +npm run build +``` diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index 2d4a05b..415afb8 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -23,6 +23,7 @@ "staff-relations:check": "node scripts/check-staff-relation-policy.mjs", "user-address-display:check": "node scripts/check-user-address-relation-display.mjs", "gasorder-address:check": "node scripts/check-gasorder-address-linkage.mjs", + "gasorder-contract-candidates:check": "node scripts/check-gasorder-contract-candidates.mjs", "product-ownership:check": "node scripts/check-product-ownership-action.mjs", "product-ownership-display:check": "node scripts/check-product-ownership-display.mjs", "product-lifecycle-display:check": "node scripts/check-product-lifecycle-display.mjs", diff --git a/frontend/platform_admin/scripts/check-contract-product-filter.mjs b/frontend/platform_admin/scripts/check-contract-product-filter.mjs index 88f8e29..5732336 100644 --- a/frontend/platform_admin/scripts/check-contract-product-filter.mjs +++ b/frontend/platform_admin/scripts/check-contract-product-filter.mjs @@ -10,6 +10,6 @@ const linkage = readFileSync(new URL('../src/views/resource/use-resource-relatio assert.match(resources, /filterKey: 'contract_identity', filterOnly: true/, '气瓶字段必须按当前合同筛选'); assert.match(resources, /请先选择合同,再选择该合同用户可绑定的气瓶/, '气瓶字段必须解释筛选范围'); -assert.match(linkage, /if \(linkage\.filterOnly\) return ''/, '纯筛选联动不得执行父标识校验'); +assert.match(linkage, /linkage\.filterOnly\) continue/, '纯筛选联动不得执行父标识校验'); console.log('合同气瓶候选过滤检查通过:气瓶下拉按当前合同加载可绑定项。'); diff --git a/frontend/platform_admin/scripts/check-gasorder-contract-candidates.mjs b/frontend/platform_admin/scripts/check-gasorder-contract-candidates.mjs new file mode 100644 index 0000000..aac03e8 --- /dev/null +++ b/frontend/platform_admin/scripts/check-gasorder-contract-candidates.mjs @@ -0,0 +1,20 @@ +/** + * 功能:静态检查订单创建与合同气瓶绑定使用正确的合同候选范围。 + * 版本: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 relations = readFileSync(new URL('../src/views/resource/use-resource-relations.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, /relationFilters: \{ candidate: 'order' \}/, '订单创建必须只加载可履约合同'); +assert.match(resources, /relationFilters: \{ candidate: 'binding' \}/, '合同气瓶绑定必须只加载草稿合同'); +assert.match(resources, /暂无可下单的生效合同,请先启用或续签配送合同/, '订单页缺少无可用合同时的引导'); +assert.match(resources, /暂无可绑定气瓶的草稿合同,请先新建配送合同/, '合同气瓶页缺少无草稿合同时的引导'); +assert.match(resources, /relationStrictFilter: true/, '合同候选必须启用严格筛选'); +assert.match(relations, /\.\.\.\(field\.relationFilters \?\? \{\}\)/, '关联请求未携带字段级筛选参数'); +assert.match(linkage, /clearInvalidStrictSelections/, '刷新后未清理失效合同选择'); + +console.log('配送合同候选检查通过:订单仅显示可履约合同,气瓶绑定仅显示草稿合同。'); diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index ba17692..a2ba99f 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -68,6 +68,12 @@ export type ResourceField = { relationAutoSelectKey?: string; /** 关联候选为空时展示的业务引导。 */ relationEmptyText?: string; + /** 该关联字段每次加载与搜索都必须携带的精确筛选参数。 */ + relationFilters?: Record; + /** 筛选结果中不存在当前值时立即清空,禁止通过详情补载绕过候选范围。 */ + relationStrictFilter?: boolean; + /** 严格筛选清空失效值时展示的提示。 */ + relationInvalidMessage?: string; staffRelation?: import('./resource-staff-relation').StaffRelationPolicy; }; @@ -504,6 +510,9 @@ export const resources: ResourceUiDefinition[] = [ ], { canCreate: true, canEdit: true }), define('gasorder_contract_product', '合同气瓶', 'append_only', [relation('gasorder_contract_identity', '/gasorder_contract', true, { readonlyRelationText: true, displayRelationLabel: true, + placeholder: '请选择草稿合同', relationFilters: { candidate: 'binding' }, relationStrictFilter: true, + relationInvalidMessage: '所选合同已不是草稿状态,已清空,请重新选择', + relationEmptyText: '暂无可绑定气瓶的草稿合同,请先新建配送合同', }), relation('product_info_identity', '/product_info', true, { placeholder: '请先选择合同,再选择该合同用户可绑定的气瓶', relationLinkage: { @@ -514,7 +523,7 @@ 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: '请选择配送合同' }), f('creator_type', { required: true, type: 'select', options: resourceSearchEnumOptions('gasorder_basic', 'creator_type') }), f('creator_identity', { required: true }), 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', { required: true }), relation('user_address_identity', '/user_address', true, { label: '收货地址', placeholder: '请先选择配送合同,再选择该合同用户的收货地址', relationOptionLabelKey: 'address', relationOptionDefaultKey: 'is_default', relationAutoSelectKey: 'is_default', relationEmptyText: '该合同用户暂无收货地址,请先维护用户地址', diff --git a/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts b/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts index 7c6ad2c..1ca6d47 100644 --- a/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts +++ b/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts @@ -3,6 +3,7 @@ * 版本:v1.1.0 */ import { Message } from '@arco-design/web-vue'; +import { ref } from 'vue'; import type { ResourceField } from '@/api/resources'; import { linkedRelationFilters, @@ -25,6 +26,7 @@ export function useResourceRelationLinkage( relations: ResourceRelations, ) { let active: ActiveLinkage[] = []; + const configuredFields = ref([]); let organizationStaffField: ResourceField | undefined; let initialized = false; @@ -36,6 +38,7 @@ export function useResourceRelationLinkage( /** 收集页面内全部显式联动,允许一个父字段同时驱动多个业务子项。 */ function configure(fields: ResourceField[]) { + configuredFields.value = fields; active = fields .filter((field) => field.relationLinkage) .map((childField) => ({ @@ -147,15 +150,49 @@ export function useResourceRelationLinkage( field.key !== organizationStaffField?.key, ), ); - await relations.ensureValues(fields, form); + await relations.ensureValues( + fields.filter((field) => !field.relationStrictFilter), + form, + ); await Promise.all(active.map((item) => reloadChildOptions(item))); await reloadStaffOptions(); + clearInvalidStrictSelections(); initialized = true; const warning = validationMessage(); if (warning) Message.warning(`当前组织归属需要确认:${warning}`); } + /** 严格候选中不存在当前值时清空,避免补载详情绕过业务筛选。 */ + function clearInvalidStrictSelections() { + for (const field of configuredFields.value) { + if (!field.relationStrictFilter || !field.relation) continue; + const rawValue = form[field.key]; + const identities = (Array.isArray(rawValue) ? rawValue : [rawValue]) + .map(relationIdentity) + .filter(Boolean); + if (identities.length === 0) continue; + const valid = new Set( + (relations.options[field.relation] ?? []).map((option) => + relationIdentity(option.identity), + ), + ); + if (identities.some((identity) => !valid.has(identity))) { + form[field.key] = field.type === 'identity-list' ? [] : ''; + for (const linkageItem of active.filter( + (item) => item.childField.relationLinkage?.parentKey === field.key, + )) { + const childField = linkageItem.childField; + form[childField.key] = childField.type === 'identity-list' ? [] : ''; + relations.clear(childField.relation); + } + Message.info( + field.relationInvalidMessage ?? '所选关联数据已失效,已清空', + ); + } + } + } + async function handleParentChange( linkageItem: ActiveLinkage, parentIdentity: string, @@ -292,19 +329,22 @@ export function useResourceRelationLinkage( /** 必填联动候选为空时返回业务引导,用于阻止提交无效表单。 */ function unavailableMessage() { - for (const linkageItem of active) { - const field = linkageItem.childField; + for (const field of configuredFields.value) { const linkage = field.relationLinkage; if ( !field.required || !field.relation || !field.relationEmptyText || - !linkage?.requiresParent || - !relationIdentity(form[linkage.parentKey]) || relations.loading[field.relation] ) { continue; } + if ( + linkage?.requiresParent && + !relationIdentity(form[linkage.parentKey]) + ) { + continue; + } if ((relations.options[field.relation] ?? []).length === 0) { return field.relationEmptyText; } diff --git a/frontend/platform_admin/src/views/resource/use-resource-relations.ts b/frontend/platform_admin/src/views/resource/use-resource-relations.ts index 21864ae..fd61b0e 100644 --- a/frontend/platform_admin/src/views/resource/use-resource-relations.ts +++ b/frontend/platform_admin/src/views/resource/use-resource-relations.ts @@ -89,6 +89,7 @@ export function useResourceRelations( return load(field.relation, keyword, { ...request, filters: { + ...(field.relationFilters ?? {}), ...staffRelationFilters(field, context()), ...(request.filters ?? {}), }, @@ -170,6 +171,7 @@ export function useResourceRelations( search(field.relation, keyword, { ...request, filters: { + ...(field.relationFilters ?? {}), ...staffRelationFilters(field, context()), ...(request.filters ?? {}), },