Files
workspace/code/fms/FMS历史记录选择器配置设计讨论.md
T
2026-09-11 17:31:34 +08:00

729 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 历史记录选择器配置设计讨论
## 一、这份设计要解决什么问题
业务页面经常需要从以前的单据中找一条或几条记录,然后带回当前页面。例如:
- 从以前的业务单中选择发货人;
- 从以前的业务单中选择收货人或通知人;
- 从以前的费用记录中选择费用,加入当前费用明细;
- 从以前的业务单中选择箱子、货物等子表数据。
现在每个页面都自己写一个弹窗,自己拼查询 SQL,自己决定选中后怎么回填。这样做有几个明显问题:代码重复,字段改名后容易出错,同一套功能不能复用,业务人员也不能只通过配置调整规则。
这次希望把它做成一个通用能力:
```text
业务动作配置 + 通用选择器 + 字段映射
```
简单说就是:
- 通用选择器只负责“查数据、让用户选择”;
- 动作配置负责“查哪个模块、固定过滤什么、选中后写到哪里”;
- 业务页面只提供当前表单数据,并接收选择结果,最后仍由页面统一保存。
## 二、整体使用方式
在模块管理中增加“动作”页签。例如,在 `bs_business` 模块下配置:
```text
选择历史发货人
选择历史收货人
选择历史通知人
选择历史费用
```
业务页面加载当前模块的动作配置,由页面自己决定按钮放在哪里、什么时候显示。点击业务页面上的按钮后,打开统一组件:
```text
src/components/fms-record-picker/FmsRecordPickerModal.vue
```
页面只需要告诉组件:
```text
动作编码:select_history_shipper
当前主表数据
当前正在编辑的子表行(如果有)
当前页面已经存在的目标数据
```
页面不再传 `wheresql`,也不再自己拼接 SQL。
### 配置入口放在哪里
动作配置放在现有的**模块管理页面**中,不另外新建一个独立的配置页面。
使用方式是:
```text
模块管理
→ 选择目标模块:cw_business
→ 动作
├─ 选择历史费用
├─ 选择历史发货人
└─ 选择历史收货人
```
这样做是因为动作属于目标业务模块:它描述的是“当前模块可以执行什么选择和回填操作”。在这里配置动作编码、来源模块、选择方式、是否允许重复以及字段映射等内容即可。
弹窗中让客户填写的查询条件,仍然配置在**来源模块**的“查询”页签中:
```text
模块管理
→ 选择来源模块:cw_business_history
→ 查询
├─ 业务单号
├─ 发货人
├─ 发生日期
└─ 状态
```
两者的职责要分开:
```text
目标模块 → 动作页签 → 配置从哪里选择、选中后写到哪里
来源模块 → 查询页签 → 配置弹窗中客户可以填写哪些筛选条件
```
按钮放在主工具栏、子表工具栏还是字段旁边,由业务页面代码决定,不在模块管理中配置。这样可以复用模块管理现有结构,又不会把页面布局做成低代码配置。
## 三、动作配置写什么
### 1. 基本信息
```text
动作编码:select_history_shipper
动作名称:选择历史发货人
动作类型:copy
启用状态:启用
排序号:10
```
动作编码必须稳定。它用于程序识别、配置导入导出和不同环境之间迁移,不能随意修改。
### 2. 从哪里选择
```text
来源模块:bs_business_history
选择模式:单选 / 多选
```
来源模块直接复用已有模块元数据:
- 列表默认显示 `s_field_view`;
- 查询条件默认使用 `s_field_query`;
- 字段标题、类型和格式使用 `s_field`。
动作不新建模块,也不复制一套字段定义。它只在来源模块的基础配置上,引用并裁剪本次选择器需要的展示列和查询项。
动作可以配置:
```text
展示列模式:inherit / explicit
查询条件模式:inherit / explicit
```
含义如下:
- `inherit`:沿用来源模块启用的 `s_field_view` 或 `s_field_query`;
- `explicit`:只使用动作配置中明确引用的展示列或查询项;
- `explicit` 且没有配置项:表示本动作明确不提供对应的展示列或用户查询项,不能再解释为“继承默认配置”。
动作字段配置只保存对既有配置的引用,不重新定义字段名称、类型、权限和操作符。来源模块仍然是字段定义的唯一来源。
## 四、单选、多选和重复选择是三件事
这三个概念不能混在一起:
```text
选择模式 一次点击“确定”可以选几条
重复选择策略 同一条来源记录以后能不能再加入
去重标识 系统凭什么判断两次选择的是同一条记录
```
动作配置建议增加以下内容:
```text
选择模式:single / multiple
重复选择策略:forbid / allow
重复时处理:skip / warn / error
去重字段:来源模块的稳定主键
```
含义如下:
- `single`:一次只能选一条;
- `multiple`:一次可以选多条;
- `forbid`:已经加入过的来源记录,之后不能再次加入;
- `allow`:允许再次打开选择器并再次加入同一条来源记录;
- `skip`:重复记录直接跳过;
- `warn`:提示用户后跳过;
- `error`:发现重复就不允许确认。
默认建议:
```text
pick / copy single + forbid + error
append / replace multiple + forbid + warn
```
只有业务明确允许重复时,才配置 `allow`。
要注意:`allow` 是指“以后可以再次选择”,不表示一次确认时可以把同一行选两遍。若要一条记录生成多行,应另加数量或复制次数功能,不放进选择器的基本选择逻辑里。
### 去重必须有稳定的来源 ID
不能用表格行号判断重复,也不能只比较名称、编码等容易重复的字段。应使用来源模块的稳定主键,例如 `b_id` 或 `subid`。
如果目标是子表,建议把来源主键保存到目标子表的隐藏字段中:
```text
来源 subid → 目标 mx_source_subid
```
这个隐藏字段不显示给用户,但用于:
- 再次打开选择器时排除已经选过的数据;
- 防止分页或重复打开造成重复加入;
- 追踪当前行来自哪条历史记录;
- 后续撤销、同步和问题排查。
## 五、查询条件怎么设计
查询条件分两部分:
### 查询条件到底配置在哪里
客户在选择器中看到并填写的查询项,基础配置仍然来自**来源模块**的 `s_field_query`(模块管理中的“查询”页签)。动作可以选择直接继承,也可以通过动作查询配置引用其中的子集。
例如来源模块 `bs_business_history` 的“查询”页签配置了:
```text
业务单号:包含
发货人:包含
发生日期:日期范围
客户:下拉选择
```
打开 `FmsRecordPickerModal` 时,后端先按动作的 `b_query_mode` 解析有效查询项,再返回给组件渲染。客户填写后点击“查询”,这些条件只影响本次弹窗的数据列表。
动作配置中的 `s_module_action_filter` 是另一回事。它保存的是动作必须带上的固定条件,客户看不到,也不能修改,例如“只能查当前客户的历史记录”。
可以这样理解:
```text
s_field_query 客户可以填写的查询条件
s_module_action_filter 系统强制追加的固定条件
```
最终查询是两者合在一起:
```text
来源模块查询条件 AND 动作固定条件
```
动作查询配置只引用来源模块已有的 `s_field_query` 记录,不复制字段定义或查询语义。这样同一个来源模块可以支持多个选择器动作,每个动作使用不同的查询项集合。
例如:
```text
来源模块查询配置:业务单号、发货人、收货人、客户、发生日期、状态
选择历史发货人:业务单号、发货人、发生日期
选择历史收货人:业务单号、收货人、发生日期
选择历史费用:费用名称、发生日期、币种、状态
```
动作固定条件不进入用户查询区。用于固定过滤、去重和关联的技术字段可以不显示,也不能作为用户临时查询项。
### 1. 动作固定条件
这是动作本身规定的条件,用户不能修改。例如“选择历史费用”只能查同一个客户、同一种费用类型:
```text
历史费用.客户 = 当前主表.客户
历史费用.费用类型 = 当前操作的费用类型
历史费用.来源单据 ID != 当前单据 ID
```
### 2. 用户临时查询条件
这是用户在弹窗中输入的条件,例如费用名称、日期、状态、编码等。动作使用 `inherit` 时复用来源模块的 `s_field_query`;使用 `explicit` 时只显示动作引用的查询项,控件和操作符仍由被引用的来源查询配置决定。
例如来源模块配置了:
```text
费用名称:包含
发生日期:日期范围
币种:下拉选择
```
选择器按动作的有效查询项显示这些查询控件。用户点击查询后,条件和动作固定条件一起生效。
### 3. 前端只传结构化条件,不传 SQL
前端请求示例:
```json
{
"actionCode": "select_history_fee",
"context": {
"main": {
"b_customer_id": 8,
"b_id": 125
},
"detail": {
"mx_type_id": "应收"
}
},
"query": {
"page": 1,
"pageSize": 20,
"conditions": [
{
"fieldId": 610,
"operator": "like",
"value": "海运"
},
{
"fieldId": 611,
"operator": "gte",
"value": "2026-01-01"
}
]
}
}
```
前端和数据库配置中都不出现 SQL。后端根据字段 ID、操作符和字段类型生成参数化查询。
### 4. 动作条件的值从哪里来
建议支持以下值来源:
```text
current.main.field 当前主表字段
current.detail.field 当前子表字段
constant 固定值
user.id 当前用户
user.orgId 当前机构
today 当前日期
```
例如:
```text
来源字段:mx_dw_id
操作符:等于
值来源:current.main.field
当前字段:b_customer_id
```
运行时就是“来源记录的 `mx_dw_id` 等于当前主表的 `b_customer_id`”。
### 5. 空值不能被悄悄忽略
如果当前客户还没有填写,不能因为值为空就直接删掉“客户相等”这个条件,否则可能查出所有客户的历史记录。
每个固定条件建议配置空值处理方式:
```text
reject 不允许打开选择器,并提示先填写条件
empty 返回空结果
is_null 按 IS NULL 查询
ignore 忽略这个条件(只对明确允许的场景使用)
```
历史记录选择默认使用 `reject` 或 `empty`。
### 6. 条件组合
第一阶段只支持多个条件之间使用 `AND`,已经可以覆盖大部分历史记录场景:
```text
客户相同 AND 当前单据不同 AND 费用类型相同
```
以后确实需要时,再增加条件组和 `OR`。不要一开始就允许用户输入任意 SQL 或任意表达式。
## 六、字段映射怎么设计
选中的来源字段和目标字段不一定同名,所以要配置映射。例如选择历史发货人:
```text
来源字段 目标字段
b_shipper b_shippermemo
b_shipper_contact b_shipper_contact
b_shipper_phone b_shipper_phone
b_customer_id b_customer_id
```
选择历史费用并追加到费用子表:
```text
来源 b_fee_code → 目标 b_fee_code
来源 b_fee_name → 目标 b_fee_name
来源 b_amount → 目标 b_amount
来源 b_currency → 目标 b_currency
来源 subid → 目标 mx_source_subid(隐藏去重字段)
```
映射配置应从源模块和目标模块的字段元数据中选择字段,保存字段 ID,不让用户手工填写字段名。
字段映射不是新的字段定义。来源字段和目标字段都必须引用现有 `s_field`,字段名称、类型、可空性、写入模式和启用状态始终以 `s_field` 为准。
每条映射还可以配置:
```text
目标范围:主表 / 子表
写入方式:覆盖 / 追加 / 合并
转换方式:原值 / 数字 / 日期 / 拼接
是否必需:是 / 否
是否为去重字段:是 / 否
```
如果目标是子表,运行时根据 `s_relation` 自动补齐父 ID、临时 ID 和排序号。目标字段的只读、计算、系统字段规则仍然有效,不能通过动作配置绕过。
动作配置只能缩小字段范围,不能提升字段权限。运行时的有效字段按以下规则计算:
```text
可展示/可查询来源字段
= 动作引用的字段
∩ 来源模块中存在且启用的字段
∩ 当前用户拥有 view 权限的字段
可写目标字段
= 动作映射的字段
∩ 目标模块中存在且启用的字段
∩ 当前用户拥有 edit 权限的字段
∩ s_field.b_writemode 允许写入
```
固定过滤字段、去重主键和关系父 ID 等技术字段可以不展示给用户,但后端只能将它们用于动作执行,不能因为动作配置而把它们作为普通可见字段返回。
## 七、重复选择的运行规则
系统需要同时考虑三种“已经选过”的数据:
```text
本次弹窗已经选过的记录
当前页面草稿中已经存在的记录
数据库中已经保存的关联记录
```
当动作配置为 `reselectPolicy = forbid` 时,这三类记录都应显示为“已选择”并禁用,而不是简单从表格中删除。这样用户知道它们存在,也知道为什么不能选。
确认时还要再次检查重复,不能只依赖前端界面:
```text
用户点击确定
↓
系统根据去重字段重新检查
↓
按 skip / warn / error 处理
↓
生成最终的回填结果
```
当配置为 `allow` 时,同一来源记录可以再次生成新的目标行;系统不自动去重,但仍应保留来源 ID,方便追踪。
## 八、动作类型
动作类型先定义为有限集合;第一阶段只落地 `copy` 和 `append`:
```text
pick 选择一条记录,返回关联值
copy 选择一条记录,复制到当前主表
append 选择多条记录,追加到目标子表
replace 选择多条记录,替换目标子表(后续阶段)
```
不要一开始支持任意脚本或任意客户端函数。遇到特殊转换时,增加明确命名的转换类型或受控处理器。
## 九、建议的数据表
第一版使用规范化配置表,不把所有内容塞进一个 JSON 字段。
### `s_module_action`
保存动作基本信息:
```text
b_id
b_code 动作编码
b_name 动作名称
b_module_id 目标模块
b_action_type pick/copy/append/replace
b_source_module_id 来源模块
b_target_module_id 结果写入的目标模块;写入当前主表时可等于目标模块
b_target_relation_id 主表到目标子表的关系;写入主表时为空
b_selection_mode single/multiple
b_reselect_policy forbid/allow
b_duplicate_behavior skip/warn/error
b_dedupe_field_id 来源去重字段
b_view_mode inherit/explicit
b_query_mode inherit/explicit
b_canuse
b_xh
```
`b_target_scope = main/detail` 不足以表达一个模块有多个子表,因此使用目标模块和目标关系确定写入位置。`b_view_mode` 与 `b_query_mode` 用于区分“未配置,继承来源模块”和“明确配置为空,不显示/不提供查询项”。
### `s_module_action_filter`
保存动作固定过滤条件:
```text
b_id
b_action_id
b_source_field_id
b_operator
b_value_type
b_value_field_id
b_value_text
b_empty_behavior reject/empty/is_null/ignore
b_logic_group 第一阶段可固定为 0
b_logic_operator 第一阶段固定为 AND
b_xh
```
### `s_module_action_column`
保存动作引用的展示列:
```text
b_id
b_action_id
b_source_field_view_id 引用来源模块的 s_field_view
b_visible
b_xh
b_width_override
```
动作使用 `explicit` 模式时,只显示被引用的 `s_field_view`;`inherit` 模式下直接使用来源模块的 `s_field_view`。动作不复制字段定义、字段标题或字段权限。
### `s_module_action_query`
保存动作引用的用户查询项:
```text
b_id
b_action_id
b_source_field_query_id 引用来源模块的 s_field_query
b_visible
b_xh
```
动作使用 `explicit` 模式时,只显示被引用的 `s_field_query`;`inherit` 模式下直接使用来源模块的 `s_field_query`。查询字段的类型和操作符以被引用的来源查询配置为准,第一版不在动作表中重复定义。
### `s_module_action_mapping`
保存选择结果的字段映射:
```text
b_id
b_action_id
b_source_field_id
b_target_module_id
b_target_field_id
b_write_mode
b_transform
b_required
b_is_dedupe_key
b_xh
```
### 动作配置与字段权限的关系
动作定义和权限定义分开保存:
```text
s_module_action
描述动作如何查、如何选、如何映射
s_power
b_power_type = action
描述谁可以执行这个动作
```
动作编码使用目标模块编码和动作编码组成稳定身份,例如:
```text
cw_business.select_history_fee
```
对应的 `s_power` 动作权限使用目标模块作为 owner,生成类似 `action.module.cw_business.select_history_fee.execute` 的全局唯一业务键,能力为 `execute`。动作配置不能绕过 `s_power`,字段策略也不能被动作配置提升。后端必须同时校验动作执行权限、来源字段最终为 `view` 或 `edit`、以及目标字段最终为 `edit`。
来源字段被停用、删除或当前用户无权查看时,动作应在配置校验或运行时被判定为无效,不能静默删掉映射项继续执行。
## 十、运行时流程
```text
业务页面加载目标模块动作配置
↓
业务页面上的按钮打开 FmsRecordPickerModal,并传入当前表单上下文
↓
解析动作的展示列模式和查询条件模式,引用来源模块配置的对应子集
↓
解析动作固定条件
↓
合并用户输入的查询条件
↓
按分页查询并显示“已选择/可选择”状态
↓
用户选择一条或多条记录
↓
检查重复选择和必填映射
↓
根据 mapping 生成主表 patch 或子表新行
↓
回写当前页面草稿
↓
由页面统一 handleSave()
```
选择器不负责保存数据库。映射结果先进入当前页面的草稿、diff 和校验流程,和手工新增的数据一样,最后统一保存。
建议选择确认事件返回记录或 patch,不返回 SQL。客户端确认时只提交来源记录的稳定主键,后端重新读取来源记录并生成 patch:
```json
{
"actionCode": "select_history_fee",
"rows": [
{
"subid": "fee-001",
"mx_fee_code": "F001",
"mx_fee_name": "海运费",
"mx_amount": 1200
}
],
"patch": {
"detailRows": [
{
"mx_source_subid": "fee-001",
"mx_fee_code": "F001",
"mx_amount": 1200
}
]
}
}
```
页面通常只需要把 `patch` 合并到草稿;`rows` 可用于提示、审计或特殊业务处理。
## 十一、组件和后端各自负责什么
### `FmsRecordPickerModal`
- 显示弹窗、查询区、表格和分页;
- 使用来源模块元数据渲染查询项和展示列;
- 显示哪些记录已选择;
- 管理本次操作中的选中状态;
- 返回用户选择的记录或动作执行结果。
### 动作执行器(建议放在后端或独立服务层)
- 读取并校验动作配置;
- 解析动作专用展示列和查询项,并与来源模块配置做一致性校验;
- 校验用户对来源模块的访问权限;
- 校验当前用户是否拥有动作对应的 `s_power` 执行权限;
- 校验来源字段 `view` 权限、目标字段 `edit` 权限和字段写入模式;
- 解析当前主表、子表、用户和日期等值来源;
- 校验字段 ID、目标模块和操作符;
- 生成参数化查询;
- 根据选中的来源主键重新读取来源数据;
- 执行去重检查和字段映射。
### 业务页面
- 提供当前页面上下文;
- 接收 patch 并更新草稿;
- 执行页面自己的计算和校验;
- 通过统一的保存流程提交。
## 十二、安全和校验要求
- 来源模块必须通过当前用户权限校验;
- 动作必须通过对应的 `s_power` `execute` 权限校验;
- 后端不能信任前端传来的任意字段、任意 SQL 或任意脚本;
- 过滤条件必须使用字段 ID、操作符和值的结构化表达;
- 后端按字段类型限制可用操作符;
- 动作展示列和查询项只能引用来源模块中存在且启用的 `s_field_view` / `s_field_query`;
- 动作配置只能缩小来源模块字段范围,不能提升字段 `view` / `edit` 权限;
- 映射目标必须属于目标模块或目标子模块;
- `readOnly`、`computed`、`system` 字段的写入规则继续生效;
- 来源字段删除或停用后,动作配置显示为无效并禁止启用;
- 配置导入导出使用动作编码和关系编码,运行时再解析内部 ID;
- 数据库配置中不保存可执行的任意 JavaScript;
- 确认时重新检查权限、字段合法性和重复记录,不能只依赖前端禁用状态。
## 十三、建议的实施顺序
### 第一阶段:先做通用选择器
先用内存中的声明式配置验证:
- 来源模块加载;
- 复用默认列表列和查询字段;
- 单选、多选;
- 动作固定过滤条件;
- 当前主表字段取值;
- 主表字段映射;
- 子表追加;
- `forbid` 和 `allow` 两种重复选择策略;
- 动作展示列和查询条件的 `inherit / explicit` 两种模式;
- 来源字段和目标字段权限交集校验;
- 分页和重新打开弹窗后的已选状态。
### 第二阶段:增加数据库配置
增加以下表和模块管理中的“动作”页签:
- `s_module_action`;
- `s_module_action_filter`;
- `s_module_action_column`;
- `s_module_action_query`;
- `s_module_action_mapping`。
动作列和动作查询项只引用来源模块已有的 `s_field_view` / `s_field_query`,不复制字段定义。同步增加动作配置的启用校验、字段权限校验和无效字段提示。
### 第三阶段:迁移业务操作
优先实现:
1. 选择历史发货人;
2. 选择历史收货人;
3. 选择历史通知人;
4. 选择历史费用;
5. 选择箱子或其他业务子表记录。
`replace`、复合去重键、`OR` 条件和自定义脚本转换不作为第一阶段的必要能力。迁移完成后,删除页面中按业务名称编写的专用选择弹窗和字段匹配代码。
## 十四、已确定和仍需确定的事项
### 已确定
- 来源模块是数据、字段和基础查询配置的唯一来源,不为每个选择器复制模块;
- 动作只引用并裁剪来源模块已有的 `s_field_view` / `s_field_query`;
- 动作展示列和查询项通过 `inherit / explicit` 明确区分继承和显式空配置;
- 字段权限由 `s_user_field_permission` 的 `hidden/view/edit` 策略统一控制,动作配置不能提升权限;
- 动作执行权限使用独立的 `s_power` `action/execute` 权限;
- 第一版优先实现 `fill/copy` 和 `append`,`replace` 延后;
- 固定过滤、去重和关系字段可以隐藏,但不能作为普通可见数据返回前端。
### 仍需确定
- `append` 追加时重复记录默认采用 `warn + skip`,还是直接阻止确认;
- `replace` 后续是否保留被替换行的来源关系;
- 是否允许复合去重键,而不只是单个稳定主键;
- 特殊字段转换是否需要受控的转换器注册表;
- 是否需要动作配置的预览和测试执行功能;
- 来源模块为视图且 `b_keyfield` 不可靠时,是否允许配置受控的复合稳定键;
- 业务页面是否允许少量自定义动作插槽。
## 十五、一句话总结
把“历史记录选择”当成一种可配置的业务动作:来源模块负责数据和字段定义,动作决定本次选择器展示哪些列、提供哪些查询项、怎么筛、能不能重复选以及选中后写到哪里;字段权限和动作执行权限由权限模型统一校验;通用选择器负责展示和选择;业务页面只接收结果并统一保存。这样无需为不同选择器复制模块,也能避免页面代码里出现 SQL 拼接和隐含的字段对应关系。