# 历史记录选择器配置设计讨论 ## 一、这份设计要解决什么问题 业务页面经常需要从以前的单据中找一条或几条记录,然后带回当前页面。例如: - 从以前的业务单中选择发货人; - 从以前的业务单中选择收货人或通知人; - 从以前的费用记录中选择费用,加入当前费用明细; - 从以前的业务单中选择箱子、货物等子表数据。 现在每个页面都自己写一个弹窗,自己拼查询 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 拼接和隐含的字段对应关系。