完善配送订单创建联动

This commit is contained in:
czl231
2026-08-23 11:30:52 +08:00
parent c07c9ea6f2
commit 59a01192c0
15 changed files with 935 additions and 163 deletions

View File

@@ -1,5 +1,5 @@
// 功能描述:实现配送点范围内的合同、合同气瓶和配送订单接口。 // 功能描述:实现配送点范围内的合同、合同气瓶和配送订单接口。
// 版本v1.1.0。 // 版本v1.2.0。
package delivery package delivery
import ( import (
@@ -49,23 +49,33 @@ func scopedContract(ctx *gin.Context, identity string, pointID uint64) (models.G
return contract, true 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) { func ListContract(ctx *gin.Context) {
point, _, ok := currentScope(ctx) point, _, ok := currentScope(ctx)
if !ok { if !ok {
return return
} }
page, size := common.PageSize(ctx) page, size := common.PageSize(ctx)
query := common.ApplyKeywordFilter(ctx, query := deliveryContractListQuery(
common.ActiveRecords(db().Model(&models.GasorderContract{})). db(), point.GasBasicID, point.ID, strings.TrimSpace(ctx.Query("candidate")), time.Now(),
Where("gas_basic_id = ? AND delivery_basic_id = ?", point.GasBasicID, point.ID), )
&models.GasorderContract{}) query = common.ApplyKeywordFilter(ctx, query, &models.GasorderContract{})
var total int64 var total int64
if err := query.Count(&total).Error; err != nil { if err := query.Count(&total).Error; err != nil {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return
} }
var list []models.GasorderContract var list []platformgasorder.ContractPartyDisplay
if err := query.Order("gasorder_contract.created_at desc").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { 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) infra.Response.Error(ctx, err)
return return
} }
@@ -74,7 +84,11 @@ func ListContract(ctx *gin.Context) {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return 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 { if err != nil {
infra.Response.Error(ctx, err) infra.Response.Error(ctx, err)
return return
@@ -186,15 +200,63 @@ func TerminateContract(ctx *gin.Context) {
withContract(ctx, platformgasorder.TerminateGasorderContract) 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) { func ListContractProduct(ctx *gin.Context) {
point, _, ok := currentScope(ctx) point, _, ok := currentScope(ctx)
if !ok { if !ok {
return return
} }
query := common.ActiveRecords(db().Model(&models.GasorderContractProduct{})). contractIdentity := strings.TrimSpace(ctx.Query("contract_identity"))
Joins("JOIN gasorder_contract ON gasorder_contract.id = gasorder_contract_product.gasorder_contract_id"). var contractID uint64
Where("gasorder_contract.delivery_basic_id = ?", point.ID) if contractIdentity != "" {
listScoped(ctx, &models.GasorderContractProduct{}, query, "gasorder_contract_product.created_at desc") 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) { func GetContractProduct(ctx *gin.Context) {

View File

@@ -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",
)
}

View File

@@ -1,7 +1,10 @@
// 功能描述:实现配送点范围内的用户、服务关系和收货地址管理。
// 版本v1.1.0。
package delivery package delivery
import ( import (
"errors" "errors"
"strings"
"git.apinb.com/bsm-sdk/core/errcode" "git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra" "git.apinb.com/bsm-sdk/core/infra"
@@ -231,16 +234,35 @@ func ArchiveUser(ctx *gin.Context) {
} }
func addressQuery(pointID uint64) *gorm.DB { 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 <> ?", 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) 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) { func ListAddress(ctx *gin.Context) {
point, _, ok := currentScope(ctx) point, _, ok := currentScope(ctx)
if ok { if !ok {
listScoped(ctx, &models.UserAddress{}, addressQuery(point.ID), "user_address.created_at desc") 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) { func GetAddress(ctx *gin.Context) {

View File

@@ -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`
- 应用内浏览器访问创建订单页被登录页拦截,未在未授权状态下填写或提交账号信息;页面交互需由现有已登录会话刷新后复核。
## 风险评估
- 快速切换合同:通过逐资源版本号丢弃旧响应。
- 伪造筛选参数:后端先校验合同属于当前配送点,并在创建时再次校验。
- 前端金额与服务端不同:页面明确标为预估,写入金额完全由服务端计算。
- 地址无联系人字段:保持独立必填快照,不生成虚假联系人数据。

View File

@@ -56,6 +56,8 @@ frontend/delivery_admin/
配送人员和用户账户列表同样参考 5173联系电话后显示 32px 圆形头像,最多并发 6 个鉴权请求,接近可视区域才加载;当前页缓存结果,刷新或离页时取消请求并释放 Blob URL。404、网络错误和图片解码失败均回退本地默认头像。全部标准资源列表隐藏数据库自增 ID只展示可复制的系统唯一标识。 配送人员和用户账户列表同样参考 5173联系电话后显示 32px 圆形头像,最多并发 6 个鉴权请求,接近可视区域才加载;当前页缓存结果,刷新或离页时取消请求并释放 Blob URL。404、网络错误和图片解码失败均回退本地默认头像。全部标准资源列表隐藏数据库自增 ID只展示可复制的系统唯一标识。
创建配送订单时以配送合同为父级收货地址只加载合同签约用户地址合同气瓶只加载当前未解绑绑定切换合同清空旧值并丢弃过期响应。候选为空或仍在加载时禁止保存金额区只提供预估最终金额继续由服务端计算。详细规则见《项目文档_配送点订单创建联动_v1.0.md》。
配送人员资质列表必须由配送人员列表进入。页面先通过受保护人员详情接口回查姓名,再以当前气站、配送点、`delivery` 角色和人员 ID 四重条件加载资质;缺少、无效或越权人员上下文时停止加载,绝不回退为全部资质。标题和上下文区域显示人员姓名及可复制唯一标识,表格隐藏重复人员列。新建页自动预填所属人员,创建和编辑均锁定该关系,返回地址只接受安全站内路径。 配送人员资质列表必须由配送人员列表进入。页面先通过受保护人员详情接口回查姓名,再以当前气站、配送点、`delivery` 角色和人员 ID 四重条件加载资质;缺少、无效或越权人员上下文时停止加载,绝不回退为全部资质。标题和上下文区域显示人员姓名及可复制唯一标识,表格隐藏重复人员列。新建页自动预填所属人员,创建和编辑均锁定该关系,返回地址只接受安全站内路径。
资源搜索由后端 `searchFields` 契约驱动。没有有效字段的配送点资料、用户地址和银行卡页面不渲染搜索区域;其他页面显示“可搜索:具体字段”提示。枚举字段同时接受中文展示名称和内部稳定编码,查询、分页和重置始终保留人员上下文及安全来源参数。 资源搜索由后端 `searchFields` 契约驱动。没有有效字段的配送点资料、用户地址和银行卡页面不渲染搜索区域;其他页面显示“可搜索:具体字段”提示。枚举字段同时接受中文展示名称和内部稳定编码,查询、分页和重置始终保留人员上下文及安全来源参数。

View File

@@ -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.02026-08-23新增配送合同到地址、合同气瓶的安全联动加入金额预估、空态提示、过期响应保护和提交保护。

View File

@@ -98,6 +98,9 @@ const attachmentState = read('src/views/resource/use-contract-attachment.ts');
const detailContent = read('src/views/resource/ResourceDetailContent.vue'); const detailContent = read('src/views/resource/ResourceDetailContent.vue');
const detailContract = read('src/api/resource-detail-contract.ts'); const detailContract = read('src/api/resource-detail-contract.ts');
const resourceDisplay = read('src/api/resource-display.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(definitions.includes("type: 'contract-file'"), '合同附件仍按普通文本字段渲染');
assert(recordPage.includes('title="合同附件"'), '合同详情页缺少附件状态卡片'); assert(recordPage.includes('title="合同附件"'), '合同详情页缺少附件状态卡片');
assert(recordPage.includes('downloadContractAttachment'), '合同详情页缺少独立下载入口'); assert(recordPage.includes('downloadContractAttachment'), '合同详情页缺少独立下载入口');
@@ -120,5 +123,20 @@ assert(detailContent.includes('智能气阀名称取当前设备资料'), '合
assert(resourceDisplay.includes("user_service_active: '签约用户仍属合同气站'"), '签约服务关系缺少中文业务标签'); assert(resourceDisplay.includes("user_service_active: '签约用户仍属合同气站'"), '签约服务关系缺少中文业务标签');
assert(resourceDisplay.includes("activate: '启用合同'"), '合同修订动作未中文化'); assert(resourceDisplay.includes("activate: '启用合同'"), '合同修订动作未中文化');
assert(recordPage.includes("record.value.user_service_active === false"), '失效服务关系下未限制合同动作'); 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}`); console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);

View File

@@ -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<string, any>,
relationOptions: Record<string, ResourceRow[]>,
): 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)}`;
}

View File

@@ -9,12 +9,28 @@ export type ResourceRow = Record<string, unknown>;
const editableFieldKeys: Record<string, Set<string>> = { const editableFieldKeys: Record<string, Set<string>> = {
staff_account: new Set(['name', 'phone', 'avatar', 'work_status']), 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_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([ gasorder_contract: new Set([
'title', 'terms', 'file_uri', 'default_delivery_fee', 'title',
'signed_at', 'effective_at', 'expired_at', 'terms',
'file_uri',
'default_delivery_fee',
'signed_at',
'effective_at',
'expired_at',
]), ]),
}; };
@@ -60,13 +76,19 @@ export function validateResourceRecordForm(
mode: 'create' | 'edit', mode: 'create' | 'edit',
) { ) {
const required = fields.filter( 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( const password = fields.find(
(field) => field.type === 'password' && !isMissingField(form[field.key]), (field) => field.type === 'password' && !isMissingField(form[field.key]),
); );
if (password && Array.from(String(form[password.key])).length < 6) if (password && Array.from(String(form[password.key])).length < 6)
return '密码长度不能少于 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 ''; return '';
} }

View File

@@ -25,6 +25,14 @@ export type ResourceFieldType =
| 'select' | 'select'
| 'contract-file'; | 'contract-file';
/** 父子关联候选配置;筛选仍由服务端按当前配送点范围执行。 */
export type ResourceRelationLinkage = {
parentKey: string;
filterKey: string;
requiresParent?: boolean;
parentChangeMessage?: string;
};
export type ResourceField = { export type ResourceField = {
key: string; key: string;
label: string; label: string;
@@ -39,6 +47,20 @@ export type ResourceField = {
readonlyRelationText?: boolean; readonlyRelationText?: boolean;
/** 列表中以可悬浮、可复制的紧凑文本展示普通字段。 */ /** 列表中以可悬浮、可复制的紧凑文本展示普通字段。 */
listCopyable?: boolean; listCopyable?: boolean;
/** 关联选项优先展示的业务字段。 */
relationOptionLabelKey?: string;
/** 合同气瓶等关系的组合展示方式。 */
relationOptionDisplay?: 'product-name-type';
/** 为默认候选追加中文标识,并在加载完成后自动选择。 */
relationOptionDefaultKey?: string;
relationAutoSelectKey?: string;
/** 关联候选为空时的业务提示。 */
relationEmptyText?: string;
relationFilters?: Record<string, string>;
relationStrictFilter?: boolean;
relationInvalidMessage?: string;
relationLinkage?: ResourceRelationLinkage;
min?: number;
options?: Array<{ label: string; value: string | number; disabled?: boolean }>; options?: Array<{ label: string; value: string | number; disabled?: boolean }>;
defaultValue?: string | number | boolean; defaultValue?: string | number | boolean;
readonly?: boolean; readonly?: boolean;
@@ -530,7 +552,50 @@ const deliveryOverrides: ResourceUiDefinition[] = [
]), ]),
define('gasorder_contract_revision', '合同修订记录', 'readonly', []), define('gasorder_contract_revision', '合同修订记录', 'readonly', []),
define('product_info', '合同可选气瓶', '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/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/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] } }, { 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] } },

View File

@@ -0,0 +1,36 @@
<!-- 功能描述展示配送订单创建时的只读金额预估版本v1.0.0 -->
<template>
<a-alert v-if="preview.ready" class="amount-preview" type="info" show-icon>
<div class="amount-grid">
<span>气瓶金额<strong>{{ formatOrderMoney(preview.productAmount) }}</strong></span>
<span>默认配送费<strong>{{ formatOrderMoney(preview.deliveryFee) }}</strong></span>
<span>优惠金额<strong>- {{ formatOrderMoney(preview.discountAmount) }}</strong></span>
<span class="payable">预计应付<strong>{{ formatOrderMoney(preview.payableAmount) }}</strong></span>
</div>
<div class="amount-note">金额仅供预览保存时由服务端按合同价格重新计算</div>
</a-alert>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { formatOrderMoney, orderAmountPreview } from '@/api/order-creation';
import type { ResourceRow } from '@/api/resource-record-form';
const props = defineProps<{
form: Record<string, any>;
relationOptions: Record<string, ResourceRow[]>;
}>();
const preview = computed(() =>
orderAmountPreview(props.form, props.relationOptions),
);
</script>
<style scoped>
.amount-preview { margin: 4px 0 20px; }
.amount-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.amount-grid span { display: grid; gap: 4px; color: var(--color-text-3); }
.amount-grid strong { color: var(--color-text-1); font-size: 16px; }
.amount-grid .payable strong { color: rgb(var(--primary-6)); font-size: 18px; }
.amount-note { margin-top: 10px; color: var(--color-text-3); font-size: 12px; }
@media (max-width: 900px) { .amount-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
</style>

View File

@@ -18,7 +18,7 @@
mode="create" mode="create"
:relation-options="relationOptions" :relation-options="relationOptions"
:relation-loading="relationLoading" :relation-loading="relationLoading"
@search-relation="(resource, keyword) => emit('searchRelation', resource, keyword)" @search-relation="(field, keyword) => emit('searchRelation', field, keyword)"
/> />
</a-modal> </a-modal>
</template> </template>
@@ -29,7 +29,7 @@ import { reactive, ref, watch } from 'vue';
import { resourceApi } from '@/api/resource'; import { resourceApi } from '@/api/resource';
import { buildResourcePayload, isMissingField } from '@/api/resource-form'; import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import type { ResourceRow } from '@/api/resource-record-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'; import ResourceFieldForm from './ResourceFieldForm.vue';
const props = defineProps<{ const props = defineProps<{
@@ -43,7 +43,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
'update:visible': [visible: boolean]; 'update:visible': [visible: boolean];
success: []; success: [];
searchRelation: [resource: string | undefined, keyword: string]; searchRelation: [field: ResourceField, keyword: string];
}>(); }>();
const form = reactive<Record<string, any>>({}); const form = reactive<Record<string, any>>({});
const submitting = ref(false); const submitting = ref(false);
@@ -53,8 +53,12 @@ watch(
([visible, action]) => { ([visible, action]) => {
if (!visible || !action) return; if (!visible || !action) return;
for (const key of Object.keys(form)) delete form[key]; for (const key of Object.keys(form)) delete form[key];
for (const field of action.fields ?? []) form[field.key] = field.defaultValue; for (const field of action.fields ?? [])
if (action.resource.endsWith('/:identity/status') && [1, 2].includes(Number(props.currentStatus))) form[field.key] = field.defaultValue;
if (
action.resource.endsWith('/:identity/status') &&
[1, 2].includes(Number(props.currentStatus))
)
form.status = props.currentStatus; form.status = props.currentStatus;
}, },
{ immediate: true }, { immediate: true },
@@ -64,11 +68,18 @@ watch(
async function submit() { async function submit() {
const action = props.action; const action = props.action;
if (!action) return; 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('请填写必填字段'); Message.warning('请填写必填字段');
return; 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('审核状态未发生变化'); Message.warning('审核状态未发生变化');
return; return;
} }

View File

@@ -1,4 +1,4 @@
<!-- 功能描述渲染配送点资源新建编辑和动作表单字段版本v1.0.0 --> <!-- 功能描述渲染配送点资源新建编辑和动作表单字段版本v1.1.0 -->
<template> <template>
<a-form :model="model" layout="vertical" class="field-grid"> <a-form :model="model" layout="vertical" class="field-grid">
<a-form-item <a-form-item
@@ -8,36 +8,37 @@
:label="field.label" :label="field.label"
:required="isRequired(field)" :required="isRequired(field)"
> >
<a-switch v-if="field.type === 'boolean'" v-model="model[field.key]" :disabled="field.readonly" /> <a-switch v-if="field.type === 'boolean'" v-model="model[field.key]" :disabled="isFieldDisabled(field)" />
<a-input-number <a-input-number
v-else-if="field.type === 'number' || field.type === 'money'" v-else-if="field.type === 'number' || field.type === 'money'"
v-model="model[field.key]" v-model="model[field.key]"
:precision="field.type === 'money' ? 2 : 0" :precision="field.type === 'money' ? 2 : 0"
:disabled="field.readonly" :min="field.min"
:disabled="isFieldDisabled(field)"
/> />
<a-date-picker <a-date-picker
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
v-model="model[field.key]" v-model="model[field.key]"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
:disabled="field.readonly" :disabled="isFieldDisabled(field)"
/> />
<a-date-picker <a-date-picker
v-else-if="field.type === 'datetime'" v-else-if="field.type === 'datetime'"
v-model="model[field.key]" v-model="model[field.key]"
show-time show-time
value-format="YYYY-MM-DDTHH:mm:ssZ" value-format="YYYY-MM-DDTHH:mm:ssZ"
:disabled="field.readonly" :disabled="isFieldDisabled(field)"
/> />
<a-textarea <a-textarea
v-else-if="field.type === 'textarea'" v-else-if="field.type === 'textarea'"
v-model="model[field.key]" v-model="model[field.key]"
:auto-size="{ minRows: 3, maxRows: 8 }" :auto-size="{ minRows: 3, maxRows: 8 }"
:disabled="field.readonly" :disabled="isFieldDisabled(field)"
/> />
<ContractAttachmentField <ContractAttachmentField
v-else-if="field.type === 'contract-file'" v-else-if="field.type === 'contract-file'"
:attachment="contractAttachment" :attachment="contractAttachment"
:disabled="field.readonly" :disabled="isFieldDisabled(field)"
@select="(file) => emit('selectAttachment', file)" @select="(file) => emit('selectAttachment', file)"
@remove="emit('removeAttachment')" @remove="emit('removeAttachment')"
@clear-selection="emit('clearAttachmentSelection')" @clear-selection="emit('clearAttachmentSelection')"
@@ -46,13 +47,14 @@
<a-input-password <a-input-password
v-else-if="field.type === 'password'" v-else-if="field.type === 'password'"
v-model="model[field.key]" v-model="model[field.key]"
:disabled="isFieldDisabled(field)"
:placeholder="mode === 'edit' ? '留空表示不修改' : `请输入${field.label}`" :placeholder="mode === 'edit' ? '留空表示不修改' : `请输入${field.label}`"
/> />
<a-select <a-select
v-else-if="field.type === 'select'" v-else-if="field.type === 'select'"
v-model="model[field.key]" v-model="model[field.key]"
allow-clear allow-clear
:disabled="field.readonly" :disabled="isFieldDisabled(field)"
> >
<a-option <a-option
v-for="option in field.options ?? []" v-for="option in field.options ?? []"
@@ -69,22 +71,27 @@
:multiple="field.type === 'identity-list'" :multiple="field.type === 'identity-list'"
allow-clear allow-clear
allow-search allow-search
:disabled="field.readonly" :disabled="isFieldDisabled(field)"
:loading="relationLoading[field.relation ?? '']" :loading="relationLoading[field.relation ?? '']"
@search="(value: string) => emit('searchRelation', field.relation, value)" :placeholder="field.placeholder ?? `请选择${field.label}`"
@change="(value: unknown) => emit('changeRelation', field, value)"
@search="(value: string) => emit('searchRelation', field, value)"
> >
<a-option <a-option
v-for="option in relationOptions[field.relation ?? ''] ?? []" v-for="option in relationOptions[field.relation ?? ''] ?? []"
:key="String(option.identity)" :key="String(option.identity)"
:value="String(option.identity)" :value="String(option.identity)"
> >
{{ optionLabel(option) }} {{ optionLabel(field, option) }}
</a-option> </a-option>
<template #empty>
<a-empty :description="relationEmptyDescription(field)" />
</template>
</a-select> </a-select>
<a-input <a-input
v-else v-else
v-model="model[field.key]" v-model="model[field.key]"
:disabled="field.readonly" :disabled="isFieldDisabled(field)"
:placeholder="field.placeholder ?? `请输入${field.label}`" :placeholder="field.placeholder ?? `请输入${field.label}`"
/> />
</a-form-item> </a-form-item>
@@ -92,20 +99,24 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-record-form'; import type { ResourceRow } from '@/api/resource-record-form';
import type { ResourceField } from '@/api/resources';
import ContractAttachmentField, { import ContractAttachmentField, {
type ContractAttachmentFieldState, type ContractAttachmentFieldState,
} from './ContractAttachmentField.vue'; } from './ContractAttachmentField.vue';
const props = withDefaults(defineProps<{ const props = withDefaults(
defineProps<{
fields: ResourceField[]; fields: ResourceField[];
model: Record<string, any>; model: Record<string, any>;
mode: 'create' | 'edit'; mode: 'create' | 'edit';
relationOptions: Record<string, ResourceRow[]>; relationOptions: Record<string, ResourceRow[]>;
relationLoading: Record<string, boolean>; relationLoading: Record<string, boolean>;
contractAttachment?: ContractAttachmentFieldState; contractAttachment?: ContractAttachmentFieldState;
}>(), { formDisabled?: boolean;
}>(),
{
formDisabled: false,
contractAttachment: () => ({ contractAttachment: () => ({
selectedFile: undefined, selectedFile: undefined,
hasExisting: false, hasExisting: false,
@@ -113,9 +124,11 @@ const props = withDefaults(defineProps<{
requiresReupload: false, requiresReupload: false,
removed: false, removed: false,
}), }),
}); },
);
const emit = defineEmits<{ const emit = defineEmits<{
searchRelation: [resource: string | undefined, keyword: string]; searchRelation: [field: ResourceField, keyword: string];
changeRelation: [field: ResourceField, value: unknown];
selectAttachment: [file: File]; selectAttachment: [file: File];
removeAttachment: []; removeAttachment: [];
clearAttachmentSelection: []; clearAttachmentSelection: [];
@@ -124,22 +137,71 @@ const emit = defineEmits<{
/** 判断当前模式下字段是否必填。 */ /** 判断当前模式下字段是否必填。 */
function isRequired(field: ResourceField) { function isRequired(field: ResourceField) {
return Boolean(field.required && !(props.mode === 'edit' && field.type === 'password')); return Boolean(
field.required && !(props.mode === 'edit' && field.type === 'password'),
);
} }
/** 长文本和多选关系占据整行,其余字段按双列排列。 */ /** 长文本和多选关系占据整行,其余字段按双列排列。 */
function isWide(field: ResourceField) { function isWide(field: ResourceField) {
return field.type === 'textarea' || field.type === 'identity-list' || field.type === 'contract-file' || return (
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key); field.type === 'textarea' ||
field.type === 'identity-list' ||
field.type === 'contract-file' ||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key)
);
}
/** 父级未选、正在保存或字段只读时禁用输入。 */
function isFieldDisabled(field: ResourceField) {
const linkage = field.relationLinkage;
return Boolean(
props.formDisabled ||
field.readonly ||
Boolean(field.relation && props.relationLoading[field.relation]) ||
(linkage?.requiresParent &&
!String(props.model[linkage.parentKey] ?? '').trim()),
);
}
/** 返回关系空态,未选父级时优先给出下一步指引。 */
function relationEmptyDescription(field: ResourceField) {
if (
field.relationLinkage?.requiresParent &&
!String(props.model[field.relationLinkage.parentKey] ?? '').trim()
)
return '请先选择配送合同';
return field.relationEmptyText ?? '暂无数据';
} }
/** 返回关联候选的可读名称。 */ /** 返回关联候选的可读名称。 */
function optionLabel(option: ResourceRow) { function optionLabel(field: ResourceField, option: ResourceRow) {
return String( let label = '';
option.name ?? option.title ?? option.code ?? option.username ?? if (field.relationOptionDisplay === 'product-name-type') {
option.contract_no ?? option.order_no ?? option.identity, label = [option.product_name, option.product_code, option.product_type_name]
.map((value) => String(value ?? '').trim())
.filter(Boolean)
.join(' / ');
} else if (field.relationOptionLabelKey) {
label = String(option[field.relationOptionLabelKey] ?? '').trim();
}
if (!label) {
label = String(
option.name ??
option.title ??
option.code ??
option.username ??
option.contract_no ??
option.order_no ??
option.address ??
option.identity,
); );
} }
return field.relationOptionDefaultKey &&
option[field.relationOptionDefaultKey]
? `${label}(默认地址)`
: label;
}
</script> </script>
<style scoped lang="less"> <style scoped lang="less">

View File

@@ -1,4 +1,4 @@
<!-- 功能描述承载配送点标准资源的新建详情和编辑独立页面版本v1.0.0 --> <!-- 功能描述承载配送点标准资源的新建详情和编辑独立页面版本v1.1.0 -->
<template> <template>
<div class="record-page"> <div class="record-page">
<header class="page-header"> <header class="page-header">
@@ -110,14 +110,32 @@
:relation-options="relationOptions" :relation-options="relationOptions"
:relation-loading="relationLoading" :relation-loading="relationLoading"
:contract-attachment="contractAttachmentState" :contract-attachment="contractAttachmentState"
:form-disabled="saving"
@change-relation="changeRelation"
@search-relation="searchRelation" @search-relation="searchRelation"
@select-attachment="contractAttachment.select" @select-attachment="contractAttachment.select"
@remove-attachment="confirmRemoveContractAttachment" @remove-attachment="confirmRemoveContractAttachment"
@clear-attachment-selection="contractAttachment.clearSelection" @clear-attachment-selection="contractAttachment.clearSelection"
@preview-attachment="previewContractAttachment" @preview-attachment="previewContractAttachment"
/> />
<a-alert
v-if="relationUnavailableMessage"
class="workflow-alert"
type="warning"
show-icon
>{{ relationUnavailableMessage }}</a-alert>
<OrderAmountPreview
v-if="isOrderCreate"
:form="form"
:relation-options="relationOptions"
/>
<div class="form-actions"> <div class="form-actions">
<a-button type="primary" :loading="saving" @click="save">保存</a-button> <a-button
type="primary"
:loading="saving"
:disabled="relationBusy || Boolean(relationUnavailableMessage)"
@click="save"
>保存</a-button>
<a-button :disabled="saving" @click="requestBack">取消</a-button> <a-button :disabled="saving" @click="requestBack">取消</a-button>
</div> </div>
</div> </div>
@@ -142,25 +160,23 @@ import { Message, Modal } from '@arco-design/web-vue';
import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon'; import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon';
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { orderAmountPreview, orderAmountValidation } from '@/api/order-creation';
import { resourceApi } from '@/api/resource'; import { resourceApi } from '@/api/resource';
import { buildResourcePayload } from '@/api/resource-form';
import {
resetResourceRecordForm,
recordFormFields,
validateResourceRecordForm,
type ResourceRow,
} from '@/api/resource-record-form';
import { primaryRecord, recordEditReason } from '@/api/resource-display'; import { primaryRecord, recordEditReason } from '@/api/resource-display';
import { buildResourcePayload } from '@/api/resource-form';
import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation'; import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation';
import { getResource, type DetailAction, type ResourceField } from '@/api/resources'; import { type ResourceRow, recordFormFields, resetResourceRecordForm, validateResourceRecordForm } from '@/api/resource-record-form';
import { type DetailAction, getResource, type ResourceField } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue'; import IdentityText from '@/components/IdentityText.vue';
import ResourceActionDialog from './ResourceActionDialog.vue'; import OrderAmountPreview from './OrderAmountPreview.vue';
import ResourceAccountSummary from './ResourceAccountSummary.vue'; import ResourceAccountSummary from './ResourceAccountSummary.vue';
import ResourceActionDialog from './ResourceActionDialog.vue';
import ResourceDetailContent from './ResourceDetailContent.vue'; import ResourceDetailContent from './ResourceDetailContent.vue';
import ResourceFieldForm from './ResourceFieldForm.vue'; import ResourceFieldForm from './ResourceFieldForm.vue';
import { useUnsavedRecord } from './use-unsaved-record';
import { useResourceAvatar } from './use-resource-avatar';
import { useContractAttachment } from './use-contract-attachment'; import { useContractAttachment } from './use-contract-attachment';
import { useResourceAvatar } from './use-resource-avatar';
import { useResourceRelations } from './use-resource-relations';
import { useUnsavedRecord } from './use-unsaved-record';
type RecordPageMode = 'create' | 'detail' | 'edit'; type RecordPageMode = 'create' | 'detail' | 'edit';
const route = useRoute(); const route = useRoute();
@@ -179,18 +195,17 @@ const detail = ref<ResourceRow>({});
const credentialOwner = ref<ResourceRow>(); const credentialOwner = ref<ResourceRow>();
const record = computed(() => primaryRecord(detail.value)); const record = computed(() => primaryRecord(detail.value));
const form = reactive<Record<string, any>>({}); const form = reactive<Record<string, any>>({});
const relationOptions = reactive<Record<string, ResourceRow[]>>({}); const relations = useResourceRelations(form);
const relationLoading = reactive<Record<string, boolean>>({}); const relationOptions = relations.options;
const relationTimers = new Map<string, ReturnType<typeof setTimeout>>(); const relationLoading = relations.loading;
const actionVisible = ref(false); const actionVisible = ref(false);
const activeAction = ref<DetailAction>(); const activeAction = ref<DetailAction>();
const avatar = useResourceAvatar(); const avatar = useResourceAvatar();
const contractAttachment = useContractAttachment(); const contractAttachment = useContractAttachment();
const hasAvatarField = computed(() => const hasAvatarField = computed(() => definition.value.fields.some((field) => field.key === 'avatar'));
definition.value.fields.some((field) => field.key === 'avatar'),
);
const isCredentialResource = computed(() => definition.value.name === 'staff_credential'); const isCredentialResource = computed(() => definition.value.name === 'staff_credential');
const isContractResource = computed(() => definition.value.name === 'gasorder_contract'); const isContractResource = computed(() => definition.value.name === 'gasorder_contract');
const isOrderCreate = computed(() => definition.value.name === 'gasorder_basic' && mode.value === 'create');
const contractAttachmentState = computed(() => ({ const contractAttachmentState = computed(() => ({
selectedFile: contractAttachment.selectedFile.value, selectedFile: contractAttachment.selectedFile.value,
hasExisting: contractAttachment.hasExisting.value, hasExisting: contractAttachment.hasExisting.value,
@@ -198,37 +213,25 @@ const contractAttachmentState = computed(() => ({
requiresReupload: contractAttachment.requiresReupload.value, requiresReupload: contractAttachment.requiresReupload.value,
removed: contractAttachment.removed.value, removed: contractAttachment.removed.value,
})); }));
const summaryRecord = computed(() => const summaryRecord = computed(() => (mode.value === 'detail' ? record.value : { ...record.value, ...form }));
mode.value === 'detail' ? record.value : { ...record.value, ...form }, const formFields = computed(() =>
); recordFormFields(definition.value.name, definition.value.fields, mode.value as 'create' | 'edit')
const formFields = computed(() => recordFormFields( .filter((field) => field.key !== 'avatar')
definition.value.name, .map((field) => (isCredentialResource.value && field.key === 'staff_account_identity' ? { ...field, readonly: true } : field)),
definition.value.fields,
mode.value as 'create' | 'edit',
).filter((field) => field.key !== 'avatar').map((field) =>
isCredentialResource.value && field.key === 'staff_account_identity'
? { ...field, readonly: true }
: field,
));
const canEditRecord = computed(
() => definition.value.canEdit && !recordEditReason(definition.value, record.value),
); );
const relationBusy = computed(() => relations.isLoading(formFields.value));
const relationUnavailableMessage = computed(() => relations.unavailableMessage(formFields.value));
const canEditRecord = computed(() => definition.value.canEdit && !recordEditReason(definition.value, record.value));
const visibleActions = computed(() => const visibleActions = computed(() =>
(definition.value.detailActions ?? []).filter( (definition.value.detailActions ?? []).filter(
(action) => (action) =>
!( !(definition.value.name === 'gasorder_contract' && record.value.user_service_active === false && action.name !== '终止合同') &&
definition.value.name === 'gasorder_contract' && (!action.visibleFor || action.visibleFor.values.includes(record.value[action.visibleFor.field] as string | number)),
record.value.user_service_active === false &&
action.name !== '终止合同'
) &&
(
!action.visibleFor ||
action.visibleFor.values.includes(record.value[action.visibleFor.field] as string | number)
),
), ),
); );
const unsaved = useUnsavedRecord( const unsaved = useUnsavedRecord(
() => JSON.stringify({ () =>
JSON.stringify({
form, form,
avatar: avatar.marker(), avatar: avatar.marker(),
contractAttachment: contractAttachment.marker(), contractAttachment: contractAttachment.marker(),
@@ -250,9 +253,7 @@ async function loadRecord() {
if (reason) throw new Error(reason); if (reason) throw new Error(reason);
} }
} }
contractAttachment.load( contractAttachment.load(isContractResource.value ? (detail.value.attachment as any) : undefined);
isContractResource.value ? (detail.value.attachment as any) : undefined,
);
if (hasAvatarField.value && mode.value !== 'create') { if (hasAvatarField.value && mode.value !== 'create') {
// 头像属于附加信息,读取失败不能阻断基础资料页面。 // 头像属于附加信息,读取失败不能阻断基础资料页面。
await avatar.load(definition.value.resource, identity.value).catch(() => undefined); await avatar.load(definition.value.resource, identity.value).catch(() => undefined);
@@ -274,15 +275,12 @@ async function loadRecord() {
/** 回查并锁定资质所属配送人员,禁止信任 URL 中的展示名称。 */ /** 回查并锁定资质所属配送人员,禁止信任 URL 中的展示名称。 */
async function loadCredentialOwner() { async function loadCredentialOwner() {
if (!isCredentialResource.value) return; if (!isCredentialResource.value) return;
const queryOwner = typeof route.query.owner_identity === 'string' const queryOwner = typeof route.query.owner_identity === 'string' ? route.query.owner_identity.trim() : '';
? route.query.owner_identity.trim()
: '';
const recordOwner = String(record.value.staff_account_identity ?? ''); const recordOwner = String(record.value.staff_account_identity ?? '');
const targetIdentity = mode.value === 'create' ? queryOwner : recordOwner; const targetIdentity = mode.value === 'create' ? queryOwner : recordOwner;
if (!targetIdentity) throw new Error('缺少配送人员上下文,请从配送人员列表进入'); if (!targetIdentity) throw new Error('缺少配送人员上下文,请从配送人员列表进入');
const owner = await resourceApi.detail<ResourceRow>('/staff_account', targetIdentity); const owner = await resourceApi.detail<ResourceRow>('/staff_account', targetIdentity);
if (!owner || String(owner.identity ?? '') !== targetIdentity) if (!owner || String(owner.identity ?? '') !== targetIdentity) throw new Error('人员详情响应无效');
throw new Error('人员详情响应无效');
credentialOwner.value = owner; credentialOwner.value = owner;
relationOptions['/staff_account'] = [owner]; relationOptions['/staff_account'] = [owner];
if (mode.value !== 'detail') form.staff_account_identity = targetIdentity; if (mode.value !== 'detail') form.staff_account_identity = targetIdentity;
@@ -290,14 +288,22 @@ async function loadCredentialOwner() {
/** 保存新建或编辑表单并进入记录详情。 */ /** 保存新建或编辑表单并进入记录详情。 */
async function save() { async function save() {
if (saving.value) return;
if (relationBusy.value) return Message.warning('合同关联数据正在加载,请稍候');
if (relationUnavailableMessage.value) return Message.warning(relationUnavailableMessage.value);
const validation = validateResourceRecordForm(form, formFields.value, mode.value as 'create' | 'edit'); const validation = validateResourceRecordForm(form, formFields.value, mode.value as 'create' | 'edit');
if (validation) return Message.warning(validation); if (validation) return Message.warning(validation);
if (isOrderCreate.value) {
const amountValidation = orderAmountValidation(orderAmountPreview(form, relationOptions));
if (amountValidation) return Message.warning(amountValidation);
}
saving.value = true; saving.value = true;
try { try {
const payload = buildResourcePayload(formFields.value, form, mode.value as 'create' | 'edit'); const payload = buildResourcePayload(formFields.value, form, mode.value as 'create' | 'edit');
if (hasAvatarField.value) await avatar.applyToPayload(payload); if (hasAvatarField.value) await avatar.applyToPayload(payload);
if (isContractResource.value) await contractAttachment.applyToPayload(payload); if (isContractResource.value) await contractAttachment.applyToPayload(payload);
const saved = mode.value === 'create' const saved =
mode.value === 'create'
? await resourceApi.create<ResourceRow>(definition.value.resource, payload) ? await resourceApi.create<ResourceRow>(definition.value.resource, payload)
: await resourceApi.update<ResourceRow>(definition.value.resource, identity.value, payload); : await resourceApi.update<ResourceRow>(definition.value.resource, identity.value, payload);
const savedIdentity = String(saved?.identity ?? identity.value); const savedIdentity = String(saved?.identity ?? identity.value);
@@ -362,7 +368,8 @@ async function downloadContractAttachment() {
/** 返回列表或编辑来源详情。 */ /** 返回列表或编辑来源详情。 */
function requestBack() { function requestBack() {
const leave = () => mode.value === 'edit' const leave = () =>
mode.value === 'edit'
? router.push(recordRouteLocation(listRouteName.value, 'detail', identity.value, returnPath.value)) ? router.push(recordRouteLocation(listRouteName.value, 'detail', identity.value, returnPath.value))
: returnToList(router, returnPath.value, listRouteName.value); : returnToList(router, returnPath.value, listRouteName.value);
if (mode.value === 'detail') return leave(); if (mode.value === 'detail') return leave();
@@ -393,10 +400,18 @@ function openStatusAction() {
name: '审核状态', name: '审核状态',
resource: `${definition.value.resource}/:identity/status`, resource: `${definition.value.resource}/:identity/status`,
method: 'PATCH', method: 'PATCH',
fields: [{ fields: [
key: 'status', label: '目标状态', type: 'select', required: true, {
options: [{ label: '启用', value: 1 }, { label: '停用', value: 2 }], key: 'status',
}], label: '目标状态',
type: 'select',
required: true,
options: [
{ label: '启用', value: 1 },
{ label: '停用', value: 2 },
],
},
],
}); });
} }
@@ -418,37 +433,29 @@ function confirmArchive() {
}); });
} }
/** 加载关联候选配送人员关系固定为配送角色。 */ /** 搜索关系候选配送人员资质继续使用锁定的单一人员。 */
async function loadRelation(resource: string, keyword = '') { function searchRelation(field: ResourceField, keyword: string) {
if (isCredentialResource.value && resource === '/staff_account') { if (isCredentialResource.value && field.relation === '/staff_account') {
void loadCredentialOwner();
return;
}
relations.search(field, keyword);
}
/** 处理合同变化,原子清空并重载地址和合同气瓶。 */
async function changeRelation(field: ResourceField) {
if (isCredentialResource.value && field.relation === '/staff_account') {
await loadCredentialOwner(); await loadCredentialOwner();
return; return;
} }
relationLoading[resource] = true; await relations.change(field);
try {
const filters: Record<string, string> = keyword ? { keyword } : {};
if (resource === '/staff_account') filters.role_code = 'delivery';
relationOptions[resource] = (await resourceApi.list<ResourceRow>(resource, 1, 100, filters)).list;
} catch (error) {
Message.error(`关联数据加载失败:${(error as Error).message}`);
} finally {
relationLoading[resource] = false;
}
}
/** 对关联搜索做短延迟合并。 */
function searchRelation(resource: string | undefined, keyword: string) {
if (!resource) return;
const timer = relationTimers.get(resource);
if (timer) clearTimeout(timer);
relationTimers.set(resource, setTimeout(() => loadRelation(resource, keyword.trim()), 250));
} }
onMounted(async () => { onMounted(async () => {
if ( if (
isCredentialResource.value && mode.value === 'create' && isCredentialResource.value &&
(typeof route.query.owner_identity !== 'string' || mode.value === 'create' &&
route.query.relation_key !== 'staff_account_identity') (typeof route.query.owner_identity !== 'string' || route.query.relation_key !== 'staff_account_identity')
) { ) {
Message.warning('请从配送人员列表进入资质新建页'); Message.warning('请从配送人员列表进入资质新建页');
await router.replace({ name: 'staff-delivery' }); await router.replace({ name: 'staff-delivery' });
@@ -456,14 +463,11 @@ onMounted(async () => {
} }
await loadRecord(); await loadRecord();
if (errorMessage.value) return; if (errorMessage.value) return;
const paths = new Set( const relationFields = [...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])];
[...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])] relations.configure(relationFields);
.map((field: ResourceField) => field.relation)
.filter((value): value is string => Boolean(value)),
);
try { try {
await Promise.all([...paths].map((resource) => loadRelation(resource))); if (isCredentialResource.value) await loadCredentialOwner();
if (isCredentialResource.value && mode.value === 'detail') await loadCredentialOwner(); await relations.preload(relationFields.filter((field) => !(isCredentialResource.value && field.relation === '/staff_account')));
} catch (error) { } catch (error) {
errorMessage.value = `所属配送人员加载失败:${(error as Error).message}`; errorMessage.value = `所属配送人员加载失败:${(error as Error).message}`;
} }

View File

@@ -0,0 +1,216 @@
/**
* 功能描述:加载配送点资源关系候选,并处理合同驱动的父子联动与过期响应。
* 版本v1.0.0。
*/
import { Message } from '@arco-design/web-vue';
import { onBeforeUnmount, reactive } from 'vue';
import { resourceApi } from '@/api/resource';
import type { ResourceRow } from '@/api/resource-record-form';
import type { ResourceField } from '@/api/resources';
/** 创建关系候选状态;同一资源仅允许最新请求更新页面。 */
export function useResourceRelations(form: Record<string, any>) {
const options = reactive<Record<string, ResourceRow[]>>({});
const loading = reactive<Record<string, boolean>>({});
const versions = new Map<string, number>();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
function begin(resource: string) {
const version = (versions.get(resource) ?? 0) + 1;
versions.set(resource, version);
return version;
}
function isLatest(resource: string, version: number) {
return versions.get(resource) === version;
}
/** 清空候选并使尚未返回的旧请求失效。 */
function clear(resource: string | undefined) {
if (!resource) return;
begin(resource);
options[resource] = [];
loading[resource] = false;
}
/** 返回字段当前必须携带的静态与父级筛选参数。 */
function filters(field: ResourceField) {
const linkage = field.relationLinkage;
const parent = linkage ? String(form[linkage.parentKey] ?? '').trim() : '';
return {
...(field.relation === '/staff_account' ? { role_code: 'delivery' } : {}),
...(field.relationFilters ?? {}),
...(linkage && parent ? { [linkage.filterKey]: parent } : {}),
};
}
/** 加载一个关系字段,搜索时仍保留当前已选候选的可读文案。 */
async function load(field: ResourceField, keyword = '') {
const resource = field.relation;
if (!resource) return;
const linkage = field.relationLinkage;
if (
linkage?.requiresParent &&
!String(form[linkage.parentKey] ?? '').trim()
) {
clear(resource);
return;
}
const version = begin(resource);
loading[resource] = true;
const selected: string[] = (
Array.isArray(form[field.key]) ? form[field.key] : [form[field.key]]
)
.map((value: unknown) => String(value ?? ''))
.filter(Boolean);
try {
const rows = (
await resourceApi.list<ResourceRow>(resource, 1, 100, {
...filters(field),
...(keyword ? { keyword } : {}),
})
).list;
if (!isLatest(resource, version)) return;
const validIdentities = new Set(
rows.map((row) => String(row.identity ?? '')),
);
if (
field.relationStrictFilter &&
!keyword &&
selected.some((identity) => !validIdentities.has(identity))
) {
form[field.key] = field.type === 'identity-list' ? [] : '';
Message.info(
field.relationInvalidMessage ?? '所选关联数据已失效,已清空',
);
}
const merged = [...rows];
const identities = new Set(validIdentities);
for (const identity of selected) {
if (field.relationStrictFilter && !keyword) continue;
const current = (options[resource] ?? []).find(
(row) => String(row.identity ?? '') === identity,
);
if (current && !identities.has(identity)) merged.push(current);
}
options[resource] = merged;
if (!selected.length && field.relationAutoSelectKey) {
const preferred = rows.find((row) =>
Boolean(row[field.relationAutoSelectKey as string]),
);
if (preferred && isLatest(resource, version))
form[field.key] = String(preferred.identity ?? '');
}
} catch (error) {
if (isLatest(resource, version))
Message.error(`关联数据加载失败:${(error as Error).message}`);
} finally {
if (isLatest(resource, version)) loading[resource] = false;
}
}
/** 先加载普通关系,再加载已有父值约束下的子关系。 */
async function preload(fields: ResourceField[]) {
const unique = new Map<string, ResourceField>();
for (const field of fields) {
if (field.relation && !unique.has(field.relation))
unique.set(field.relation, field);
}
await Promise.all(
[...unique.values()]
.filter((field) => !field.relationLinkage)
.map((field) => load(field)),
);
await Promise.all(
[...unique.values()]
.filter((field) => field.relationLinkage)
.map((field) => load(field)),
);
}
/** 父关系变化时原子清空所有子值,再按新父值加载候选。 */
async function change(field: ResourceField) {
const fields = configuredFields;
const children = fields.filter(
(item) => item.relationLinkage?.parentKey === field.key,
);
await Promise.all(
children.map(async (child) => {
const previous = Array.isArray(form[child.key])
? form[child.key].length > 0
: Boolean(form[child.key]);
form[child.key] = child.type === 'identity-list' ? [] : '';
clear(child.relation);
if (previous)
Message.info(
child.relationLinkage?.parentChangeMessage ??
'上级数据已变更,请重新选择',
);
await load(child);
}),
);
}
let configuredFields: ResourceField[] = [];
function configure(fields: ResourceField[]) {
configuredFields = fields;
}
/** 搜索采用 250ms 防抖,并始终附带当前父级过滤。 */
function search(field: ResourceField, keyword: string) {
const resource = field.relation;
if (!resource) return;
const timer = timers.get(resource);
if (timer) clearTimeout(timer);
timers.set(
resource,
setTimeout(() => {
timers.delete(resource);
void load(field, keyword.trim());
}, 250),
);
}
/** 返回必填候选为空时的业务提示。 */
function unavailableMessage(fields: ResourceField[]) {
for (const field of fields) {
if (!field.required || !field.relation || !field.relationEmptyText)
continue;
const linkage = field.relationLinkage;
if (
linkage?.requiresParent &&
!String(form[linkage.parentKey] ?? '').trim()
)
continue;
if (
!loading[field.relation] &&
(options[field.relation] ?? []).length === 0
)
return field.relationEmptyText;
}
return '';
}
function isLoading(fields: ResourceField[]) {
return fields.some((field) =>
Boolean(field.relation && loading[field.relation]),
);
}
onBeforeUnmount(() => {
for (const timer of timers.values()) clearTimeout(timer);
});
return {
options,
loading,
configure,
preload,
load,
change,
search,
clear,
unavailableMessage,
isLoading,
};
}