diff --git a/backend/api/internal/logic/delivery/order.go b/backend/api/internal/logic/delivery/order.go index da98c7e..7f4ad82 100644 --- a/backend/api/internal/logic/delivery/order.go +++ b/backend/api/internal/logic/delivery/order.go @@ -1,5 +1,5 @@ // 功能描述:实现配送点范围内的合同、合同气瓶和配送订单接口。 -// 版本:v1.1.0。 +// 版本:v1.2.0。 package delivery import ( @@ -49,23 +49,33 @@ func scopedContract(ctx *gin.Context, identity string, pointID uint64) (models.G return contract, true } +// deliveryContractListQuery 构造当前配送点合同列表;订单候选模式额外限定可履约状态。 +func deliveryContractListQuery(database *gorm.DB, gasID, pointID uint64, candidate string, now time.Time) *gorm.DB { + query := platformgasorder.ContractPartyDisplayQuery(database). + Where("gasorder_contract.gas_basic_id = ? AND gasorder_contract.delivery_basic_id = ?", gasID, pointID) + if candidate == "order" { + return platformgasorder.FilterGasorderContractCandidates(query, "order", now) + } + return query +} + func ListContract(ctx *gin.Context) { point, _, ok := currentScope(ctx) if !ok { return } page, size := common.PageSize(ctx) - query := common.ApplyKeywordFilter(ctx, - common.ActiveRecords(db().Model(&models.GasorderContract{})). - Where("gas_basic_id = ? AND delivery_basic_id = ?", point.GasBasicID, point.ID), - &models.GasorderContract{}) + query := deliveryContractListQuery( + db(), point.GasBasicID, point.ID, strings.TrimSpace(ctx.Query("candidate")), time.Now(), + ) + query = common.ApplyKeywordFilter(ctx, query, &models.GasorderContract{}) var total int64 if err := query.Count(&total).Error; err != nil { infra.Response.Error(ctx, err) return } - var list []models.GasorderContract - if err := query.Order("gasorder_contract.created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { + var list []platformgasorder.ContractPartyDisplay + if err := query.Order("gasorder_contract.created_at desc").Offset((page - 1) * size).Limit(size).Scan(&list).Error; err != nil { infra.Response.Error(ctx, err) return } @@ -74,7 +84,11 @@ func ListContract(ctx *gin.Context) { infra.Response.Error(ctx, err) return } - protected, err := protectDeliveryContractListResponse(ctx, response, list) + contracts := make([]models.GasorderContract, 0, len(list)) + for _, item := range list { + contracts = append(contracts, item.GasorderContract) + } + protected, err := protectDeliveryContractListResponse(ctx, response, contracts) if err != nil { infra.Response.Error(ctx, err) return @@ -186,15 +200,63 @@ func TerminateContract(ctx *gin.Context) { withContract(ctx, platformgasorder.TerminateGasorderContract) } +// deliveryContractProductQuery 构造当前配送点合同气瓶查询;指定合同时仅保留未解绑候选。 +func deliveryContractProductQuery(database *gorm.DB, pointID, contractID uint64, keyword string) *gorm.DB { + query := common.ActiveRecords(database.Model(&models.GasorderContractProduct{})). + Select("gasorder_contract_product.*, COALESCE(display_product.name, '') AS product_name"). + Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_product.gasorder_contract_id"). + Joins("LEFT JOIN product_info AS display_product ON display_product.id = gasorder_contract_product.product_info_id AND display_product.deleted_at IS NULL AND display_product.status <> ?", common.StatusArchived). + Where("gasorder_contract.delivery_basic_id = ?", pointID) + if contractID > 0 { + query = query.Where("gasorder_contract_product.gasorder_contract_id = ? AND gasorder_contract_product.unbound_at IS NULL", contractID) + } + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if keyword == "" { + return query + } + pattern := "%" + keyword + "%" + return query.Where("(LOWER(COALESCE(display_product.name, '')) LIKE ? OR LOWER(gasorder_contract_product.product_code) LIKE ? OR LOWER(gasorder_contract_product.product_type_name) LIKE ?)", pattern, pattern, pattern) +} + func ListContractProduct(ctx *gin.Context) { point, _, ok := currentScope(ctx) if !ok { return } - query := common.ActiveRecords(db().Model(&models.GasorderContractProduct{})). - Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_product.gasorder_contract_id"). - Where("gasorder_contract.delivery_basic_id = ?", point.ID) - listScoped(ctx, &models.GasorderContractProduct{}, query, "gasorder_contract_product.created_at desc") + contractIdentity := strings.TrimSpace(ctx.Query("contract_identity")) + var contractID uint64 + if contractIdentity != "" { + contract, valid := scopedContract(ctx, contractIdentity, point.ID) + if !valid { + return + } + contractID = contract.ID + } + query := deliveryContractProductQuery(db(), point.ID, contractID, ctx.Query("keyword")) + page, size := common.PageSize(ctx) + var total int64 + if err := query.Count(&total).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + type contractProductDisplay struct { + models.GasorderContractProduct + ProductName string `gorm:"column:product_name" json:"product_name"` + } + var list []contractProductDisplay + if err := query.Order("gasorder_contract_product.created_at desc").Offset((page - 1) * size).Limit(size).Scan(&list).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + response, err := common.PublicResourceResponse(list) + if err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{ + "total": total, + "list": common.ProtectPreciseLocation(ctx, &models.GasorderContractProduct{}, response), + }) } func GetContractProduct(ctx *gin.Context) { diff --git a/backend/api/internal/logic/delivery/order_candidate_test.go b/backend/api/internal/logic/delivery/order_candidate_test.go new file mode 100644 index 0000000..a432c87 --- /dev/null +++ b/backend/api/internal/logic/delivery/order_candidate_test.go @@ -0,0 +1,78 @@ +// 功能描述:验证配送点创建订单的合同、地址和合同气瓶候选查询范围。 +// 版本:v1.0.0。 +package delivery + +import ( + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +// candidateTestDatabase 创建只用于生成 SQL 的模拟数据库。 +func candidateTestDatabase(t *testing.T) *gorm.DB { + t.Helper() + sqlDatabase, _, err := sqlmock.New() + if err != nil { + t.Fatalf("创建模拟数据库失败:%v", err) + } + t.Cleanup(func() { _ = sqlDatabase.Close() }) + database, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDatabase}), &gorm.Config{}) + if err != nil { + t.Fatalf("创建 GORM 数据库失败:%v", err) + } + return database +} + +// assertSQLFragments 验证候选查询包含全部范围约束。 +func assertSQLFragments(t *testing.T, statement string, fragments ...string) { + t.Helper() + for _, fragment := range fragments { + if !strings.Contains(statement, fragment) { + t.Fatalf("候选 SQL 缺少 %q:%s", fragment, statement) + } + } +} + +func TestDeliveryOrderContractCandidates(t *testing.T) { + database := candidateTestDatabase(t) + statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB { + return deliveryContractListQuery(tx, 7, 9, "order", time.Now()).Find(&[]map[string]any{}) + }) + assertSQLFragments(t, statement, + "gasorder_contract.gas_basic_id = 7", + "gasorder_contract.delivery_basic_id = 9", + "candidate_relation.user_account_id = gasorder_contract.user_account_id", + "contract_status = 11", + ) +} + +func TestDeliveryOrderAddressCandidates(t *testing.T) { + database := candidateTestDatabase(t) + statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB { + return addressQueryForContract( + addressQueryWithDatabase(tx, 9), 31, + ).Find(&[]map[string]any{}) + }) + assertSQLFragments(t, statement, + "user_service_relation.delivery_basic_id = 9", + "user_address.user_account_id = 31", + ) +} + +func TestDeliveryOrderContractProductCandidates(t *testing.T) { + database := candidateTestDatabase(t) + statement := database.ToSQL(func(tx *gorm.DB) *gorm.DB { + return deliveryContractProductQuery(tx, 9, 27, "阀门").Find(&[]map[string]any{}) + }) + assertSQLFragments(t, statement, + "gasorder_contract.delivery_basic_id = 9", + "gasorder_contract_product.gasorder_contract_id = 27", + "gasorder_contract_product.unbound_at IS NULL", + "display_product.name", + "gasorder_contract_product.product_code", + ) +} diff --git a/backend/api/internal/logic/delivery/user.go b/backend/api/internal/logic/delivery/user.go index 90743a4..87d4c7f 100644 --- a/backend/api/internal/logic/delivery/user.go +++ b/backend/api/internal/logic/delivery/user.go @@ -1,7 +1,10 @@ +// 功能描述:实现配送点范围内的用户、服务关系和收货地址管理。 +// 版本:v1.1.0。 package delivery import ( "errors" + "strings" "git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/infra" @@ -231,16 +234,35 @@ func ArchiveUser(ctx *gin.Context) { } func addressQuery(pointID uint64) *gorm.DB { - return common.ActiveRecords(db().Model(&models.UserAddress{})). + return addressQueryWithDatabase(db(), pointID) +} + +// addressQueryWithDatabase 构造配送点有效服务用户的地址范围,便于独立验证查询约束。 +func addressQueryWithDatabase(database *gorm.DB, pointID uint64) *gorm.DB { + return common.ActiveRecords(database.Model(&models.UserAddress{})). Joins("JOIN user_service_relation ON user_service_relation.user_account_id = user_address.user_account_id AND user_service_relation.status <> ?", common.StatusArchived).Where("user_service_relation.delivery_basic_id = ?", pointID) } +// addressQueryForContract 将当前配送点地址进一步限定为合同签约用户。 +func addressQueryForContract(query *gorm.DB, userAccountID uint64) *gorm.DB { + return query.Where("user_address.user_account_id = ?", userAccountID) +} + func ListAddress(ctx *gin.Context) { point, _, ok := currentScope(ctx) - if ok { - listScoped(ctx, &models.UserAddress{}, addressQuery(point.ID), "user_address.created_at desc") + if !ok { + return } + query := addressQuery(point.ID) + if contractIdentity := strings.TrimSpace(ctx.Query("gasorder_contract_identity")); contractIdentity != "" { + contract, valid := scopedContract(ctx, contractIdentity, point.ID) + if !valid { + return + } + query = addressQueryForContract(query, contract.UserAccountID) + } + listScoped(ctx, &models.UserAddress{}, query, "user_address.is_default desc, user_address.created_at desc") } func GetAddress(ctx *gin.Context) { diff --git a/docs/操作日志_配送点订单创建联动_20260823.md b/docs/操作日志_配送点订单创建联动_20260823.md new file mode 100644 index 0000000..edad3d6 --- /dev/null +++ b/docs/操作日志_配送点订单创建联动_20260823.md @@ -0,0 +1,48 @@ +# 操作日志:配送点订单创建联动 + +操作时间:2026-08-23 +操作类型:扩展、修改 +影响模块:5176 创建订单页、配送合同候选、用户地址候选、合同气瓶候选 + +## 操作前状态 + +- 页面挂载时并行加载所有关系,地址和气瓶不依赖所选合同。 +- 配送后端忽略订单合同、合同用户地址和合同气瓶筛选参数。 +- 地址候选退化显示唯一标识;合同气瓶无法区分当前名称、编码和类型。 +- 切换合同不会清空旧地址和气瓶,可能填到最后才被服务端拒绝。 +- 优惠金额没有非负前端约束,页面不展示金额预估。 + +## 具体操作 + +1. 合同候选增加当前配送点范围和可履约状态过滤。 +2. 地址候选增加合同归属校验,并限定合同签约用户。 +3. 合同气瓶候选增加合同归属、未解绑过滤和名称/编码/类型搜索。 +4. 新增前端父子联动、默认地址选择、防抖搜索和请求版本守卫。 +5. 切换合同时原子清空旧地址和气瓶,加载期间禁用控件及保存。 +6. 联系人和联系电话继续独立手填;请求流水号继续按 5173 人工必填。 +7. 优惠金额默认 0、禁止负数,并展示服务端最终计算声明的金额预估。 +8. 保存增加显式防重入、候选空态阻断和金额正数校验。 + +## 行为变化 + +- 变更前:候选看似可选,但服务端最终可能因跨合同或失效记录拒绝。 +- 变更后:页面只展示当前合同有效候选,后端仍执行最终授权与一致性校验。 +- 兼容性:不修改数据库和创建订单请求结构;三个列表接口仅新增可选筛选参数。 + +## 验证结果 + +- `go test ./api/internal/logic/delivery ./api/internal/logic/platform/gasorder`:通过;覆盖当前点范围、有效合同、合同用户地址和未解绑气瓶。 +- `npm.cmd run type:check`:通过。 +- `npm.cmd run resource-pages:check`:通过,详情 17、新建 9、编辑 5;覆盖联动参数、可读候选、版本守卫、保存阻断和金额公式。 +- `npm.cmd run contract:check`:通过,18 个资源。 +- `npm.cmd run profile:check`:通过。 +- `npm.cmd run build`:通过,2648 个模块完成生产构建。 +- 后端已重新构建并恢复监听 12426;旧程序保存在 `platform-api.before-order-linkage.exe`。 +- 应用内浏览器访问创建订单页被登录页拦截,未在未授权状态下填写或提交账号信息;页面交互需由现有已登录会话刷新后复核。 + +## 风险评估 + +- 快速切换合同:通过逐资源版本号丢弃旧响应。 +- 伪造筛选参数:后端先校验合同属于当前配送点,并在创建时再次校验。 +- 前端金额与服务端不同:页面明确标为预估,写入金额完全由服务端计算。 +- 地址无联系人字段:保持独立必填快照,不生成虚假联系人数据。 diff --git a/docs/项目文档_配送点独立资源页面_v1.0.md b/docs/项目文档_配送点独立资源页面_v1.0.md index a45f997..733873c 100644 --- a/docs/项目文档_配送点独立资源页面_v1.0.md +++ b/docs/项目文档_配送点独立资源页面_v1.0.md @@ -56,6 +56,8 @@ frontend/delivery_admin/ 配送人员和用户账户列表同样参考 5173:联系电话后显示 32px 圆形头像,最多并发 6 个鉴权请求,接近可视区域才加载;当前页缓存结果,刷新或离页时取消请求并释放 Blob URL。404、网络错误和图片解码失败均回退本地默认头像。全部标准资源列表隐藏数据库自增 ID,只展示可复制的系统唯一标识。 +创建配送订单时以配送合同为父级,收货地址只加载合同签约用户地址,合同气瓶只加载当前未解绑绑定;切换合同清空旧值并丢弃过期响应。候选为空或仍在加载时禁止保存,金额区只提供预估,最终金额继续由服务端计算。详细规则见《项目文档_配送点订单创建联动_v1.0.md》。 + 配送人员资质列表必须由配送人员列表进入。页面先通过受保护人员详情接口回查姓名,再以当前气站、配送点、`delivery` 角色和人员 ID 四重条件加载资质;缺少、无效或越权人员上下文时停止加载,绝不回退为全部资质。标题和上下文区域显示人员姓名及可复制唯一标识,表格隐藏重复人员列。新建页自动预填所属人员,创建和编辑均锁定该关系,返回地址只接受安全站内路径。 资源搜索由后端 `searchFields` 契约驱动。没有有效字段的配送点资料、用户地址和银行卡页面不渲染搜索区域;其他页面显示“可搜索:具体字段”提示。枚举字段同时接受中文展示名称和内部稳定编码,查询、分页和重置始终保留人员上下文及安全来源参数。 diff --git a/docs/项目文档_配送点订单创建联动_v1.0.md b/docs/项目文档_配送点订单创建联动_v1.0.md new file mode 100644 index 0000000..69a2e85 --- /dev/null +++ b/docs/项目文档_配送点订单创建联动_v1.0.md @@ -0,0 +1,62 @@ +# 项目文档:配送点订单创建联动 v1.0 + +## 1. 项目概述 + +5176 配送点管理端创建订单页按 5173 的安全联动规则展示候选。配送合同是地址和合同气瓶的父级;前端只负责筛选交互和金额预估,后端始终在当前配送点范围内重新校验并计算最终金额。 + +技术栈:Vue 3、TypeScript、Arco Design、Go、Gin、GORM。 + +## 2. 目录结构 + +```text +frontend/delivery_admin/src/ +├── api/order-creation.ts # 金额预估与前端金额校验 +├── api/resources.ts # 订单字段和关系联动契约 +└── views/resource/ + ├── OrderAmountPreview.vue # 只读金额预估 + ├── ResourceFieldForm.vue # 关系空态、禁用和可读候选 + ├── ResourceRecordPage.vue # 保存保护和页面编排 + └── use-resource-relations.ts # 父子联动、搜索和版本守卫 +backend/api/internal/logic/delivery/ +├── order.go # 可履约合同与未解绑合同气瓶候选 +├── user.go # 合同签约用户地址候选 +└── order_candidate_test.go # 候选 SQL 范围测试 +``` + +## 3. 业务规则 + +- 请求流水号按 5173 保持人工必填,用作唯一幂等追踪字段。 +- 合同候选仅包含当前配送点范围内、生效中、已到生效时间、尚未过期且签约用户服务关系仍有效的合同。 +- 未选择合同时,收货地址和合同气瓶禁用并提示先选合同。 +- 收货地址仅来自合同签约用户,显示完整地址;存在默认地址时自动选择。 +- 合同气瓶仅包含所选合同尚未解绑的绑定,显示“智能气阀名称 / 气瓶编码 / 气瓶类型”。 +- 切换合同立即清空旧地址和旧气瓶;同一资源仅最新请求可以回写候选。 +- 联系人和联系电话继续作为独立、必填、可编辑的订单快照字段,不从地址伪造回填。 +- 优惠金额默认 0,不得为负,且必须小于气瓶金额与配送费之和。 +- 金额预估公式为:气瓶金额 + 默认配送费 - 优惠金额。最终金额由服务端重新计算。 + +## 4. 接口兼容与安全 + +- `/gasorder_contract` 新增可选 `candidate=order` 筛选;不传参数时合同管理列表保持原行为。 +- `/user_address` 新增可选 `gasorder_contract_identity` 筛选;后端先确认合同属于当前配送点,再限定合同用户。 +- `/gasorder_contract_product` 新增可选 `contract_identity` 筛选;后端先确认合同属于当前配送点,再限定未解绑绑定。 +- 原路径、原响应字段和创建订单请求协议保持兼容;候选接口只新增可选参数和只读展示名称。 +- 前端候选不可作为授权依据,创建接口继续校验合同、地址、气瓶、创建方和金额。 + +## 5. 维护与验证 + +新增联动字段时必须声明父字段、服务端筛选键、空态文案和切换提示。禁止仅在浏览器中过滤全量候选。关系请求必须保留版本守卫,保存期间冻结表单并显式防重入。 + +验证命令: + +```text +go test ./api/internal/logic/delivery ./api/internal/logic/platform/gasorder +npm.cmd run type:check +npm.cmd run resource-pages:check +npm.cmd run contract:check +npm.cmd run build +``` + +## 6. 变更记录 + +- v1.0(2026-08-23):新增配送合同到地址、合同气瓶的安全联动,加入金额预估、空态提示、过期响应保护和提交保护。 diff --git a/frontend/delivery_admin/scripts/check-resource-pages.mjs b/frontend/delivery_admin/scripts/check-resource-pages.mjs index 5fbc7d6..6ad5a39 100644 --- a/frontend/delivery_admin/scripts/check-resource-pages.mjs +++ b/frontend/delivery_admin/scripts/check-resource-pages.mjs @@ -98,6 +98,9 @@ const attachmentState = read('src/views/resource/use-contract-attachment.ts'); const detailContent = read('src/views/resource/ResourceDetailContent.vue'); const detailContract = read('src/api/resource-detail-contract.ts'); const resourceDisplay = read('src/api/resource-display.ts'); +const relationLinkage = read('src/views/resource/use-resource-relations.ts'); +const orderCreation = read('src/api/order-creation.ts'); +const orderPreview = read('src/views/resource/OrderAmountPreview.vue'); assert(definitions.includes("type: 'contract-file'"), '合同附件仍按普通文本字段渲染'); assert(recordPage.includes('title="合同附件"'), '合同详情页缺少附件状态卡片'); assert(recordPage.includes('downloadContractAttachment'), '合同详情页缺少独立下载入口'); @@ -120,5 +123,20 @@ assert(detailContent.includes('智能气阀名称取当前设备资料'), '合 assert(resourceDisplay.includes("user_service_active: '签约用户仍属合同气站'"), '签约服务关系缺少中文业务标签'); assert(resourceDisplay.includes("activate: '启用合同'"), '合同修订动作未中文化'); assert(recordPage.includes("record.value.user_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("relationOptionLabelKey: 'address'"), '收货地址候选仍可能显示裸唯一标识'); +assert(definitions.includes("relationOptionDisplay: 'product-name-type'"), '合同气瓶缺少名称、编码和类型组合展示'); +assert(relationLinkage.includes('const versions = new Map'), '关系联动缺少过期响应版本守卫'); +assert(relationLinkage.includes("field.type === 'identity-list' ? [] : ''"), '切换合同时未清空旧关联值'); +assert(recordPage.includes('relationBusy || Boolean(relationUnavailableMessage)'), '关联候选加载或为空时仍可保存'); +assert(recordPage.includes('if (saving.value) return'), '订单保存缺少显式防重入'); +assert(orderCreation.includes('productAmount + deliveryFee - discountAmount'), '订单金额预估公式不正确'); +assert(orderCreation.includes("优惠金额必须小于商品金额与配送费之和"), '优惠金额缺少应付金额正数校验'); +assert(orderPreview.includes('金额仅供预览'), '订单金额预估未声明服务端最终计算'); console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`); diff --git a/frontend/delivery_admin/src/api/order-creation.ts b/frontend/delivery_admin/src/api/order-creation.ts new file mode 100644 index 0000000..0679785 --- /dev/null +++ b/frontend/delivery_admin/src/api/order-creation.ts @@ -0,0 +1,64 @@ +/** + * 功能描述:计算配送订单创建页的只读金额预估并执行前端金额校验。 + * 版本:v1.0.0。 + */ +import type { ResourceRow } from './resource-record-form'; + +export type OrderAmountPreview = { + productAmount: number; + deliveryFee: number; + discountAmount: number; + payableAmount: number; + ready: boolean; +}; + +/** 金额均以分计算,页面只展示预估,最终结果仍由服务端重新计算。 */ +export function orderAmountPreview( + form: Record, + relationOptions: Record, +): OrderAmountPreview { + const contractIdentity = String(form.gasorder_contract_identity ?? ''); + const contract = (relationOptions['/gasorder_contract'] ?? []).find( + (row) => String(row.identity ?? '') === contractIdentity, + ); + const selected = new Set( + (Array.isArray(form.gasorder_contract_product_identities) + ? form.gasorder_contract_product_identities + : [] + ) + .map((value: unknown) => String(value ?? '')) + .filter(Boolean), + ); + const products = (relationOptions['/gasorder_contract_product'] ?? []).filter( + (row) => selected.has(String(row.identity ?? '')), + ); + const productAmount = products.reduce( + (total, row) => total + Math.max(0, Number(row.unit_price ?? 0)), + 0, + ); + const deliveryFee = Math.max(0, Number(contract?.default_delivery_fee ?? 0)); + const discountAmount = Math.round(Number(form.discount_amount ?? 0) * 100); + return { + productAmount, + deliveryFee, + discountAmount, + payableAmount: productAmount + deliveryFee - discountAmount, + ready: Boolean( + contract && selected.size > 0 && products.length === selected.size, + ), + }; +} + +/** 返回订单金额的前端校验结果;服务端仍执行相同约束和溢出保护。 */ +export function orderAmountValidation(preview: OrderAmountPreview) { + if (!Number.isFinite(preview.discountAmount) || preview.discountAmount < 0) + return '优惠金额不能为负数'; + if (preview.ready && preview.payableAmount <= 0) + return '优惠金额必须小于商品金额与配送费之和'; + return ''; +} + +/** 将分格式化为人民币展示文本。 */ +export function formatOrderMoney(value: number) { + return `¥${(value / 100).toFixed(2)}`; +} diff --git a/frontend/delivery_admin/src/api/resource-record-form.ts b/frontend/delivery_admin/src/api/resource-record-form.ts index db67e48..98c233e 100644 --- a/frontend/delivery_admin/src/api/resource-record-form.ts +++ b/frontend/delivery_admin/src/api/resource-record-form.ts @@ -9,12 +9,28 @@ export type ResourceRow = Record; const editableFieldKeys: Record> = { staff_account: new Set(['name', 'phone', 'avatar', 'work_status']), - staff_credential: new Set(['staff_account_identity', 'credential_type', 'credential_no', 'expired_at']), + staff_credential: new Set([ + 'staff_account_identity', + 'credential_type', + 'credential_no', + 'expired_at', + ]), user_account: new Set(['name', 'phone', 'avatar', 'real_name']), - user_address: new Set(['user_account_identity', 'address', 'longitude', 'latitude', 'is_default']), + user_address: new Set([ + 'user_account_identity', + 'address', + 'longitude', + 'latitude', + 'is_default', + ]), gasorder_contract: new Set([ - 'title', 'terms', 'file_uri', 'default_delivery_fee', - 'signed_at', 'effective_at', 'expired_at', + 'title', + 'terms', + 'file_uri', + 'default_delivery_fee', + 'signed_at', + 'effective_at', + 'expired_at', ]), }; @@ -60,13 +76,19 @@ export function validateResourceRecordForm( mode: 'create' | 'edit', ) { const required = fields.filter( - (field) => field.required && !(mode === 'edit' && field.type === 'password'), + (field) => + field.required && !(mode === 'edit' && field.type === 'password'), ); - if (required.some((field) => isMissingField(form[field.key]))) return '请填写必填字段'; + if (required.some((field) => isMissingField(form[field.key]))) + return '请填写必填字段'; const password = fields.find( (field) => field.type === 'password' && !isMissingField(form[field.key]), ); if (password && Array.from(String(form[password.key])).length < 6) return '密码长度不能少于 6 个字符'; + const belowMinimum = fields.find( + (field) => field.min != null && Number(form[field.key]) < field.min, + ); + if (belowMinimum) return `${belowMinimum.label}不能小于 ${belowMinimum.min}`; return ''; } diff --git a/frontend/delivery_admin/src/api/resources.ts b/frontend/delivery_admin/src/api/resources.ts index f80288d..096decd 100644 --- a/frontend/delivery_admin/src/api/resources.ts +++ b/frontend/delivery_admin/src/api/resources.ts @@ -25,6 +25,14 @@ export type ResourceFieldType = | 'select' | 'contract-file'; +/** 父子关联候选配置;筛选仍由服务端按当前配送点范围执行。 */ +export type ResourceRelationLinkage = { + parentKey: string; + filterKey: string; + requiresParent?: boolean; + parentChangeMessage?: string; +}; + export type ResourceField = { key: string; label: string; @@ -39,6 +47,20 @@ export type ResourceField = { readonlyRelationText?: boolean; /** 列表中以可悬浮、可复制的紧凑文本展示普通字段。 */ listCopyable?: boolean; + /** 关联选项优先展示的业务字段。 */ + relationOptionLabelKey?: string; + /** 合同气瓶等关系的组合展示方式。 */ + relationOptionDisplay?: 'product-name-type'; + /** 为默认候选追加中文标识,并在加载完成后自动选择。 */ + relationOptionDefaultKey?: string; + relationAutoSelectKey?: string; + /** 关联候选为空时的业务提示。 */ + relationEmptyText?: string; + relationFilters?: Record; + relationStrictFilter?: boolean; + relationInvalidMessage?: string; + relationLinkage?: ResourceRelationLinkage; + min?: number; options?: Array<{ label: string; value: string | number; disabled?: boolean }>; defaultValue?: string | number | boolean; readonly?: boolean; @@ -530,7 +552,50 @@ const deliveryOverrides: ResourceUiDefinition[] = [ ]), define('gasorder_contract_revision', '合同修订记录', 'readonly', []), define('product_info', '合同可选气瓶', 'readonly', []), - define('gasorder_basic', '配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true), relation('user_address_identity', '/user_address', true), f('gasorder_contract_product_identities', { required: true, type: 'identity-list', relation: '/gasorder_contract_product' }), f('contact_name', { required: true }), f('contact_phone', { required: true }), f('discount_amount'), f('remark')], 'list', [ + 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: '暂无可下单的生效合同,请先启用或续签配送合同', + }), + relation('user_address_identity', '/user_address', true, { + label: '收货地址', + placeholder: '请先选择配送合同,再选择该合同用户的收货地址', + relationOptionLabelKey: 'address', + relationOptionDefaultKey: 'is_default', + relationAutoSelectKey: 'is_default', + relationEmptyText: '该合同用户暂无收货地址,请先维护用户地址', + relationLinkage: { + parentKey: 'gasorder_contract_identity', + filterKey: 'gasorder_contract_identity', + requiresParent: true, + parentChangeMessage: '配送合同已变更,请重新选择该合同用户的收货地址', + }, + }), + f('gasorder_contract_product_identities', { + label: '合同气瓶', + required: true, + type: 'identity-list', + relation: '/gasorder_contract_product', + placeholder: '请输入智能气阀名称、气瓶类型或气瓶编码搜索', + relationOptionDisplay: 'product-name-type', + relationEmptyText: '该合同暂无可用气瓶,请先为合同绑定气瓶', + relationLinkage: { + parentKey: 'gasorder_contract_identity', + filterKey: 'contract_identity', + requiresParent: true, + parentChangeMessage: '配送合同已变更,请重新选择该合同可用的气瓶', + }, + }), + f('contact_name', { required: true }), + f('contact_phone', { required: true }), + f('discount_amount', { defaultValue: 0, min: 0 }), + f('remark'), + ], 'list', [ { name: '分配/改派', resource: '/gasorder_basic/:identity/assign', fields: [relation('staff_account_identity', '/staff_account', true), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } }, { name: '回收任务', resource: '/gasorder_basic/:identity/reclaim', danger: true, fields: reason, visibleFor: { field: 'order_status', values: [18] } }, { name: '调整金额', resource: '/gasorder_basic/:identity/adjust-amount', fields: [f('delivery_fee', { required: true }), f('discount_amount', { required: true }), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } }, diff --git a/frontend/delivery_admin/src/views/resource/OrderAmountPreview.vue b/frontend/delivery_admin/src/views/resource/OrderAmountPreview.vue new file mode 100644 index 0000000..841dc48 --- /dev/null +++ b/frontend/delivery_admin/src/views/resource/OrderAmountPreview.vue @@ -0,0 +1,36 @@ + + + + + + diff --git a/frontend/delivery_admin/src/views/resource/ResourceActionDialog.vue b/frontend/delivery_admin/src/views/resource/ResourceActionDialog.vue index d94ea7e..ba9ba8c 100644 --- a/frontend/delivery_admin/src/views/resource/ResourceActionDialog.vue +++ b/frontend/delivery_admin/src/views/resource/ResourceActionDialog.vue @@ -18,7 +18,7 @@ mode="create" :relation-options="relationOptions" :relation-loading="relationLoading" - @search-relation="(resource, keyword) => emit('searchRelation', resource, keyword)" + @search-relation="(field, keyword) => emit('searchRelation', field, keyword)" /> @@ -29,7 +29,7 @@ import { reactive, ref, watch } from 'vue'; import { resourceApi } from '@/api/resource'; import { buildResourcePayload, isMissingField } from '@/api/resource-form'; import type { ResourceRow } from '@/api/resource-record-form'; -import type { DetailAction } from '@/api/resources'; +import type { DetailAction, ResourceField } from '@/api/resources'; import ResourceFieldForm from './ResourceFieldForm.vue'; const props = defineProps<{ @@ -43,7 +43,7 @@ const props = defineProps<{ const emit = defineEmits<{ 'update:visible': [visible: boolean]; success: []; - searchRelation: [resource: string | undefined, keyword: string]; + searchRelation: [field: ResourceField, keyword: string]; }>(); const form = reactive>({}); const submitting = ref(false); @@ -53,8 +53,12 @@ watch( ([visible, action]) => { if (!visible || !action) return; for (const key of Object.keys(form)) delete form[key]; - for (const field of action.fields ?? []) form[field.key] = field.defaultValue; - if (action.resource.endsWith('/:identity/status') && [1, 2].includes(Number(props.currentStatus))) + for (const field of action.fields ?? []) + form[field.key] = field.defaultValue; + if ( + action.resource.endsWith('/:identity/status') && + [1, 2].includes(Number(props.currentStatus)) + ) form.status = props.currentStatus; }, { immediate: true }, @@ -64,11 +68,18 @@ watch( async function submit() { const action = props.action; if (!action) return; - if ((action.fields ?? []).some((field) => field.required && isMissingField(form[field.key]))) { + if ( + (action.fields ?? []).some( + (field) => field.required && isMissingField(form[field.key]), + ) + ) { Message.warning('请填写必填字段'); return; } - if (action.resource.endsWith('/:identity/status') && Number(form.status) === Number(props.currentStatus)) { + if ( + action.resource.endsWith('/:identity/status') && + Number(form.status) === Number(props.currentStatus) + ) { Message.warning('审核状态未发生变化'); return; } diff --git a/frontend/delivery_admin/src/views/resource/ResourceFieldForm.vue b/frontend/delivery_admin/src/views/resource/ResourceFieldForm.vue index 7bded4a..4ce36c0 100644 --- a/frontend/delivery_admin/src/views/resource/ResourceFieldForm.vue +++ b/frontend/delivery_admin/src/views/resource/ResourceFieldForm.vue @@ -1,4 +1,4 @@ - + diff --git a/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue b/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue index 4fce355..cf1d7b1 100644 --- a/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue +++ b/frontend/delivery_admin/src/views/resource/ResourceRecordPage.vue @@ -1,4 +1,4 @@ - +