From 0f2475098f7342df36b5bc3e0ffab7fdbff98234 Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Mon, 31 Aug 2026 22:04:22 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=90=88=E5=90=8C=E6=B0=94?= =?UTF-8?q?=E9=98=80=E5=80=99=E9=80=89=E4=B8=8E=E7=BB=91=E5=AE=9A=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/api/internal/logic/delivery/order.go | 67 ++++++++++++++++--- .../logic/delivery/order_candidate_test.go | 19 +++++- backend/api/internal/logic/gas/gasorder.go | 45 ++++++++++--- .../api/internal/logic/gas/gasorder_test.go | 28 +++++++- .../gasorder/contract_party_display.go | 16 ++++- .../gasorder/contract_product_candidate.go | 22 ++++++ .../logic/platform/gasorder/gasorder_test.go | 33 ++++++++- .../logic/platform/product/product.go | 5 +- docs/06-气站管理系统需求.md | 2 +- docs/07-配送点管理系统需求.md | 2 +- .../操作日志_合同气阀候选范围统一_20260831.md | 26 +++++++ ...作日志_配送点合同气瓶绑定入口_20260831.md | 30 +++++++++ docs/项目文档_合同气阀候选范围统一_v1.0.md | 31 +++++++++ docs/项目文档_配送点独立资源页面_v1.0.md | 2 + .../scripts/check-resource-pages.mjs | 11 ++- .../src/api/resource-detail-contract.ts | 2 +- .../src/api/resource-display.ts | 3 +- frontend/delivery_admin/src/api/resources.ts | 6 +- .../views/resource/ResourceDetailContent.vue | 2 +- .../src/views/resource/ResourceRecordPage.vue | 15 ++++- .../src/views/shared/ResourceListPage.vue | 22 +++++- .../scripts/check-resource-pages.mjs | 6 ++ frontend/gas_admin/src/api/resources.ts | 4 +- 23 files changed, 352 insertions(+), 47 deletions(-) create mode 100644 backend/api/internal/logic/platform/gasorder/contract_product_candidate.go create mode 100644 docs/操作日志_合同气阀候选范围统一_20260831.md create mode 100644 docs/操作日志_配送点合同气瓶绑定入口_20260831.md create mode 100644 docs/项目文档_合同气阀候选范围统一_v1.0.md diff --git a/backend/api/internal/logic/delivery/order.go b/backend/api/internal/logic/delivery/order.go index 9a4c993..83ebe6a 100644 --- a/backend/api/internal/logic/delivery/order.go +++ b/backend/api/internal/logic/delivery/order.go @@ -1,5 +1,5 @@ // 功能描述:实现配送点范围内的合同、合同气瓶和配送订单接口。 -// 版本:v1.4.1。 +// 版本:v1.5.0。 package delivery import ( @@ -22,6 +22,8 @@ import ( "gorm.io/gorm/clause" ) +var errContractUserOutsidePoint = errors.New("签约用户已不属于当前配送点,只允许查看或终止合同") + func rewriteJSON(ctx *gin.Context, mutate func(map[string]any) bool) bool { body, err := io.ReadAll(ctx.Request.Body) if err != nil { @@ -49,6 +51,16 @@ func scopedContract(ctx *gin.Context, identity string, pointID uint64) (models.G return contract, true } +// contractUserActiveAtPoint 校验合同用户仍属于合同气站及当前配送点。 +func contractUserActiveAtPoint(databaseService *gorm.DB, contract models.GasorderContract, pointID uint64) bool { + var count int64 + err := databaseService.Model(&models.UserServiceRelation{}). + Where("user_account_id = ? AND gas_basic_id = ? AND delivery_basic_id = ? AND status <> ?", + contract.UserAccountID, contract.GasBasicID, pointID, common.StatusArchived). + Count(&count).Error + return err == nil && count > 0 +} + // deliveryContractListQuery 构造当前配送点合同列表;订单候选模式额外限定可履约状态。 func deliveryContractListQuery(database *gorm.DB, gasID, pointID uint64, candidate string, now time.Time) *gorm.DB { query := platformgasorder.ContractPartyDisplayQuery(database). @@ -280,8 +292,15 @@ func BindContractProduct(ctx *gin.Context) { } if !rewriteJSON(ctx, func(values map[string]any) bool { identity, _ := values["gasorder_contract_identity"].(string) - _, valid := scopedContract(ctx, identity, point.ID) - return valid + contract, valid := scopedContract(ctx, identity, point.ID) + if !valid { + return false + } + if !contractUserActiveAtPoint(db(), contract, point.ID) { + infra.Response.Error(ctx, errContractUserOutsidePoint) + return false + } + return true }) { return } @@ -329,28 +348,54 @@ func GetContractRevision(ctx *gin.Context) { respondRecord(ctx, query, &item) } +// deliveryContractProductCandidateQuery 按合同用户及当前配送点服务关系限定候选气阀。 +func deliveryContractProductCandidateQuery(databaseService *gorm.DB, contract models.GasorderContract, pointID uint64) *gorm.DB { + query := common.ActiveRecords(databaseService.Model(&models.ProductInfo{})) + return platformgasorder.ContractProductCandidateQuery(query, contract). + Where(`EXISTS ( + SELECT 1 FROM user_service_relation AS candidate_relation + WHERE candidate_relation.user_account_id = product_info.user_account_id + AND candidate_relation.gas_basic_id = ? + AND candidate_relation.delivery_basic_id = ? + AND candidate_relation.status <> ? + AND candidate_relation.deleted_at IS NULL + )`, contract.GasBasicID, pointID, common.StatusArchived) +} + +// ListProductCandidate 仅返回属于指定合同用户且仍在当前配送点服务范围内的候选气阀。 func ListProductCandidate(ctx *gin.Context) { point, _, ok := currentScope(ctx) if !ok { return } - query := common.ActiveRecords(db().Model(&models.ProductInfo{})). - Joins("JOIN user_service_relation ON user_service_relation.user_account_id = product_info.user_account_id AND user_service_relation.status <> ?", - common.StatusArchived).Where("user_service_relation.delivery_basic_id = ?", point.ID) + query := common.ActiveRecords(db().Model(&models.ProductInfo{})).Where("1 = 0") + if contractIdentity := strings.TrimSpace(ctx.Query("contract_identity")); contractIdentity != "" { + contract, valid := scopedContract(ctx, contractIdentity, point.ID) + if !valid { + return + } + query = deliveryContractProductCandidateQuery(db(), contract, point.ID) + } listScoped(ctx, &models.ProductInfo{}, query, "product_info.created_at desc") } +// GetProductCandidate 返回指定合同用户范围内的单条候选气阀。 func GetProductCandidate(ctx *gin.Context) { point, _, ok := currentScope(ctx) if !ok { return } var item models.ProductInfo - query := common.ActiveRecords(db().Model(&models.ProductInfo{})). - Select("product_info.*"). - Joins("JOIN user_service_relation ON user_service_relation.user_account_id = product_info.user_account_id AND user_service_relation.status <> ?", - common.StatusArchived). - Where("product_info.identity = ? AND user_service_relation.delivery_basic_id = ?", ctx.Param("identity"), point.ID) + contractIdentity := strings.TrimSpace(ctx.Query("contract_identity")) + query := common.ActiveRecords(db().Model(&models.ProductInfo{})).Where("1 = 0") + if contractIdentity != "" { + contract, valid := scopedContract(ctx, contractIdentity, point.ID) + if !valid { + return + } + query = deliveryContractProductCandidateQuery(db(), contract, point.ID) + } + query = query.Select("product_info.*").Where("product_info.identity = ?", ctx.Param("identity")) respondRecord(ctx, query, &item) } diff --git a/backend/api/internal/logic/delivery/order_candidate_test.go b/backend/api/internal/logic/delivery/order_candidate_test.go index a432c87..e158354 100644 --- a/backend/api/internal/logic/delivery/order_candidate_test.go +++ b/backend/api/internal/logic/delivery/order_candidate_test.go @@ -1,5 +1,5 @@ // 功能描述:验证配送点创建订单的合同、地址和合同气瓶候选查询范围。 -// 版本:v1.0.0。 +// 版本:v1.1.0。 package delivery import ( @@ -7,6 +7,7 @@ import ( "testing" "time" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/DATA-DOG/go-sqlmock" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -76,3 +77,19 @@ func TestDeliveryOrderContractProductCandidates(t *testing.T) { "gasorder_contract_product.product_code", ) } + +// TestDeliveryContractProductBindingCandidates 验证配送点候选同时受合同用户和当前配送点服务关系约束。 +func TestDeliveryContractProductBindingCandidates(t *testing.T) { + database := candidateTestDatabase(t) + statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB { + contract := models.GasorderContract{Entity: models.Entity{ID: 27}, UserAccountID: 31, GasBasicID: 7, DeliveryBasicID: 9} + return deliveryContractProductCandidateQuery(tx, contract, 9).Find(&[]models.ProductInfo{}) + }) + assertSQLFragments(t, statement, + "product_info.user_account_id = 31", + "candidate_binding.gasorder_contract_id = 27", + "candidate_relation.gas_basic_id = 7", + "candidate_relation.delivery_basic_id = 9", + "candidate_relation.user_account_id = product_info.user_account_id", + ) +} diff --git a/backend/api/internal/logic/gas/gasorder.go b/backend/api/internal/logic/gas/gasorder.go index 4d3edb6..3aacbda 100644 --- a/backend/api/internal/logic/gas/gasorder.go +++ b/backend/api/internal/logic/gas/gasorder.go @@ -1,5 +1,5 @@ // 功能描述:实现气站范围内的配送合同、合同气瓶和燃气配送订单接口。 -// 版本:v1.2.0 +// 版本:v1.3.0 package gas import ( @@ -233,29 +233,52 @@ func GetGasorderContractProduct(ctx *gin.Context) { respondScopedRecord(ctx, query, &models.GasorderContractProduct{}) } -// ListContractProductCandidate 仅返回当前气站服务用户名下可用于合同绑定的气瓶。 +// gasContractProductCandidateQuery 按合同用户和当前气站服务关系限定可绑定智能气阀。 +func gasContractProductCandidateQuery(databaseService *gorm.DB, contract models.GasorderContract, gasBasicID uint64) *gorm.DB { + query := common.ActiveRecords(databaseService.Model(&models.ProductInfo{})) + return platformgasorder.ContractProductCandidateQuery(query, contract). + Where(`EXISTS ( + SELECT 1 FROM user_service_relation AS candidate_relation + WHERE candidate_relation.user_account_id = product_info.user_account_id + AND candidate_relation.gas_basic_id = ? + AND candidate_relation.status <> ? + AND candidate_relation.deleted_at IS NULL + )`, gasBasicID, common.StatusArchived) +} + +// ListContractProductCandidate 仅返回属于指定合同用户且仍在当前气站服务范围内的可绑定气阀。 func ListContractProductCandidate(ctx *gin.Context) { station, ok := currentGas(ctx) if !ok { return } - query := common.ActiveRecords(impl.DBService.Model(&models.ProductInfo{})). - Joins("JOIN user_service_relation ON user_service_relation.user_account_id = product_info.user_account_id AND user_service_relation.status <> ?", - common.StatusArchived). - Where("user_service_relation.gas_basic_id = ?", station.ID) + query := common.ActiveRecords(impl.DBService.Model(&models.ProductInfo{})).Where("1 = 0") + if contractIdentity := strings.TrimSpace(ctx.Query("contract_identity")); contractIdentity != "" { + contract, valid := scopedContract(ctx, contractIdentity, station.ID) + if !valid { + return + } + query = gasContractProductCandidateQuery(impl.DBService, contract, station.ID) + } listScoped(ctx, &models.ProductInfo{}, query, "product_info.created_at desc") } -// GetContractProductCandidate 返回当前气站服务用户范围内的单条候选气瓶。 +// GetContractProductCandidate 返回指定合同用户范围内的单条候选气阀。 func GetContractProductCandidate(ctx *gin.Context) { station, ok := currentGas(ctx) if !ok { return } - query := common.ActiveRecords(impl.DBService.Model(&models.ProductInfo{})). - Select("product_info.*"). - Joins("JOIN user_service_relation ON user_service_relation.user_account_id = product_info.user_account_id AND user_service_relation.status <> ?", common.StatusArchived). - Where("product_info.identity = ? AND user_service_relation.gas_basic_id = ?", ctx.Param("identity"), station.ID) + contractIdentity := strings.TrimSpace(ctx.Query("contract_identity")) + query := common.ActiveRecords(impl.DBService.Model(&models.ProductInfo{})).Where("1 = 0") + if contractIdentity != "" { + contract, valid := scopedContract(ctx, contractIdentity, station.ID) + if !valid { + return + } + query = gasContractProductCandidateQuery(impl.DBService, contract, station.ID) + } + query = query.Select("product_info.*").Where("product_info.identity = ?", ctx.Param("identity")) respondScopedRecord(ctx, query, &models.ProductInfo{}) } diff --git a/backend/api/internal/logic/gas/gasorder_test.go b/backend/api/internal/logic/gas/gasorder_test.go index 82dc996..bfd33b3 100644 --- a/backend/api/internal/logic/gas/gasorder_test.go +++ b/backend/api/internal/logic/gas/gasorder_test.go @@ -1,11 +1,12 @@ // 功能描述:验证气站合同与配送订单展示查询的对象权限范围。 -// 版本:v1.0.0 +// 版本:v1.1.0。 package gas import ( "strings" "testing" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/DATA-DOG/go-sqlmock" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -35,3 +36,28 @@ func TestScopedOrderDisplayQuery(t *testing.T) { } } } + +// TestGasContractProductCandidates 验证气站候选按指定合同用户过滤,而非返回本站全部用户设备。 +func TestGasContractProductCandidates(t *testing.T) { + connection, _, err := sqlmock.New() + if err != nil { + t.Fatalf("创建 SQL 模拟失败:%v", err) + } + t.Cleanup(func() { _ = connection.Close() }) + databaseService, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{DryRun: true}) + if err != nil { + t.Fatalf("创建 GORM 数据库失败:%v", err) + } + statement := gasContractProductCandidateQuery(databaseService, models.GasorderContract{Entity: models.Entity{ID: 17}, UserAccountID: 23}, 42). + Find(&[]models.ProductInfo{}).Statement.SQL.String() + for _, fragment := range []string{ + "product_info.user_account_id =", + "candidate_binding.gasorder_contract_id =", + "candidate_relation.gas_basic_id =", + "candidate_relation.user_account_id = product_info.user_account_id", + } { + if !strings.Contains(statement, fragment) { + t.Fatalf("气站合同气阀候选 SQL 缺少 %q:%s", fragment, statement) + } + } +} diff --git a/backend/api/internal/logic/platform/gasorder/contract_party_display.go b/backend/api/internal/logic/platform/gasorder/contract_party_display.go index 36c8f69..fa7d341 100644 --- a/backend/api/internal/logic/platform/gasorder/contract_party_display.go +++ b/backend/api/internal/logic/platform/gasorder/contract_party_display.go @@ -1,5 +1,5 @@ // 功能描述:为配送合同提供关联主体名称和当前服务关系状态,不新增数据库字段。 -// 版本:v1.1.0 +// 版本:v1.2.0 package gasorder import ( @@ -15,6 +15,7 @@ type ContractPartyDisplay struct { GasBasicDisplayName string `gorm:"column:gas_basic_display_name" json:"gas_basic_display_name"` DeliveryDisplayName string `gorm:"column:delivery_basic_display_name" json:"delivery_basic_display_name"` UserServiceActive bool `gorm:"column:user_service_active" json:"user_service_active"` + UserDeliveryActive bool `gorm:"column:user_delivery_service_active" json:"user_delivery_service_active"` } // ContractPartyDisplayQuery 仅通过合同已保存的关联主键读取名称,不开放平台主体全集。 @@ -33,7 +34,18 @@ func ContractPartyDisplayQuery(databaseService *gorm.DB) *gorm.DB { AND contract_relation.gas_basic_id = gasorder_contract.gas_basic_id AND contract_relation.status <> ? AND contract_relation.deleted_at IS NULL - ) AS user_service_active`, common.StatusArchived). + ) AS user_service_active, + CASE + WHEN gasorder_contract.delivery_basic_id = 0 THEN FALSE + ELSE EXISTS ( + SELECT 1 FROM user_service_relation AS delivery_relation + WHERE delivery_relation.user_account_id = gasorder_contract.user_account_id + AND delivery_relation.gas_basic_id = gasorder_contract.gas_basic_id + AND delivery_relation.delivery_basic_id = gasorder_contract.delivery_basic_id + AND delivery_relation.status <> ? + AND delivery_relation.deleted_at IS NULL + ) + END AS user_delivery_service_active`, common.StatusArchived, common.StatusArchived). Joins("LEFT JOIN user_account AS contract_user ON contract_user.id = gasorder_contract.user_account_id"). Joins("LEFT JOIN gas_basic AS contract_gas ON contract_gas.id = gasorder_contract.gas_basic_id"). Joins("LEFT JOIN delivery_basic AS contract_delivery ON contract_delivery.id = gasorder_contract.delivery_basic_id") diff --git a/backend/api/internal/logic/platform/gasorder/contract_product_candidate.go b/backend/api/internal/logic/platform/gasorder/contract_product_candidate.go new file mode 100644 index 0000000..c9dabbc --- /dev/null +++ b/backend/api/internal/logic/platform/gasorder/contract_product_candidate.go @@ -0,0 +1,22 @@ +// 功能描述:提供跨管理端一致的合同可绑定智能气阀候选查询。 +// 版本:v1.0.0。 +package gasorder + +import ( + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "gorm.io/gorm" +) + +// ContractProductCandidateQuery 仅返回属于合同用户、可用、未报废且未重复绑定的智能气阀。 +func ContractProductCandidateQuery(query *gorm.DB, contract models.GasorderContract) *gorm.DB { + return query. + Where("product_info.status = ? AND product_info.product_status <> ? AND product_info.user_account_id = ?", + common.StatusEnable, common.StatusScrapped, contract.UserAccountID). + Where(`NOT EXISTS ( + SELECT 1 FROM gasorder_contract_product AS candidate_binding + WHERE candidate_binding.product_info_id = product_info.id + AND candidate_binding.gasorder_contract_id = ? + AND candidate_binding.unbound_at IS NULL + )`, contract.ID) +} diff --git a/backend/api/internal/logic/platform/gasorder/gasorder_test.go b/backend/api/internal/logic/platform/gasorder/gasorder_test.go index 0d9e2cb..b4547d5 100644 --- a/backend/api/internal/logic/platform/gasorder/gasorder_test.go +++ b/backend/api/internal/logic/platform/gasorder/gasorder_test.go @@ -1,5 +1,5 @@ // 功能描述:验证平台配送合同、订单创建和调度状态规则。 -// 版本:v1.3.0 +// 版本:v1.4.0。 package gasorder import ( @@ -100,6 +100,8 @@ func TestContractPartyDisplayQuery(t *testing.T) { "delivery_basic_display_name", "contract_relation.user_account_id = gasorder_contract.user_account_id", "contract_relation.gas_basic_id = gasorder_contract.gas_basic_id", + "delivery_relation.delivery_basic_id = gasorder_contract.delivery_basic_id", + "user_delivery_service_active", "gasorder_contract.gas_basic_id = 42", } { if !strings.Contains(statement, fragment) { @@ -108,6 +110,35 @@ func TestContractPartyDisplayQuery(t *testing.T) { } } +// TestContractProductCandidateQuery 验证三端共享候选严格按合同用户和设备可用性筛选。 +func TestContractProductCandidateQuery(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) + } + statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB { + query := common.ActiveRecords(tx.Model(&models.ProductInfo{})) + return ContractProductCandidateQuery(query, models.GasorderContract{Entity: models.Entity{ID: 17}, UserAccountID: 23}). + Find(&[]models.ProductInfo{}) + }) + for _, fragment := range []string{ + "product_info.status = 1", + "product_info.product_status <> 27", + "product_info.user_account_id = 23", + "candidate_binding.gasorder_contract_id = 17", + "candidate_binding.unbound_at IS NULL", + } { + if !strings.Contains(statement, fragment) { + t.Fatalf("合同气阀候选 SQL 缺少 %q:%s", fragment, statement) + } + } +} + // TestFilterGasorderContractProductCandidates 验证订单候选仅包含当前合同未解绑气瓶并按业务字段排序。 func TestFilterGasorderContractProductCandidates(t *testing.T) { sqlDatabase, _, err := sqlmock.New() diff --git a/backend/api/internal/logic/platform/product/product.go b/backend/api/internal/logic/platform/product/product.go index efc7d9a..95fe192 100644 --- a/backend/api/internal/logic/platform/product/product.go +++ b/backend/api/internal/logic/platform/product/product.go @@ -11,6 +11,7 @@ import ( "git.apinb.com/bsm-sdk/core/middleware" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + platformgasorder "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/platform/gasorder" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -47,9 +48,7 @@ func listProductInfo(ctx *gin.Context) { 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) + return platformgasorder.ContractProductCandidateQuery(query, contract) }) } diff --git a/docs/06-气站管理系统需求.md b/docs/06-气站管理系统需求.md index f51c208..a333ae2 100644 --- a/docs/06-气站管理系统需求.md +++ b/docs/06-气站管理系统需求.md @@ -106,7 +106,7 @@ - 配送合同创建、草稿编辑和续签必须填写到期时间,且到期时间必须晚于生效时间;历史缺失值应提示补录,不得推测回填。 - 合同只能选择当前气站用户及当前气站配送点。 - 合同附件仅支持单个不超过 10 MiB 的 PDF;创建和草稿编辑时通过受控接口上传、替换或移除,详情页通过气站鉴权和合同归属校验后预览,不向前端暴露内部存储地址。 -- 合同气瓶支持绑定和受控解绑。 +- 合同气瓶支持绑定和受控解绑;绑定候选必须按所选合同筛选,只允许选择属于合同签约用户、已启用、未报废且未重复绑定的智能气阀。缺少合同上下文时不得返回当前气站其他用户的设备;无候选设备时应引导平台总后台先将设备归属变更为合同用户。 - 合同修订记录只读,不允许修改或删除。 - 合同修订记录不展示公共软删除时间等无业务意义的基础设施字段。 - 合同状态只能通过专用动作接口推进,不能通用编辑 `contract_status`。 diff --git a/docs/07-配送点管理系统需求.md b/docs/07-配送点管理系统需求.md index b42ae1f..ed2c40d 100644 --- a/docs/07-配送点管理系统需求.md +++ b/docs/07-配送点管理系统需求.md @@ -113,7 +113,7 @@ Global: - 配送点可完整管理当前配送点用户的配送合同。 - 支持创建、草稿编辑、启用、续签和终止。 - 启用仅适用于草稿合同;已终止合同不得直接启用,只能通过续签填写新的生效时间、到期时间和原因后恢复履约。 -- 支持绑定和受控解绑当前用户已有气瓶。 +- 支持绑定和受控解绑当前用户已有气瓶;绑定候选必须按所选合同筛选,只允许选择属于合同签约用户、已启用、未报废且未重复绑定的智能气阀,并要求该用户当前仍属于本配送点。缺少合同上下文时不得返回本点其他用户的设备;无候选设备时应引导平台总后台先将设备归属变更为合同用户。 - 合同修订记录只读。 - 服务端强制合同所属气站和配送点为当前范围。 - 不得查看或操作所属气站下其他配送点的合同。 diff --git a/docs/操作日志_合同气阀候选范围统一_20260831.md b/docs/操作日志_合同气阀候选范围统一_20260831.md new file mode 100644 index 0000000..8340619 --- /dev/null +++ b/docs/操作日志_合同气阀候选范围统一_20260831.md @@ -0,0 +1,26 @@ +# 合同气阀候选范围统一操作日志 + +操作时间:2026-08-31 +操作类型:修改 +影响模块:平台合同候选、气站合同、配送点合同 + +## 操作前状态 + +平台总后台按合同用户筛选可绑定智能气阀;气站端按当前气站全部服务用户筛选,配送点端按当前配送点全部服务用户筛选。组织端可能显示其他用户的设备,配送点绑定入口也未单独判断用户是否仍属于当前配送点。 + +## 具体操作 + +1. 提取平台、气站和配送点共用的合同智能气阀候选规则。 +2. 气站和配送点候选接口要求合同上下文,并校验当前组织范围。 +3. 配送点合同响应增加当前配送点服务关系派生状态。 +4. 配送点绑定接口增加当前配送点服务关系校验。 +5. 两个组织端的智能气阀下拉携带合同标识,并统一空状态引导。 +6. 增加 SQL 范围测试和前端静态契约检查。 + +## 操作后状态 + +三个管理端均只展示当前合同用户可绑定的智能气阀。缺少合同上下文时不返回组织范围设备;配送点用户转出后不能再从页面或接口绑定气阀。 + +## 风险评估 + +候选范围较原组织端更严格,历史上依赖组织级宽泛候选的错误操作将不再可用。合同气瓶最终绑定接口原有的设备归属校验保持不变,不影响合法绑定记录及历史数据。 diff --git a/docs/操作日志_配送点合同气瓶绑定入口_20260831.md b/docs/操作日志_配送点合同气瓶绑定入口_20260831.md new file mode 100644 index 0000000..92d4c97 --- /dev/null +++ b/docs/操作日志_配送点合同气瓶绑定入口_20260831.md @@ -0,0 +1,30 @@ +# 操作日志:配送点合同气瓶绑定入口 + +操作时间:2026-08-31 +操作类型:扩展 +影响模块:配送点合同列表、合同气瓶新建页 + +## 操作前状态 + +配送点需求、后端接口和隐藏子路由均支持合同气瓶绑定,但配送合同列表没有进入新建页的入口。 + +## 具体操作 + +1. 草稿合同且签约用户服务关系有效时显示“绑定气瓶”。 +2. 点击后进入合同气瓶新建页,并传递当前合同唯一标识和返回地址。 +3. 从列表进入时锁定预填合同,禁止切换到其他合同。 +4. 增加入口、状态条件、目标路由和合同锁定的静态契约检查。 + +## 操作后状态 + +配送点管理员可从草稿合同所在行直接绑定气瓶;非草稿合同不显示入口。 + +## 验证结果 + +- `npm.cmd run type:check`:通过。 +- `npm.cmd run resource-pages:check`:通过,详情 17、新建 9、编辑 5。 +- `npm.cmd run build`:通过,2648 个模块完成生产构建。 + +## 风险评估 + +复用既有隐藏路由、接口与权限模型,仅增加缺失的前端入口和上下文锁定,不改变合同状态或数据范围。 diff --git a/docs/项目文档_合同气阀候选范围统一_v1.0.md b/docs/项目文档_合同气阀候选范围统一_v1.0.md new file mode 100644 index 0000000..cbb74f0 --- /dev/null +++ b/docs/项目文档_合同气阀候选范围统一_v1.0.md @@ -0,0 +1,31 @@ +# 项目文档:合同气阀候选范围统一 v1.0 + +## 1. 项目概述 + +平台总后台、气站后台和配送点后台统一使用合同级智能气阀候选规则。组织端不再按全部服务用户返回设备,避免其他用户的智能气阀进入当前合同候选。 + +## 2. 核心规则 + +- 候选请求必须提供 `contract_identity`,缺少合同上下文时返回空列表。 +- 智能气阀必须直接归属于合同签约用户。 +- 智能气阀记录必须启用,设备生命周期不得为已报废。 +- 已在当前合同中有效绑定的设备不得重复进入候选。 +- 气站端要求合同用户当前仍属于合同气站。 +- 配送点端要求合同用户当前仍属于合同气站及当前配送点。 +- 配送点用户转出后隐藏绑定入口,绑定接口继续执行相同的服务端范围校验。 + +## 3. 页面行为 + +- 合同气瓶新建页先取得已锁定的合同,再携带 `contract_identity` 加载智能气阀候选。 +- 切换合同会清空原智能气阀选择并重新加载。 +- 无候选设备时提示管理员由平台总后台先将设备归属变更为合同用户。 + +## 4. 接口兼容性 + +继续使用现有 `/product_info` 候选资源和合同气瓶绑定接口,不新增数据库字段。新增的 `user_delivery_service_active` 是合同查询派生字段,用于配送点页面和服务端动作范围判断,不修改合同表结构。 + +## 5. 验证方法 + +- 运行平台合同、产品、气站合同及配送点订单逻辑测试。 +- 运行气站与配送点资源页面静态契约检查。 +- 运行两个组织端的 TypeScript 类型检查和生产构建。 diff --git a/docs/项目文档_配送点独立资源页面_v1.0.md b/docs/项目文档_配送点独立资源页面_v1.0.md index 8792a8c..836c108 100644 --- a/docs/项目文档_配送点独立资源页面_v1.0.md +++ b/docs/项目文档_配送点独立资源页面_v1.0.md @@ -58,6 +58,8 @@ frontend/delivery_admin/ 创建配送订单时以配送合同为父级,收货地址只加载合同签约用户地址,合同气瓶只加载当前未解绑绑定;切换合同清空旧值并丢弃过期响应。候选为空或仍在加载时禁止保存,金额区只提供预估,最终金额继续由服务端计算。详细规则见《项目文档_配送点订单创建联动_v1.0.md》。 +配送合同列表仅对草稿合同且签约用户服务关系仍有效的记录显示“绑定气瓶”。入口进入合同气瓶独立新建页,自动带入并锁定当前合同,保存或取消后可返回原合同列表;生效、过期、终止合同不显示该入口。 + 订单详情的“调整金额”动作读取当前配送费和优惠金额作为弹窗初始值,并完成分到元的展示转换;本次操作原因继续单独填写,不复用历史原因。 配送人员资质列表必须由配送人员列表进入。页面先通过受保护人员详情接口回查姓名,再以当前气站、配送点、`delivery` 角色和人员 ID 四重条件加载资质;缺少、无效或越权人员上下文时停止加载,绝不回退为全部资质。标题和上下文区域显示人员姓名及可复制唯一标识,表格隐藏重复人员列。新建页自动预填所属人员,创建和编辑均锁定该关系,返回地址只接受安全站内路径。 diff --git a/frontend/delivery_admin/scripts/check-resource-pages.mjs b/frontend/delivery_admin/scripts/check-resource-pages.mjs index 5e0fda4..f48dfe0 100644 --- a/frontend/delivery_admin/scripts/check-resource-pages.mjs +++ b/frontend/delivery_admin/scripts/check-resource-pages.mjs @@ -77,6 +77,10 @@ assert(listPage.includes('avatarLoader.reset()'), '列表刷新未清理头像 assert(listPage.includes('field.listRelationNameOnly && fieldValue(field, record)'), '关系列表字段尚未按气站端显示业务名称'); assert(listPage.includes('relationListName(field, record)'), '关系列表字段缺少服务端派生名称读取'); assert(listPage.includes('@open="openRelation(field, record)"'), '关系列表名称缺少关联详情入口'); +assert(listPage.includes("Number(record.contract_status) === 0"), '草稿合同缺少绑定气瓶入口状态限制'); +assert(listPage.includes('record.user_delivery_service_active !== false'), '合同气瓶入口未校验用户仍属当前配送点'); +assert(listPage.includes('@click="bindContractProduct(record)"'), '配送合同列表缺少绑定气瓶入口'); +assert(listPage.includes("name: 'contract-products-create'"), '绑定气瓶入口未进入合同气瓶新建页'); const avatarLoader = read('src/views/shared/protected-list-avatar-loader.ts'); assert(avatarLoader.includes('MAX_CONCURRENT_REQUESTS = 6'), '头像请求并发上限未与 5173 对齐'); assert(avatarLoader.includes("new Set(['staff_account', 'user_account'])"), '头像列表资源白名单不正确'); @@ -97,6 +101,8 @@ assert(!listPage.includes('placeholder="关键字段模糊搜索"'), '列表仍 assert(listPage.includes('ensureCredentialContext'), '人员资质列表缺少服务端人员上下文校验'); assert(listPage.includes("field.key === 'staff_account_identity'"), '人员范围资质列表未隐藏重复人员列'); assert(recordPage.includes('已锁定,不可更换'), '人员资质独立页未锁定所属配送人员'); +assert(recordPage.includes("definition.value.name === 'gasorder_contract_product'"), '合同气瓶新建页未识别合同预填上下文'); +assert(recordPage.includes("field.key === 'gasorder_contract_identity'"), '预填合同未在合同气瓶新建页锁定'); const attachmentApi = read('src/api/contract-attachment.ts'); const attachmentState = read('src/views/resource/use-contract-attachment.ts'); const detailContent = read('src/views/resource/ResourceDetailContent.vue'); @@ -124,15 +130,16 @@ assert(detailContent.includes("key.endsWith('_display_name')"), '详情页仍可 assert(detailContent.includes('hasDetailFieldLabel'), '详情页未启用中文字段白名单'); assert(detailContent.includes('查看参数'), '关联记录缺少完整 JSON 查看入口'); assert(detailContent.includes('智能气阀名称取当前设备资料'), '合同气瓶名称与快照语义未说明'); -assert(resourceDisplay.includes("user_service_active: '签约用户仍属合同气站'"), '签约服务关系缺少中文业务标签'); +assert(resourceDisplay.includes("user_delivery_service_active: '签约用户仍属当前配送点'"), '当前配送点服务关系缺少中文业务标签'); assert(resourceDisplay.includes("activate: '启用合同'"), '合同修订动作未中文化'); -assert(recordPage.includes("record.value.user_service_active === false"), '失效服务关系下未限制合同动作'); +assert(recordPage.includes("record.value.user_delivery_service_active === false"), '用户转出当前配送点后未限制合同动作'); assert(definitions.includes("relationFilters: { candidate: 'order' }"), '订单合同候选未限定可履约范围'); assert(definitions.includes("f('request_no', { required: true })"), '请求流水号未按 5173 保持人工必填'); assert(definitions.includes("f('contact_name', { required: true })"), '订单联系人未保持独立必填'); assert(definitions.includes("f('contact_phone', { required: true })"), '订单联系电话未保持独立必填'); assert(definitions.includes("filterKey: 'gasorder_contract_identity'"), '收货地址未按配送合同联动'); assert(definitions.includes("filterKey: 'contract_identity'"), '合同气瓶未按配送合同联动'); +assert(definitions.includes('该合同用户暂无可绑定的智能气阀,请先由平台总后台将设备归属变更为该用户'), '合同气阀空状态缺少平台归属引导'); assert(definitions.includes("relationOptionLabelKey: 'address'"), '收货地址候选仍可能显示裸唯一标识'); assert(definitions.includes("relationOptionDisplay: 'product-name-type'"), '合同气瓶缺少名称、编码和类型组合展示'); assert(relationLinkage.includes('const versions = new Map'), '关系联动缺少过期响应版本守卫'); diff --git a/frontend/delivery_admin/src/api/resource-detail-contract.ts b/frontend/delivery_admin/src/api/resource-detail-contract.ts index 30377fb..b6281ac 100644 --- a/frontend/delivery_admin/src/api/resource-detail-contract.ts +++ b/frontend/delivery_admin/src/api/resource-detail-contract.ts @@ -22,7 +22,7 @@ const contracts: Record = { 'contract_status', 'title', 'user_account_identity', - 'user_service_active', + 'user_delivery_service_active', 'gas_basic_identity', 'delivery_basic_identity', 'default_delivery_fee', diff --git a/frontend/delivery_admin/src/api/resource-display.ts b/frontend/delivery_admin/src/api/resource-display.ts index b43488d..82bc7ca 100644 --- a/frontend/delivery_admin/src/api/resource-display.ts +++ b/frontend/delivery_admin/src/api/resource-display.ts @@ -1,6 +1,6 @@ /** * 功能描述:统一配送点资源独立页面的字段名称和值展示。 - * 版本:v1.1.0。 + * 版本:v1.2.0。 */ import dayjs from 'dayjs'; import type { ResourceRow } from './resource-record-form'; @@ -26,6 +26,7 @@ const aliases: Record = { contract: '合同', order: '订单', user_service_active: '签约用户仍属合同气站', + user_delivery_service_active: '签约用户仍属当前配送点', user_account_display_name: '用户账户', gas_basic_display_name: '气站', delivery_basic_display_name: '配送点', diff --git a/frontend/delivery_admin/src/api/resources.ts b/frontend/delivery_admin/src/api/resources.ts index 93d3533..57336c5 100644 --- a/frontend/delivery_admin/src/api/resources.ts +++ b/frontend/delivery_admin/src/api/resources.ts @@ -421,7 +421,7 @@ const platformResources: ResourceUiDefinition[] = [ { name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12, 13] } }, { name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } }, ], { 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), relation('product_info_identity', '/product_info', true, { label: '智能气阀', placeholder: '请选择该合同用户可绑定的智能气阀', relationEmptyText: '该合同用户暂无可绑定的智能气阀,请先由平台总后台将设备归属变更为该用户', relationLinkage: { parentKey: 'gasorder_contract_identity', filterKey: 'contract_identity', requiresParent: true, parentChangeMessage: '配送合同已变更,请重新选择该合同用户的智能气阀' } }), f('unit_price')], 'list', [ { name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason }, ]), define('gasorder_contract_revision', '合同修订记录', 'readonly', []), @@ -514,7 +514,7 @@ const gasOverrides: ResourceUiDefinition[] = [ { name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12, 13] } }, { name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } }, ], { 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), relation('product_info_identity', '/product_info', true, { label: '智能气阀', placeholder: '请选择该合同用户可绑定的智能气阀', relationEmptyText: '该合同用户暂无可绑定的智能气阀,请先由平台总后台将设备归属变更为该用户', relationLinkage: { parentKey: 'gasorder_contract_identity', filterKey: 'contract_identity', requiresParent: true, parentChangeMessage: '配送合同已变更,请重新选择该合同用户的智能气阀' } }), f('unit_price')], 'list', [ { name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason }, ]), define('gasorder_contract_revision', '合同修订记录', 'readonly', []), @@ -553,7 +553,7 @@ const deliveryOverrides: ResourceUiDefinition[] = [ { name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12, 13] } }, { name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } }, ], { 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), relation('product_info_identity', '/product_info', true, { label: '智能气阀', placeholder: '请选择该合同用户可绑定的智能气阀', relationEmptyText: '该合同用户暂无可绑定的智能气阀,请先由平台总后台将设备归属变更为该用户', relationLinkage: { parentKey: 'gasorder_contract_identity', filterKey: 'contract_identity', requiresParent: true, parentChangeMessage: '配送合同已变更,请重新选择该合同用户的智能气阀' } }), f('unit_price')], 'list', [ { name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason }, ]), define('gasorder_contract_revision', '合同修订记录', 'readonly', []), diff --git a/frontend/delivery_admin/src/views/resource/ResourceDetailContent.vue b/frontend/delivery_admin/src/views/resource/ResourceDetailContent.vue index 7f345e5..dab0cb7 100644 --- a/frontend/delivery_admin/src/views/resource/ResourceDetailContent.vue +++ b/frontend/delivery_admin/src/views/resource/ResourceDetailContent.vue @@ -20,7 +20,7 @@ :color="contractStatusColor(Number(entry.rawValue))" >{{ entry.value }} {{ entry.value }} diff --git a/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue b/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue index 51de9a9..199966a 100644 --- a/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue +++ b/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue @@ -1,4 +1,4 @@ - +