Compare commits
7 Commits
1f68bd5ca8
...
a677220f4d
| Author | SHA1 | Date | |
|---|---|---|---|
| a677220f4d | |||
| 758b1c4f77 | |||
| 0ae2db1808 | |||
| e786bd5bf0 | |||
| a22c527daf | |||
| bd81c6dae6 | |||
| b9fbf357a4 |
@@ -81,10 +81,13 @@
|
||||
### 测试
|
||||
- 全量:`pytest tests -q`
|
||||
- 说明:`tests/README.md`
|
||||
- 远程环境初始化工具:`python tools/remote_env.py --config tools/remote_env.example.yaml init|run|stop|clear`
|
||||
- 测试总览:`docs/testing_guide.md`
|
||||
|
||||
### 其他工具
|
||||
- UI 调试:`python -m ui.main --host 127.0.0.1 --port 8765`
|
||||
- 运维与排障:`docs/operations.md`
|
||||
- 远程测试环境:`tools/remote_env.py`
|
||||
|
||||
## 文档导航
|
||||
|
||||
@@ -96,5 +99,6 @@
|
||||
- 库化与接入:`docs/library_integration_guide.md`
|
||||
- Collection 查询与升级:`docs/collection_query_and_upgrade_plan.md`
|
||||
- 运维迭代:`docs/operations.md`
|
||||
- 测试指南:`docs/testing_guide.md`
|
||||
|
||||
历史资料已归档到 `docs/archive/`。
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Construction 领域重构指南
|
||||
|
||||
## 1. 范围
|
||||
|
||||
本文档应覆盖 construction 领域中两部分字段的并集:
|
||||
|
||||
- 集团侧 `enum_config` 中 construction 相关的全部字段
|
||||
- depm 中所有已经使用代码 enum、代码中的初始化列表、硬编码 dict、类 enum、`config` 表、`dict` 表承载,并且会影响施工初始化、手动创建、手动编辑、接口展示或业务校验的相关字段
|
||||
|
||||
当前这份文档先给出 construction 领域的总范围和主线判断,再把本轮优先落地对象收敛到一条主线:
|
||||
|
||||
- `construction.section_type_code`
|
||||
|
||||
以下复杂问题本轮先不展开具体迁移方案,但仍属于 construction 领域后续必须补齐的梳理范围:
|
||||
|
||||
- `task_id`
|
||||
- `task_type`
|
||||
- 其它施工节点层级和映射规则特别复杂的问题
|
||||
|
||||
也就是说,施工文档不是永久只看 section,而是本轮先把 section 这一层讲清楚,同时把 task 相关复杂问题明确记为后续梳理项,而不是视为领域外问题。
|
||||
|
||||
## 2. 当前现状
|
||||
|
||||
construction 领域当前至少有两类配置来源:
|
||||
|
||||
### 2.1 `construction_section_config`
|
||||
|
||||
当前按项目类型配置 section 信息,例如光伏下的:
|
||||
|
||||
- `code`
|
||||
- `name`
|
||||
- `weight`
|
||||
- `project_root_nodes`
|
||||
|
||||
这份配置同时承担:
|
||||
|
||||
- section 候选项来源
|
||||
- 施工初始化入口信息来源
|
||||
|
||||
### 2.2 `construction_node_config`
|
||||
|
||||
当前按项目类型配置节点信息,例如:
|
||||
|
||||
- `task_code`
|
||||
- `type_id`
|
||||
- `type_name`
|
||||
- `unit`
|
||||
- `weight`
|
||||
- `is_primary`
|
||||
- `section`
|
||||
|
||||
这份配置描述的是节点补充信息,不是字段级配置。
|
||||
|
||||
## 3. 重构目标
|
||||
|
||||
construction 领域本轮的目标不是重写全部施工配置,而是:
|
||||
|
||||
- 把 section 候选项迁到新的字段级配置
|
||||
- 保留 `construction_node_config` 作为自由业务配置
|
||||
- 让施工初始化和手动校验可以逐步复用统一的字段级入口
|
||||
|
||||
## 4. `construction.section_type_code` 的新结构
|
||||
|
||||
`construction_section_config` 应改造成新的字段级 key:
|
||||
|
||||
- `construction.section_type_code`
|
||||
|
||||
它的 JSON 中必须包含:
|
||||
|
||||
- `field_config`
|
||||
|
||||
并且可以额外包含业务自定义字段,例如:
|
||||
|
||||
- `description`
|
||||
- `init_config`
|
||||
|
||||
推荐结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "施工区字段配置",
|
||||
"field_config": [
|
||||
{"value": "GF", "label": "光伏场区", "weight": 1.4},
|
||||
{"value": "SY", "label": "升压站", "weight": 1.4},
|
||||
{"value": "SC", "label": "送出(自建)", "weight": 1.2},
|
||||
{"value": "SCDW", "label": "送出(电网建设)", "weight": 1.0}
|
||||
],
|
||||
"init_config": {
|
||||
"root_nodes_by_value": {
|
||||
"GF": [
|
||||
{"type_id": "01", "type_code": "01", "type_name": "光伏区土建工程"},
|
||||
{"type_id": "03", "type_code": "03", "type_name": "光伏区安装工程"}
|
||||
],
|
||||
"SY": [
|
||||
{"type_id": "02", "type_code": "02", "type_name": "升压站土建工程"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里的原则是:
|
||||
|
||||
- `field_config` 负责 section 候选项
|
||||
- `init_config` 负责 section 相关初始化附属数据
|
||||
- 两者可以同处一个 JSON 中,不需要再拆第二份 section 配置
|
||||
|
||||
## 5. `construction_node_config` 的定位
|
||||
|
||||
`construction_node_config` 不迁到字段级配置。
|
||||
|
||||
它仍然是自由业务配置,负责描述:
|
||||
|
||||
- 可能节点有哪些种类
|
||||
- 哪些节点是主节点
|
||||
- 节点单位、权重等补充信息
|
||||
|
||||
建议只做一项统一性改造:
|
||||
|
||||
- 顶层补 `description`
|
||||
|
||||
## 6. 需要迁移到 `field_config` 的功能
|
||||
|
||||
construction 领域应重点梳理下面两类功能:
|
||||
|
||||
### 6.1 项目初始化链路
|
||||
|
||||
需要明确:
|
||||
|
||||
- 初始化 section 时,候选项是否改由 `get_field_options(project_type, "construction.section_type_code")` 提供
|
||||
- 初始化 root node 时,是否从同一配置对象中的业务自定义字段读取附属数据
|
||||
|
||||
也就是说,初始化链路应逐步从“直接解释旧 `construction_section_config`”迁移到“先读取字段级配置,再读取其业务附属字段”。
|
||||
|
||||
### 6.2 手动创建 / 编辑链路
|
||||
|
||||
需要明确:
|
||||
|
||||
- 如果某接口允许手动传 `section_type_code`
|
||||
- 是否应改为调用 `validate_field_value(project_type, "construction.section_type_code", value)`
|
||||
|
||||
这样可以保证手动写入与初始化使用同一套候选项来源。
|
||||
|
||||
## 7. 当前迁移示例
|
||||
|
||||
### 示例 1:section 候选项读取
|
||||
|
||||
当前:
|
||||
|
||||
- 通过 construction 私有 helper 直接读取并解释 `construction_section_config`
|
||||
|
||||
目标:
|
||||
|
||||
- 通过 `ConfigService.get_field_options(project_type, "construction.section_type_code")` 获取 section 列表
|
||||
|
||||
### 示例 2:section 合法值校验
|
||||
|
||||
当前:
|
||||
|
||||
- 可能由业务代码自行判断,或者根本没有统一校验入口
|
||||
|
||||
目标:
|
||||
|
||||
- 统一走 `ConfigService.validate_field_value(...)`
|
||||
|
||||
### 示例 3:初始化附属数据读取
|
||||
|
||||
当前:
|
||||
|
||||
- 直接从旧 `construction_section_config` 读取 `project_root_nodes`
|
||||
|
||||
目标:
|
||||
|
||||
- 从 `construction.section_type_code` 的完整配置对象中读取业务自定义字段
|
||||
|
||||
## 8. 梳理清单
|
||||
|
||||
construction 领域后续梳理时,至少要回答下面这些问题:
|
||||
|
||||
1. 哪些字段属于集团侧 `enum_config` 已覆盖或可对应的字段。
|
||||
2. 哪些字段在初始化时使用候选项,哪些字段在手动创建时也要校验。
|
||||
3. 哪些逻辑仍在直接解释旧 JSON,而不是通过统一 helper 读取。
|
||||
4. 哪些逻辑仍在使用硬编码白名单。
|
||||
5. `construction_node_config` 中哪些数据只是节点补充信息,哪些其实已经在承担字段候选项职责。
|
||||
|
||||
如果发现某字段既参与初始化又参与手动创建,就应优先考虑迁到字段级配置。
|
||||
|
||||
## 9. 按本轮要求的改造落点
|
||||
|
||||
construction 领域这轮不是泛泛地“改配置”,而是明确围绕 3 条链路收口:
|
||||
|
||||
- 初始化
|
||||
- create 校验
|
||||
- 下拉列表
|
||||
|
||||
### 9.1 初始化
|
||||
|
||||
当前施工初始化本来就不是 dict 驱动,而是:
|
||||
|
||||
- `construction_section_config`
|
||||
- `ConstructionTypeTree`
|
||||
- `ConstructionTypeMapping`
|
||||
|
||||
因此本轮初始化改造的重点不是“把 dict 搬到 config”,而是:
|
||||
|
||||
- 把 `section_type_code` 的候选项读取统一收口到新的 config 工具
|
||||
- 保留类型树和 root node 等结构化数据,但不再让 section 候选项散落在私有 helper 里
|
||||
|
||||
### 9.2 Create 校验
|
||||
|
||||
当前 `ConstructionService.create()` / `create_for_scdw()` 已经在 service 开头做了较强校验:
|
||||
|
||||
- `section_type_code` 来自 `construction_section_config`
|
||||
- `section_sub_type_code` 来自 section config 和类型树映射
|
||||
- `type_id` 来自施工类型树或 SCDW 专用配置
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- `section_type_code` 统一改成通过 `ConfigService.validate_field_value(project_type, "construction.section_type_code", value)` 校验
|
||||
- `section_sub_type_code`、`type_id` 继续走配置/类型树校验,不做降级
|
||||
|
||||
### 9.3 下拉列表
|
||||
|
||||
当前施工下拉接口已经基本同源:
|
||||
|
||||
- `/construction/section/type/list`
|
||||
- `/construction/section/sub/type/list`
|
||||
- `/construction/task/type/list`
|
||||
|
||||
它们当前都来自 `construction_section_config + 类型树`。
|
||||
|
||||
本轮目标不是重做接口,而是:
|
||||
|
||||
- 让 section 级选项由新的 config 工具统一返回
|
||||
- 保证下拉、初始化、create 校验继续同源
|
||||
|
||||
### 9.4 清理项
|
||||
|
||||
construction 领域这轮优先清理的是“section 候选项读取分散”问题,不是一次性重写全部施工结构。
|
||||
|
||||
完成后应清理:
|
||||
|
||||
- 只负责 section 候选项翻译的旧 helper 直接解析逻辑
|
||||
- 不再适合作为 section 真源的旧硬编码映射
|
||||
|
||||
但下面这些不属于本轮删除目标:
|
||||
|
||||
- `ConstructionTypeTree`
|
||||
- `ConstructionTypeMapping`
|
||||
- 与节点树生成强绑定的结构化配置
|
||||
|
||||
## 10. 测试关注点
|
||||
|
||||
construction 领域 service 层集成测试至少覆盖:
|
||||
|
||||
1. 初始化:项目初始化后生成的 section / root node / 主施工任务是否来自目标 config。
|
||||
2. 下拉:section / sub type / task type 列表是否和初始化使用同源配置。
|
||||
3. Create:合法 `section_type_code`、`section_sub_type_code`、`type_id` 可以创建,非法值明确报错。
|
||||
@@ -0,0 +1,108 @@
|
||||
# Construction Task 领域重构指南
|
||||
|
||||
## 1. 范围
|
||||
|
||||
本文档对应 backend 的 `construction` 主表,也就是施工任务本体。
|
||||
|
||||
当前优先关注:
|
||||
|
||||
- `construction.section_type_code`
|
||||
- `construction.task_type`
|
||||
- `construction.status`
|
||||
|
||||
## 2. 字段盘点
|
||||
|
||||
### 2.1 `section_type_code`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 本地有硬编码 `section_type_id_dict`
|
||||
- 也有 `construction_section_config` 作为配置来源
|
||||
- 模型里是 `String`
|
||||
- 创建 schema 里是 `str`
|
||||
- 同步 schema 对应字段名是 `construction_section`,类型是 `str`
|
||||
|
||||
当前使用:
|
||||
|
||||
- 是施工初始化和施工任务分区的关键字段
|
||||
- construction 服务和 config helper 都直接依赖它
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 本地明确值:`GF`、`SY`、`SC`、`SCDW`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 当前没看到集团侧 `enum_config` 有独立的 Construction 顶层段
|
||||
- 同步 schema 里有对应字段,但仍是普通字符串
|
||||
|
||||
结论:
|
||||
|
||||
- 应迁到 `field_config`
|
||||
- 继续沿用 `construction.section_type_code`
|
||||
|
||||
### 2.2 `task_type`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `String`
|
||||
- 创建 schema 里是 `str`
|
||||
- 本地只有一个层级 enum `ConstructionCategory`,值是 `01`、`02`、`0201`、`03`、`0301`、`04`、`05`
|
||||
|
||||
当前使用:
|
||||
|
||||
- 施工树和类型树逻辑会使用它
|
||||
- 但业务任务实际取值往往来自施工树节点,而不只是 `ConstructionCategory`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 如果按层级 category 看,明确值是:`01`、`02`、`0201`、`03`、`0301`、`04`、`05`
|
||||
- 如果按真实任务类型看,当前无法从单一代码 enum 列出完整集合,因为还受施工树/`construction_node_config` 影响
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 同步 schema 里 `task_type` 仍是 `str`
|
||||
- 当前没有看到集团侧 `enum_config` 对这个字段给出统一候选项表
|
||||
|
||||
结论:
|
||||
|
||||
- 不能简单做成单一代码 enum
|
||||
- 更适合拆成“层级 category”与“真实任务类型”两层定义
|
||||
|
||||
### 2.3 `status`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `SmallInteger`
|
||||
- 同步 schema 里是 `Optional[int]`
|
||||
- 本地有 `progress_status_dict`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- `-1`
|
||||
- `0`
|
||||
- `1`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 当前没看到集团侧 `enum_config` 独立列出 Construction.status
|
||||
- 同步 schema 仍是 `int`
|
||||
|
||||
结论:
|
||||
|
||||
- 保留数值状态字段
|
||||
- 需要统一是走代码常量、dict 映射还是后续字段配置
|
||||
|
||||
## 3. 重点结论
|
||||
|
||||
- `section_type_code` 是典型的字段级配置候选。
|
||||
- `task_type` 当前最复杂,不能直接当成一个静态 enum 处理。
|
||||
- `status` 目前是简单数值状态,但也需要统一正式定义源。
|
||||
|
||||
## 4. 待修改项清单
|
||||
|
||||
1. 将 `section_type_code` 正式迁到 `field_config`。
|
||||
2. 清理 `section_type_code` 的硬编码 dict 与 config helper 双来源。
|
||||
3. 拆分 `task_type` 的“层级 category”与“真实任务类型”语义。
|
||||
4. 明确哪些 `task_type` 值来自施工树,哪些来自配置表。
|
||||
5. 为 `status` 建立统一常量定义,避免散落数值字面量。
|
||||
@@ -0,0 +1,192 @@
|
||||
# Contract 领域重构指南
|
||||
|
||||
## 1. 范围
|
||||
|
||||
本文档只做 Contract 领域的项目侧梳理。
|
||||
|
||||
当前优先关注:
|
||||
|
||||
- `contract.contract_type`
|
||||
- `contract.code`
|
||||
- `contract.currency`
|
||||
|
||||
## 2. 字段盘点
|
||||
|
||||
### 2.1 `contract_type`
|
||||
|
||||
当前实现:
|
||||
|
||||
- `app/modules/contract/enums.py` 定义了 `ContractType`
|
||||
- 取值:`product`、`service`、`project`
|
||||
- `Contract` 模型里是 `String`
|
||||
- `ContractCreate` 里是 `str | None`
|
||||
- 同步 schema `schemas/contract/contract.py` 里也是 `str`
|
||||
|
||||
当前使用:
|
||||
|
||||
- 是合同主表核心分类字段
|
||||
- 合同结算服务里会按 `contract_type == "project"` 分支处理工程类合同
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- `product`
|
||||
- `service`
|
||||
- `project`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.Contract.contract_type` 当前只有 `product`、`service`
|
||||
- 项目侧比集团侧多一个 `project`
|
||||
- 同步 schema 当前没有把它收紧成 enum
|
||||
|
||||
结论:
|
||||
|
||||
- 保留为代码 enum
|
||||
- 先统一“是否正式保留 `project`”
|
||||
|
||||
### 2.2 `code`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是普通 `String`
|
||||
- 没有本地正式 enum
|
||||
- 初始化侧实际依赖 `contract_config` / `contract_code_dict` / `supply_chain_group`
|
||||
|
||||
当前使用:
|
||||
|
||||
- 初始化合同时,`code` 承担业务子类型编码职责,例如 `EPC`、`PC`、`KY`、`JL`
|
||||
- 不是单纯的“自由合同编号”
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前无法从单一代码 enum 列出权威全集
|
||||
- 本地已明确出现的典型值包括:`EPC`、`PC`、`PF`、`CG`、`OSC`、`KY`、`JL`、`E`、`SCYW`、`ZJ`、`TY`、`NB`、`XB`、`ZB`、`GS`、`CN`、`QT`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.Contract.code` 已明确把这类值当成正式候选项,并且按项目类型变化
|
||||
- 同步 schema 里的 `code` 仍是普通 `str`
|
||||
- 项目侧现在没有把这组值沉成统一的字段配置或正式 enum
|
||||
|
||||
结论:
|
||||
|
||||
- 不建议继续当自由字符串处理
|
||||
- 更适合迁到 `field_config`,或至少统一走配置表来源
|
||||
|
||||
### 2.3 `currency`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `String`
|
||||
- 默认值 `CNY`
|
||||
- schema 里是普通字符串
|
||||
|
||||
当前使用:
|
||||
|
||||
- 当前主要是合同基础信息字段
|
||||
- 没看到复杂分支逻辑
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前代码里唯一明确值是默认值 `CNY`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 当前没看到集团侧 `enum_config` 对这个字段给出正式候选项
|
||||
- 同步 schema 里也是 `str`
|
||||
|
||||
结论:
|
||||
|
||||
- 先保持字符串字段
|
||||
- 如果未来真的出现多币种,再决定是否需要正式枚举
|
||||
|
||||
## 3. 重点结论
|
||||
|
||||
- `contract_type` 应保留为代码 enum,但要先明确是否保留 `project`。
|
||||
- `code` 当前实际上承担合同业务子类型职责,不应继续当自由字符串处理。
|
||||
- `currency` 当前没有足够证据说明要做枚举化。
|
||||
|
||||
## 4. 待修改项清单
|
||||
|
||||
1. 统一 `ContractType` 的正式定义源。
|
||||
2. 确认 `contract_type=project` 是否继续作为正式值保留。
|
||||
3. 为 `contract_type` 在 schema 层补 enum 校验。
|
||||
4. 把 `contract.code` 从“自由字符串”改成统一配置来源。
|
||||
5. 评估 `contract.code` 是否直接迁到 `field_config`。
|
||||
6. 区分清楚 `contract.code` 与“合同编号/contract_sn”的职责,避免一个字段同时承担两层语义。
|
||||
|
||||
## 5. 按本轮要求的改造落点
|
||||
|
||||
contract 领域本轮要直接收口 3 条链路:
|
||||
|
||||
- 初始化
|
||||
- create 校验
|
||||
- 下拉列表
|
||||
|
||||
### 5.1 初始化
|
||||
|
||||
当前合同初始化已经走 `contract_config`。
|
||||
|
||||
因此这轮初始化改造的核心不是重做合同初始化,而是:
|
||||
|
||||
- 把 `contract.code` 明确成正式字段级配置来源
|
||||
- 后续让合同初始化、物资初始化、前端下拉都围绕这套配置工作
|
||||
|
||||
`contract_type` 继续保留为代码 enum,不迁到 `field_config`。
|
||||
|
||||
### 5.2 Create 校验
|
||||
|
||||
当前 `ContractService.create()` 的校验主要依赖:
|
||||
|
||||
- `contarct_type_dict`
|
||||
- `contract_code_dict`
|
||||
- service / product 的硬编码白名单
|
||||
|
||||
这和初始化使用的 `contract_config` 不是同一真源。
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- `contract_type` 继续按代码 enum 校验
|
||||
- `contract.code` 改成通过新的 config 工具校验
|
||||
- 避免继续新增 `if contract_type == ... and code in [...]` 这种硬编码白名单
|
||||
|
||||
### 5.3 下拉列表
|
||||
|
||||
当前合同下拉接口:
|
||||
|
||||
- `/contract/type/list`
|
||||
- `/contract/sub-type/list`
|
||||
|
||||
其中:
|
||||
|
||||
- `type/list` 直接读代码字典
|
||||
- `sub-type/list` 先写死过滤列表,再读代码字典
|
||||
|
||||
这和初始化 `contract_config` 不一致。
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- 合同类型列表继续由代码 enum 提供
|
||||
- 合同子类型列表改为动态读取 config
|
||||
- 保证前端看到的 code 列表和初始化、create 校验同源
|
||||
|
||||
### 5.4 清理项
|
||||
|
||||
本轮 contract 领域改造完成后,应清理:
|
||||
|
||||
- 只用于 `contract.code` 下拉和校验的旧硬编码 code 白名单
|
||||
- 只承担旧合同子类型翻译的代码字典入口
|
||||
|
||||
但下面这些不应误删:
|
||||
|
||||
- `ContractType` enum
|
||||
- 其它真正稳定的固定语义 enum
|
||||
|
||||
## 6. 测试关注点
|
||||
|
||||
contract 领域 service 层集成测试至少覆盖:
|
||||
|
||||
1. 初始化:`contract_config` 能正确初始化合同,并继续驱动下游结算/物资链路。
|
||||
2. 下拉:合同子类型下拉是否来自目标 config,且与初始化 code 集一致。
|
||||
3. Create:合法 `contract_type + code` 创建成功,非法 code 明确报错。
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Contract Settlement 领域重构指南
|
||||
|
||||
## 1. 范围
|
||||
|
||||
本文档对应 backend 的 `contract_settlement` 主表。
|
||||
|
||||
当前优先关注:
|
||||
|
||||
- `contract_settlement.contract_type`
|
||||
- `contract_settlement.status`
|
||||
- `contract_settlement.payment_method`
|
||||
|
||||
## 2. 字段盘点
|
||||
|
||||
### 2.1 `contract_type`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `String`
|
||||
- 没有单独 enum 类型绑定到模型
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前主要跟随关联合同的 `contract_type`
|
||||
- 代码现状至少包含:`product`、`service`、`project`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.ContractSettlement.contract_type` 当前只有 `product`、`service`
|
||||
- 项目侧如果继续沿用 `project`,会比集团侧多一个值
|
||||
- 当前仓库里没有 standalone 的同步 schema 来收紧这个字段
|
||||
|
||||
结论:
|
||||
|
||||
- 先与合同主表 `contract_type` 保持一致
|
||||
- 再确认结算领域是否允许 `project`
|
||||
|
||||
### 2.2 `status`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `SmallInteger`
|
||||
- 默认值 `0`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前没有独立 enum 类
|
||||
- 从集团侧和现有语义看,应至少覆盖 `-1`、`0`、`1`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.ContractSettlement.status` 明确给出 `-1`、`0`、`1`
|
||||
- 项目侧模型没有沉成统一常量或 enum
|
||||
|
||||
结论:
|
||||
|
||||
- 应补统一状态定义源
|
||||
|
||||
### 2.3 `payment_method`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 本地 `PaymentMethod` enum 有两个值:`node_payment`、`progress_payment`
|
||||
- 模型里仍是 `String`
|
||||
- `SettlePaymentMethodsSelect` 手动校验合法值
|
||||
|
||||
当前使用:
|
||||
|
||||
- 合同结算服务会按 `payment_method` 分支校验和处理
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- `node_payment`
|
||||
- `progress_payment`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 当前没看到集团侧 `enum_config` 为该字段给出正式候选项
|
||||
- 当前仓库里也没有 standalone 的同步 schema 覆盖该字段
|
||||
|
||||
结论:
|
||||
|
||||
- 保留为本地代码 enum
|
||||
- 需要把“手动 validator”提升为统一 schema / service 约束
|
||||
|
||||
## 3. 重点结论
|
||||
|
||||
- `contract_settlement` 里最稳定的枚举字段是 `payment_method`,应继续保留为代码 enum。
|
||||
- `contract_type` 不能单独拍板,必须跟合同主表一起决定是否保留 `project`。
|
||||
- `status` 当前只是模型整数,没有正式定义源,需要补齐。
|
||||
|
||||
## 4. 待修改项清单
|
||||
|
||||
1. 明确 `contract_settlement.contract_type` 是否允许 `project`。
|
||||
2. 为 `status` 补正式常量或 enum 定义。
|
||||
3. 将 `payment_method` 的合法值校验统一沉到正式 schema/enum,而不是只在个别 schema 手动校验。
|
||||
4. 明确结算主表与合同主表在 `contract_type` 上是否必须同源。
|
||||
|
||||
## 5. 按本轮要求的改造落点
|
||||
|
||||
contract_settlement 领域不属于项目初始化第一批重点,但它直接涉及“写死下拉改动态”这件事。
|
||||
|
||||
### 5.1 初始化
|
||||
|
||||
本领域当前没有独立的项目初始化候选项链路。
|
||||
|
||||
`contract_type` 主要跟随合同主表,`payment_method` 主要出现在结算业务选择和后续分支处理中。
|
||||
|
||||
### 5.2 Create / 业务校验
|
||||
|
||||
当前 `payment_method` 的合法值主要靠:
|
||||
|
||||
- `PaymentMethod` enum
|
||||
- `SettlePaymentMethodsSelect` schema 里的手动 validator
|
||||
|
||||
而 service `payment_methods_select()` 本身没有再做额外校验。
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- 保留 `payment_method` 的固定语义 enum 属性
|
||||
- 但在业务层入口也要能通过统一 config 工具或统一 enum 工具做显式校验
|
||||
- 不把校验只留在 schema 里
|
||||
|
||||
### 5.3 下拉列表
|
||||
|
||||
当前支付方式只有选择接口,没有正式的动态下拉接口。
|
||||
|
||||
因此这轮要补的不是“替换已有动态接口”,而是:
|
||||
|
||||
- 把现有写死支付方式候选项明确收口
|
||||
- 如果前端需要单独下拉接口,应改成动态返回,而不是继续依赖前端或 schema 写死
|
||||
|
||||
同理,`payment_type`、`advance_type` 这类当前靠本地 enum 固定的候选项,也应按同样思路处理。
|
||||
|
||||
### 5.4 清理项
|
||||
|
||||
本轮 contract_settlement 领域完成后,应避免继续保留:
|
||||
|
||||
- 只存在于 schema validator、但没有统一出口的支付方式候选项定义
|
||||
- 前端和后端各自写死一份支付方式列表
|
||||
|
||||
## 6. 测试关注点
|
||||
|
||||
contract_settlement 领域 service 层集成测试至少覆盖:
|
||||
|
||||
1. `payment_method` 合法值更新成功,非法值明确报错。
|
||||
2. 如果补了下拉接口,下拉返回是否与 service 校验同源。
|
||||
3. `contract_type` 跟随合同主表时,不出现与合同主表不一致的脏数据。
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# 项目侧字段分流总表草案
|
||||
|
||||
## 1. 目的
|
||||
|
||||
这份总表只回答一件事:
|
||||
|
||||
- 哪些字段继续保留为代码 enum / 代码常量。
|
||||
- 哪些字段应迁到 `field_config`。
|
||||
- 哪些字段暂时不能直接收口,必须先补领域边界或映射关系。
|
||||
|
||||
## 2. 分流原则
|
||||
|
||||
优先按三类处理:
|
||||
|
||||
- 保留代码 enum:值域固定、语义稳定、业务分支已直接依赖。
|
||||
- 迁到 `field_config`:值域跟 `project_type`、初始化模板或业务配置强相关。
|
||||
- 暂缓收口:当前 backend 与 sync schema 映射不完整,或领域边界本身还没定。
|
||||
|
||||
## 3. 总表
|
||||
|
||||
| 领域 | 字段 | 当前主要来源 | 建议归类 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| project | `project_type` | 代码 enum + 初始化字典 | 保留代码 enum | 核心路由字段 |
|
||||
| project | `project_subtype` | 自由字符串 | 迁到 `field_config` | 建议 key:`project.project_subtype` |
|
||||
| project | `syxs` | 自由字符串 | 迁到 `field_config` | 建议 key:`project.syxs` |
|
||||
| project | `status` | 代码 enum + 服务推导 | 保留代码 enum | 正式集合含 `early`,并清理历史 `NULL` |
|
||||
| project | `progress_type` | 代码 enum + DB enum | 保留代码 enum | 已与 sync schema 对齐 |
|
||||
| project | `key_constraints` | 代码 enum 分叉 + DB enum | 保留代码 enum | 项目侧保留 `OTHER`,备注集团侧使用 `QT` |
|
||||
| contract | `contract_type` | 代码 enum | 保留代码 enum | 先确认是否保留 `project` |
|
||||
| contract | `code` | 初始化字典 / config / 自由字符串 | 迁到 `field_config` | 建议 key:`contract.code` |
|
||||
| contract | `currency` | 自由字符串 | 暂不处理 | 当前仅见 `CNY` |
|
||||
| construction_task | `section_type_code` | 硬编码 dict + config | 迁到 `field_config` | 建议 key:`construction.section_type_code` |
|
||||
| construction_task | `task_type` | 类型树 + 字符串字段 | 暂缓收口 | 先拆 category 与真实任务类型 |
|
||||
| construction_task | `status` | 数值字段 + dict | 保留代码常量 | 先统一常量定义源 |
|
||||
| material | `material_type` | 本地列表 + 自由字符串 | 迁到 `field_config` | 建议 key:`material.material_type` |
|
||||
| material | `status` | 数值字段 | 暂缓收口 | 先和 `supply_status` 划边界 |
|
||||
| material | `supply_status` | 代码 enum | 保留代码常量 | 先确认是否为正式推送字段 |
|
||||
| material | `approval_party` | 本地候选项 | 暂不处理 | 当前无集团侧对应 |
|
||||
| contract_settlement | `contract_type` | 跟随合同主表 | 暂缓收口 | 先确认是否允许 `project` |
|
||||
| contract_settlement | `status` | 数值字段 | 保留代码常量 | 需补统一定义源 |
|
||||
| contract_settlement | `payment_method` | 代码 enum | 保留代码 enum | 不建议迁 `field_config` |
|
||||
| personnel | `personnel_type` | 本地代码常量 | 保留代码 enum | 本地真实领域是人员资质 |
|
||||
| personnel | `task_type` | 本地候选项常量 | 保留代码常量 | 先补 schema 白名单 |
|
||||
| personnel | `is_duty` | 数值常量 | 保留代码常量 | 与集团侧 `status` 不是同一字段 |
|
||||
| preparation | `code` | 初始化字典 + 自由字符串 | 迁到 `field_config` | 建议 key:`preparation.code` |
|
||||
| preparation | `approval_status` | 通用审批体系 | 接入通用审批定义 | 不单独建 `field_config` |
|
||||
| preparation | `is_exist` | 布尔字段 | 暂不处理 | 不属于候选项字段 |
|
||||
| production | `code` | 初始化字典 + 自由字符串 | 迁到 `field_config` | 建议 key:`production.code` |
|
||||
| production | `approval_status` | 通用审批体系 | 接入通用审批定义 | 不单独建 `field_config` |
|
||||
| production | `is_exist` | 布尔字段 | 暂不处理 | 不属于候选项字段 |
|
||||
|
||||
## 4. 第一批建议落地项
|
||||
|
||||
优先进入 `field_config` 的字段:
|
||||
|
||||
- `project.project_subtype`
|
||||
- `project.syxs`
|
||||
- `contract.code`
|
||||
- `construction.section_type_code`
|
||||
- `material.material_type`
|
||||
- `preparation.code`
|
||||
- `production.code`
|
||||
|
||||
优先保留并收紧 schema 的代码 enum 字段:
|
||||
|
||||
- `project.project_type`
|
||||
- `project.status`
|
||||
- `project.progress_type`
|
||||
- `project.key_constraints`
|
||||
- `contract.contract_type`
|
||||
- `contract_settlement.payment_method`
|
||||
|
||||
优先先做边界确认、暂不直接配置化的字段:
|
||||
|
||||
- `construction.task_type`
|
||||
- `material.status`
|
||||
- `contract_settlement.contract_type`
|
||||
|
||||
## 5. 使用方式
|
||||
|
||||
这份表可以直接作为后续代码改造顺序的输入:
|
||||
|
||||
1. 先处理“迁到 `field_config`”的一组字段。
|
||||
2. 再处理“保留代码 enum,但 schema 还没收口”的一组字段。
|
||||
3. 最后处理“暂缓收口”的结构性问题字段。
|
||||
|
||||
## 6. 按本轮目标拆出的第一批字段
|
||||
|
||||
这次不是单纯做字段分类,而是要直接服务于 3 条链路:
|
||||
|
||||
- 项目初始化
|
||||
- create 业务校验
|
||||
- 前端下拉列表
|
||||
|
||||
因此第一批字段不能只看“适不适合进 `field_config`”,还要看它能不能尽快把这 3 条链路收口到同一真源。
|
||||
|
||||
### 6.1 第一批优先收口字段
|
||||
|
||||
建议第一批优先处理:
|
||||
|
||||
- `contract.code`
|
||||
- `material.material_type`
|
||||
- `construction.section_type_code`
|
||||
- `preparation.code`
|
||||
- `production.code`
|
||||
- `power.code`
|
||||
|
||||
原因:
|
||||
|
||||
- 都属于 code 类字段
|
||||
- 都直接影响初始化、创建或下拉其中至少两条链路
|
||||
- 当前都存在 config / dict / 代码常量混用问题,或者已具备明显收口价值
|
||||
|
||||
### 6.2 每个字段要同时回答的 3 个问题
|
||||
|
||||
对上面每个字段,后续文档和代码改造都应同时回答:
|
||||
|
||||
1. 初始化时从哪里拿候选项。
|
||||
2. create service 开头用哪个 config key 做校验。
|
||||
3. 前端下拉接口用哪个 config key 返回选项。
|
||||
|
||||
如果这 3 个问题的答案不是同一个 key,就说明还没有真正收口。
|
||||
|
||||
### 6.3 本轮不优先收口但要避免误迁的字段
|
||||
|
||||
下面这些字段虽然也和 enum 有关,但本轮不应抢先塞进字段级配置:
|
||||
|
||||
- `project.status`
|
||||
- `project.progress_type`
|
||||
- `project.key_constraints`
|
||||
- `contract.contract_type`
|
||||
- `contract_settlement.payment_method`
|
||||
|
||||
原因是:
|
||||
|
||||
- 这些字段语义更固定
|
||||
- 业务分支已经直接依赖
|
||||
- 本轮重点是先把“按项目类型变化的 code 类字段”收口,而不是把固定状态类枚举全部配置化
|
||||
|
||||
### 6.4 对物资字段的特别说明
|
||||
|
||||
`material.material_type` 虽然在表里只是一行,但它实际上牵扯 4 套来源:
|
||||
|
||||
- 初始化合同候选项:`contract_config`
|
||||
- 物资初始化补充:`material_contract_*`
|
||||
- 前端下拉:`material_types`
|
||||
- create 内部默认展示/单位:`contract_product_type_list`
|
||||
|
||||
所以 `material.material_type` 不是简单迁一个 key,而是本轮最需要先统一真源的一项。
|
||||
|
||||
### 6.5 本轮完成后的字段判定标准
|
||||
|
||||
一个字段只有同时满足下面 3 条,才算本轮真正完成收口:
|
||||
|
||||
- 初始化使用它对应的 field config
|
||||
- create 校验使用它对应的 field config
|
||||
- 下拉列表返回也使用它对应的 field config
|
||||
@@ -0,0 +1,757 @@
|
||||
# init_project_data 现状分析
|
||||
|
||||
## 1. 范围
|
||||
|
||||
本文档只分析 backend 里的 `ProjectService._init_project_data()` 当前到底做了什么。
|
||||
|
||||
重点回答 4 个问题:
|
||||
|
||||
- 这个函数当前创建了哪些业务数据。
|
||||
- 它直接读取了哪些 `config` 和 `dict`。
|
||||
- 哪些 `dict` 是真的在负责初始化,哪些只是历史残留。
|
||||
- 如果把这里 `dict` 负责的部分迁到 `config`,复杂度到底高不高。
|
||||
|
||||
## 2. 触发时机
|
||||
|
||||
`_init_project_data()` 在项目审批通过后执行。
|
||||
|
||||
调用链是:
|
||||
|
||||
- `ProjectService.audit_project()`
|
||||
- `ProjectService._audit_approval()`
|
||||
- `ProjectService._init_project_data()`
|
||||
|
||||
也就是说,这不是“创建项目主表时立即执行”的逻辑,而是“审批通过后批量灌初始化数据”的逻辑。
|
||||
|
||||
## 3. 函数当前做的 6 件事
|
||||
|
||||
按执行顺序,当前函数会做下面 6 件事:
|
||||
|
||||
1. 读取当前 `project_type` 整个 category 下的全部 config。
|
||||
2. 读取 6 组 dict 数据。
|
||||
3. 初始化 `preparation` 里程碑节点。
|
||||
4. 初始化 `production` 投产节点。
|
||||
5. 初始化 `contract` 合同,并在合同初始化内部继续初始化 `contract_settlement` 和 `material`。
|
||||
6. 初始化 `power` 力能配置、`construction` 施工任务、`archive` 电子档案。
|
||||
|
||||
注意:
|
||||
|
||||
- 这个函数表面上不长,但它本质上是一个“项目初始化编排器”。
|
||||
- 真正复杂的地方不只在它自己,而在它调用出去的各个 `init_from_*`。
|
||||
|
||||
## 4. 直接读取了什么
|
||||
|
||||
### 4.1 直接读取的 config
|
||||
|
||||
函数一开始会调用:
|
||||
|
||||
- `config_service.get_category_config(category=project.project_type)`
|
||||
|
||||
这一步不是只读某一个 key,而是把当前 `project_type` 这个 category 下的全部 config 都读出来,再做:
|
||||
|
||||
- `configs = {config_key: decoded_json}`
|
||||
|
||||
但在 `_init_project_data()` 自己内部,真正直接用到的 config 只有 1 个:
|
||||
|
||||
- `contract_config`
|
||||
|
||||
也就是说:
|
||||
|
||||
- 直接读取:整类 config
|
||||
- 直接消费:只有 `contract_config`
|
||||
- 其余 config 在这个函数里只是被一并装进了 `configs`,并没有直接使用
|
||||
|
||||
### 4.2 直接读取的 dict
|
||||
|
||||
函数会一次性读取 6 个 dict key:
|
||||
|
||||
- `preparation_{project_type}`
|
||||
- `production_{project_type}`
|
||||
- `construction_contract_{project_type}`
|
||||
- `service_contract_{project_type}`
|
||||
- `material_contract_{project_type}`
|
||||
- `power_{project_type}`
|
||||
|
||||
但这 6 个 key 不是都真正被当前函数消费了。
|
||||
|
||||
## 5. 哪些数据是怎么创建出来的
|
||||
|
||||
### 5.1 Preparation
|
||||
|
||||
创建来源:
|
||||
|
||||
- 直接使用 dict:`preparation_{project_type}`
|
||||
|
||||
创建方式:
|
||||
|
||||
- 读取 dict 项的 `code`、`name`
|
||||
- 先删项目下旧的 `Preparation`
|
||||
- 再批量插入新的 `Preparation`
|
||||
|
||||
涉及配置数量:
|
||||
|
||||
- `config`: 0
|
||||
- `dict`: 1 组
|
||||
|
||||
当前种子数据条目数:
|
||||
|
||||
- `preparation_photovoltaic`: 12 条
|
||||
- `preparation_onshore_wind`: 13 条
|
||||
- `preparation_offshore_wind`: 14 条
|
||||
|
||||
结论:
|
||||
|
||||
- 这是最典型的“dict 直接承担初始化模板”场景。
|
||||
- 结构非常简单,迁到 `config` 不复杂。
|
||||
|
||||
### 5.2 Production
|
||||
|
||||
创建来源:
|
||||
|
||||
- 直接使用 dict:`production_{project_type}`
|
||||
|
||||
创建方式:
|
||||
|
||||
- 读取 dict 项的 `code`、`name`
|
||||
- 先删项目下旧的 `Production`
|
||||
- 再批量插入新的 `Production`
|
||||
|
||||
涉及配置数量:
|
||||
|
||||
- `config`: 0
|
||||
- `dict`: 1 组
|
||||
|
||||
当前种子数据条目数:
|
||||
|
||||
- `production_photovoltaic`: 9 条
|
||||
- `production_onshore_wind`: 9 条
|
||||
- `production_offshore_wind`: 9 条
|
||||
|
||||
结论:
|
||||
|
||||
- 和 `preparation` 一样,是平铺型 code/name 列表。
|
||||
- 迁到 `config` 也不复杂。
|
||||
|
||||
### 5.3 Contract
|
||||
|
||||
创建来源:
|
||||
|
||||
- 直接使用 config:`contract_config`
|
||||
|
||||
创建方式:
|
||||
|
||||
- `_init_project_data()` 从 `configs["contract_config"]` 取出列表
|
||||
- `ContractService.init_from_project()` 根据每项的 `code`、`name`、`contract_type` 创建合同
|
||||
- 先删项目下旧合同,再批量插入新合同
|
||||
|
||||
后续联动:
|
||||
|
||||
- 初始化 `contract_settlement`
|
||||
- 初始化 `material`
|
||||
|
||||
涉及配置数量:
|
||||
|
||||
- `config`: 1 个 key
|
||||
- `dict`: 0 组直接参与合同创建
|
||||
|
||||
当前 `contract_config` 条目数:
|
||||
|
||||
- `photovoltaic`: 21 条
|
||||
- `onshore_wind`: 20 条
|
||||
- `offshore_wind`: 20 条
|
||||
|
||||
结论:
|
||||
|
||||
- 合同初始化本体已经是 config 驱动,不是 dict 驱动。
|
||||
- 所以这里真正要迁移的不是“合同本身”,而是合同初始化后还依赖的那些配套 dict。
|
||||
|
||||
### 5.4 Material
|
||||
|
||||
创建来源:
|
||||
|
||||
- 不是 `_init_project_data()` 直接创建。
|
||||
- 它是 `contract.init_from_project()` 内部继续调用 `material.init_from_contract()` 创建。
|
||||
|
||||
当前来源拆分:
|
||||
|
||||
- 合同列表来源:`contract_config`
|
||||
- 物资单位来源:`material_contract_{project_type}` dict
|
||||
|
||||
也就是说,当前 `material` 初始化其实是两段拼起来的:
|
||||
|
||||
- 先由 `contract_config` 决定会创建哪些货物类合同
|
||||
- 再由 `material_contract_*` dict 给这些物资补 `unit`
|
||||
|
||||
涉及配置数量:
|
||||
|
||||
- `config`: 1 个上游 key `contract_config`
|
||||
- `dict`: 1 组 `material_contract_{project_type}`
|
||||
|
||||
当前种子数据条目数:
|
||||
|
||||
- `material_contract_photovoltaic`: 14 条
|
||||
- `material_contract_onshore_wind`: 12 条
|
||||
- `material_contract_offshore_wind`: 7 条
|
||||
|
||||
这里有一个关键现状:
|
||||
|
||||
- `material` 实际创建多少条,取决于 `contract_config` 里有多少 `product` 合同。
|
||||
- `material_contract_*` dict 只负责补充单位和名称,不负责决定是否创建。
|
||||
- 因此这两套来源当前不是同一个真源。
|
||||
|
||||
已看到的分叉:
|
||||
|
||||
- 光伏 `contract_config` 里的货物类候选项和 `material_contract_photovoltaic` 并不完全一致。
|
||||
- 陆风也存在类似分叉。
|
||||
- 海风更明显:当前能看到 `contract_config` 已有较完整货物类合同,但 `dict` 侧条目明显更少。
|
||||
|
||||
结论:
|
||||
|
||||
- `material` 不是简单的“把一个 dict 搬到 config”。
|
||||
- 真正要做的是先合并“合同货物候选项”和“物资单位/展示信息”两套来源。
|
||||
- 这块复杂度中等,不是最高,但已经不是平铺搬运。
|
||||
|
||||
### 5.5 Power
|
||||
|
||||
创建来源:
|
||||
|
||||
- 直接使用 dict:`power_{project_type}`
|
||||
|
||||
创建方式:
|
||||
|
||||
- 读取 dict 项的 `code`、`name`
|
||||
- 统一绑定到 `contract_data.get("EPC")`
|
||||
- 先删项目下旧 `Power`
|
||||
- 再批量插入新 `Power`
|
||||
|
||||
涉及配置数量:
|
||||
|
||||
- `config`: 0
|
||||
- `dict`: 1 组
|
||||
|
||||
当前种子数据条目数:
|
||||
|
||||
- `power_photovoltaic`: 1 条
|
||||
- `power_onshore_wind`: 2 条
|
||||
- `power_offshore_wind`: 2 条
|
||||
|
||||
结论:
|
||||
|
||||
- 也是平铺型初始化模板,迁到 `config` 不复杂。
|
||||
- 但它和合同存在一条强耦合:默认绑定 `EPC` 合同。
|
||||
|
||||
### 5.6 Construction
|
||||
|
||||
创建来源:
|
||||
|
||||
- `_init_project_data()` 自己没有直接给施工传 dict 或 config 数据
|
||||
- 只调用:`create_constructions_for_project(tenant_id, project_id)`
|
||||
|
||||
但施工初始化内部会继续读取:
|
||||
|
||||
- `construction_section_config`
|
||||
|
||||
并依赖:
|
||||
|
||||
- `BuildMethod`
|
||||
- `ConstructionTypeTree`
|
||||
- `ConstructionTypeMapping`
|
||||
|
||||
实际创建内容:
|
||||
|
||||
- 按 `construction_section_config.sections[*].project_root_nodes` 创建项目根单位工程
|
||||
- 基于这些根节点再创建主施工任务
|
||||
- 对质量验收类施工继续创建施工树
|
||||
- 对主施工继续自动补次级施工
|
||||
|
||||
涉及配置数量:
|
||||
|
||||
- `config`: 至少 1 个 key `construction_section_config`
|
||||
- `dict`: 0
|
||||
- 另外强依赖数据库里的施工类型树,不是简单 JSON 配置
|
||||
|
||||
结论:
|
||||
|
||||
- 施工初始化本来就不是 dict 驱动,已经是 config + 类型树驱动。
|
||||
- 这里真正复杂的不是“dict 要不要迁 config”,而是施工本身就已经是结构化初始化。
|
||||
|
||||
### 5.7 Archive
|
||||
|
||||
创建来源:
|
||||
|
||||
- 不依赖 dict
|
||||
- 也不依赖 `_init_project_data()` 读出来的 config
|
||||
|
||||
当前方式:
|
||||
|
||||
- 按 `template/{project_type}_archive.xlsx` 模板文件初始化电子档案树
|
||||
|
||||
涉及配置数量:
|
||||
|
||||
- `config`: 0(对 `_init_project_data` 而言)
|
||||
- `dict`: 0
|
||||
- 外部模板文件: 1 份 Excel
|
||||
|
||||
结论:
|
||||
|
||||
- 电子档案初始化不属于这次 dict/config 收敛的核心问题。
|
||||
|
||||
## 6. 这 6 组 dict 里,哪些真的在负责事情
|
||||
|
||||
### 6.1 直接被 `_init_project_data()` 消费的 dict
|
||||
|
||||
- `preparation_{project_type}`
|
||||
- `production_{project_type}`
|
||||
- `power_{project_type}`
|
||||
|
||||
这 3 组 dict 是真的在当前函数里直接承担初始化模板职责。
|
||||
|
||||
### 6.2 被 `_init_project_data()` 取了,但当前函数里没有直接消费的 dict
|
||||
|
||||
- `construction_contract_{project_type}`
|
||||
- `service_contract_{project_type}`
|
||||
- `material_contract_{project_type}`
|
||||
|
||||
其中:
|
||||
|
||||
- `construction_contract_*`:当前只看到被 `_init_project_data()` 取出,但没有实际使用。
|
||||
- `service_contract_*`:当前也只看到被 `_init_project_data()` 取出,但没有实际使用。
|
||||
- `material_contract_*`:当前函数本身没用,但会在 `MaterialService.init_from_contract()` 里被重新读取一次,用于补单位。
|
||||
|
||||
这说明当前 `_init_project_data()` 至少存在两类历史包袱:
|
||||
|
||||
- 取了但没用的 dict key
|
||||
- 同一套语义在上游合同 config 和下游 material dict 之间分叉
|
||||
|
||||
## 7. 把 dict 迁到 config,会不会太复杂
|
||||
|
||||
### 7.1 不复杂的部分
|
||||
|
||||
下面 3 组迁移到 config,复杂度不高:
|
||||
|
||||
- `preparation_{project_type}`
|
||||
- `production_{project_type}`
|
||||
- `power_{project_type}`
|
||||
|
||||
原因很简单:
|
||||
|
||||
- 它们当前就是平铺的 `code/name` 列表
|
||||
- 服务接口已经接受 `list[dict]`
|
||||
- 初始化逻辑只做“删旧 + 批量重建”
|
||||
- 不依赖复杂树结构,也不依赖额外业务联动
|
||||
|
||||
如果迁移:
|
||||
|
||||
- `preparation`、`production` 很适合直接改成 `field_config`
|
||||
- `power` 更像一个小型初始化模板,也可以直接放到 config,但不一定要走 `field_config` 这个名字
|
||||
|
||||
### 7.2 中等复杂的部分
|
||||
|
||||
`material_contract_{project_type}` 迁移到 `config`,复杂度中等。
|
||||
|
||||
原因不是数据量大,而是来源分叉:
|
||||
|
||||
- 哪些货物类记录会被创建,取决于 `contract_config`
|
||||
- 每种物资的显示名、单位,又取决于 `material_contract_*` dict
|
||||
|
||||
所以这块不是“dict 搬家”,而是“先统一真源”。
|
||||
|
||||
更稳妥的做法是:
|
||||
|
||||
- 先把 `contract_config` 和 `material_contract_*` 合并成一套正式结构
|
||||
- 再决定是否把单位、显示名、parts 一并并到 `material.field_config` 或其它 config key
|
||||
|
||||
### 7.3 高复杂的部分其实不在 dict
|
||||
|
||||
真正复杂的初始化不是 dict,而是施工:
|
||||
|
||||
- 它已经依赖 `construction_section_config`
|
||||
- 又依赖 `ConstructionTypeTree`
|
||||
- 又依赖 `ConstructionTypeMapping`
|
||||
- 还会继续生成施工树和次级施工
|
||||
|
||||
所以如果目标是“降低 `init_project_data` 初始化复杂度”,优先清理 dict 只能解决一部分表层问题,不能解决施工初始化本身的结构复杂度。
|
||||
|
||||
## 8. 额外现状问题
|
||||
|
||||
### 8.1 `_init_project_data()` 读了整类 config,但只直接用了 `contract_config`
|
||||
|
||||
这会导致:
|
||||
|
||||
- 函数看起来像“config 驱动”
|
||||
- 实际上只有合同初始化是直接 config 驱动
|
||||
|
||||
### 8.2 有 3 个 dict key 在当前函数里不是实际输入
|
||||
|
||||
- `construction_contract_*`
|
||||
- `service_contract_*`
|
||||
- `material_contract_*`
|
||||
|
||||
其中前 2 个当前更像历史残留。
|
||||
|
||||
### 8.3 当前只明显覆盖了 3 类项目类型的初始化种子
|
||||
|
||||
从现有 `dict.csv` / `config.csv` 看,当前明确有初始化种子的主要是:
|
||||
|
||||
- `photovoltaic`
|
||||
- `onshore_wind`
|
||||
- `offshore_wind`
|
||||
|
||||
而 `project_type` 枚举里其实还有:
|
||||
|
||||
- `pumped_storage`
|
||||
- `hydropower`
|
||||
- `thermal_power`
|
||||
- `gas_turbine`
|
||||
|
||||
也就是说,`project_type` 枚举范围和初始化种子覆盖范围并不一致。
|
||||
|
||||
### 8.4 海风配置目前看起来不完整
|
||||
|
||||
当前能直接看到:
|
||||
|
||||
- `offshore_wind` 有 `contract_config`
|
||||
- 也有 `preparation_*`、`production_*`、`power_*`、`material_contract_*` dict
|
||||
|
||||
但当前 `config.csv` 里没看到海风的:
|
||||
|
||||
- `construction_section_config`
|
||||
- `construction_node_config`
|
||||
- `material_types`
|
||||
- `supply_chain_group`
|
||||
|
||||
这意味着海风初始化链路当前很可能并不完整,至少配置层不齐。
|
||||
|
||||
## 9. 结论
|
||||
|
||||
可以把 `_init_project_data()` 当前的配置来源,拆成 4 类理解:
|
||||
|
||||
1. 纯 dict 初始化:`preparation`、`production`、`power`
|
||||
2. 纯 config 初始化:`contract`
|
||||
3. config + dict 混合初始化:`material`
|
||||
4. config + 类型树 + 模板文件初始化:`construction`、`archive`
|
||||
|
||||
所以“把这里 dict 负责的东西迁到 config 会不会太复杂”的答案不是一个统一结论:
|
||||
|
||||
- `preparation`、`production`、`power`:不复杂,可以迁。
|
||||
- `material_contract`:中等复杂,先统一真源再迁。
|
||||
- `construction`:复杂点不在 dict,本来就不是 dict 驱动。
|
||||
|
||||
## 10. 建议落地顺序
|
||||
|
||||
1. 先把 `_init_project_data()` 里“取了但没直接用”的 `construction_contract_*`、`service_contract_*` 从函数里清出来,确认是否还能删掉。
|
||||
2. 再把 `preparation_{project_type}`、`production_{project_type}`、`power_{project_type}` 迁到 config。
|
||||
3. 再处理 `material_contract_*`,但前提是先统一 `contract_config` 和 material 初始化所需的单位/展示信息来源。
|
||||
4. 施工初始化不要和这批 dict 迁移混做,单独处理。
|
||||
|
||||
## 10.1 按本轮改造目标,初始化链路具体要改什么
|
||||
|
||||
如果把这次目标收敛成“初始化统一走配置工具”,那么 `_init_project_data()` 不应该再只是分析对象,而是明确要往下面的形态改。
|
||||
|
||||
### A. 保留并整理的 config
|
||||
|
||||
初始化链路应继续保留并整理这几类 config:
|
||||
|
||||
- `contract_config`:合同初始化候选项
|
||||
- `construction_section_config`:施工区域 / 单位工程 / 任务入口配置
|
||||
- `material_types`:物资类型、展示名、单位、parts 等字段配置
|
||||
- 后续新增的字段级配置 key,例如:
|
||||
- `preparation.code`
|
||||
- `production.code`
|
||||
- `power.code`
|
||||
|
||||
这里的关键不是 config 数量多少,而是把“初始化真的要用的候选项”收口到 config 工具统一读取。
|
||||
|
||||
### B. 应从 dict 迁出的初始化候选项
|
||||
|
||||
下面这几类当前属于平铺型初始化模板,适合迁出 dict:
|
||||
|
||||
- `preparation_{project_type}`
|
||||
- `production_{project_type}`
|
||||
- `power_{project_type}`
|
||||
|
||||
迁移后的目标形态是:
|
||||
|
||||
- `_init_project_data()` 不再直接读这些 dict key
|
||||
- 改由 `ConfigService.get_field_options()` 或等价工具读取字段候选项
|
||||
- service 仍只负责“删旧 + 批量重建”,不再关心候选项来自 dict 还是 config
|
||||
|
||||
### C. 应去掉的不适当 dict 用法
|
||||
|
||||
本轮最典型的不适当 dict 用法是:
|
||||
|
||||
- `material_contract_{project_type}` 用 `remark` 存物资单位
|
||||
|
||||
这类做法的问题不是“它现在不能工作”,而是:
|
||||
|
||||
- 含义不显式
|
||||
- 初始化、创建、下拉三条链路都没法自然共用
|
||||
- 后续导出 CSV / 校验时也不容易发现结构错误
|
||||
|
||||
迁移方向应是:
|
||||
|
||||
- 把 `unit`、`label`、`parts` 等字段并回正式 config 结构
|
||||
- 不再让单位这类业务字段挂在 dict `remark` 上
|
||||
|
||||
### D. `_init_project_data()` 迁移后的读取原则
|
||||
|
||||
迁移后,`_init_project_data()` 本身应只做 2 件事:
|
||||
|
||||
- 找到当前项目类型下所需的候选项配置
|
||||
- 把候选项列表传给各领域初始化 service
|
||||
|
||||
它不应该继续承担:
|
||||
|
||||
- 现场拼 JSON 结构
|
||||
- 自己手工区分 dict / config / remark
|
||||
- 同时取一堆历史 key 再由下游各自二次分叉读取
|
||||
|
||||
### E. 初始化改造完成后的判定标准
|
||||
|
||||
初始化侧改造完成后,至少应满足:
|
||||
|
||||
- `preparation`、`production`、`power` 初始化不再依赖 dict
|
||||
- `material` 初始化不再依赖 dict `remark` 补单位
|
||||
- 初始化候选项与前端下拉、create 校验使用同一套 config 真源
|
||||
- `_init_project_data()` 中不再保留只读取不用的 dict key
|
||||
|
||||
## 11. 从接口创建视角反看这些字段是不是已经“主要 config 化”
|
||||
|
||||
只看 `_init_project_data()`,容易得出“系统已经比较 config 驱动”的印象。
|
||||
|
||||
但如果再抽样看接口创建路径,会发现当前真实情况是:
|
||||
|
||||
- 初始化层里,确实有一部分已经走 `config`
|
||||
- 但创建接口里,很多 enum-like 字段仍然主要靠代码常量或 service 层校验
|
||||
- schema 层大多还是普通 `str`,并没有把这些候选值正式收口到统一配置来源
|
||||
|
||||
### 11.1 Contract 创建
|
||||
|
||||
接口入参:
|
||||
|
||||
- `ContractCreate` 里的 `contract_type`、`code` 都只是普通字符串
|
||||
|
||||
创建时字段来源:
|
||||
|
||||
- `contract_type`、`code` 直接来自接口传参
|
||||
- 合同名称不是前端传,而是 service 根据 `contract_code_dict` 动态拼出来
|
||||
|
||||
当前校验方式:
|
||||
|
||||
- schema 层:只有长度约束,没有枚举白名单
|
||||
- service 层:
|
||||
- `code` 必须在 `contract_code_dict`
|
||||
- `contract_type` 必须在 `contarct_type_dict`
|
||||
- `service` 合同再限制为 `JL`、`KY`、`E`、`SCYW`
|
||||
- `product` 合同再限制为一组硬编码产品 code
|
||||
|
||||
结论:
|
||||
|
||||
- 合同初始化是 `contract_config` 驱动
|
||||
- 但合同接口创建仍然主要依赖代码字典和硬编码白名单
|
||||
- 这说明“初始化层已 config 化”并不等于“合同域已经完成 config 收口”
|
||||
|
||||
### 11.2 Material 创建
|
||||
|
||||
接口入参:
|
||||
|
||||
- `MaterialCreate.material_type`、`approval_party` 也都是普通字符串
|
||||
|
||||
创建时字段来源:
|
||||
|
||||
- `material_type` 直接来自接口传参
|
||||
- `show_name`、`unit` 不是前端传,而是 `MaterialService.get_material_info_by_code()` 从 `contract_product_type_list` 里反查
|
||||
- 创建物资时还会同步创建一个 `product` 合同,合同 code 直接复用 `material_type`
|
||||
|
||||
当前校验方式:
|
||||
|
||||
- schema 层:
|
||||
- `material_type` 只是示例值来自 `contract_product_type_list`
|
||||
- `approval_party` 只有示例,没有正式白名单
|
||||
- service 层:
|
||||
- 强校验的是 `contract_quantity > 0`、项目存在、tenant 存在
|
||||
- `material_type` 本身没有显式抛错白名单
|
||||
- 如果 code 不在 `contract_product_type_list`,`get_material_info_by_code()` 会退回默认值 `支架/组`
|
||||
- 随后又会去调用合同创建,而合同创建对 `code` 有一层更严格的白名单校验
|
||||
|
||||
结论:
|
||||
|
||||
- material 创建不是 config 驱动,而是代码常量驱动
|
||||
- 而且 `material_type` 的直接校验位置并不清晰,存在“本服务默认值兜底,合同服务再拦截”的现状
|
||||
- 这正是还需要整理 config/真源的证据,不适合直接认为已经收口完成
|
||||
|
||||
### 11.3 Construction 创建
|
||||
|
||||
接口入参:
|
||||
|
||||
- `ConstructionCreate.section_type_code`、`section_sub_type_code`、`type_id` 都是普通字符串
|
||||
|
||||
创建时字段来源:
|
||||
|
||||
- `section_type_code`、`section_sub_type_code`、`type_id` 直接来自接口传参
|
||||
- `section_type_name`、`section_sub_type_name`、`task_code`、`task_type` 则由 service 根据配置和类型树反查生成
|
||||
|
||||
当前校验方式:
|
||||
|
||||
- schema 层:只有字符串长度和数值范围,没有枚举白名单
|
||||
- service 层:
|
||||
- `section_type_code` 必须存在于 `construction_section_config`
|
||||
- `section_sub_type_code` 必须能映射到统一类型节点
|
||||
- `type_id` 必须能映射到施工类型树节点
|
||||
- `SCDW` 分支还会单独校验专用任务编码列表
|
||||
|
||||
结论:
|
||||
|
||||
- 施工创建是这 3 个抽样域里最接近“配置/类型树驱动”的
|
||||
- 但即便如此,接口 schema 本身仍未把候选值正式声明出来,真正校验仍落在 service 里
|
||||
|
||||
### 11.4 总判断
|
||||
|
||||
如果只问“初始化层面能不能看出 enum_config 类字段怎么来的”,答案是:
|
||||
|
||||
- 能看出一部分。
|
||||
- `preparation`、`production`、`power` 明显还来自 dict。
|
||||
- `contract` 初始化候选项明显来自 `contract_config`。
|
||||
- `material` 初始化候选项来自 `contract_config`,单位补充来自 `material_contract_*` dict。
|
||||
- `construction` 初始化候选项来自 config + 类型树。
|
||||
|
||||
但如果进一步问“那是不是已经主要用 config 写了,所以没必要再整理 config”,答案是否定的。
|
||||
|
||||
原因是当前系统仍然存在 3 个没有收口的问题:
|
||||
|
||||
- 初始化来源和接口创建来源不是同一套真源。
|
||||
- schema 层没有把这些字段正式建模成受控候选值。
|
||||
- service 层仍保留大量代码字典、硬编码白名单和默认兜底。
|
||||
|
||||
所以现在做 config 整理仍然有必要,而且必要性不在“把所有东西都搬到 config”,而在:
|
||||
|
||||
- 明确每个字段的唯一真源
|
||||
- 消除 dict/config/代码常量三头并存
|
||||
- 让初始化和接口创建共用同一套候选值与校验逻辑
|
||||
|
||||
## 12. 前端下拉选项当前是通过什么路径返回的
|
||||
|
||||
除了创建接口本身,前端通常会先调一些 list 接口拿可选项。
|
||||
|
||||
这部分如果和初始化来源不一致,就会出现:
|
||||
|
||||
- 初始化能创建出来的值,不一定和前端可选项完全一致
|
||||
- 前端下拉看到的值,也不一定就是创建 service 实际校验使用的那套真源
|
||||
|
||||
下面按合同、物资、施工 3 类抽样。
|
||||
|
||||
### 12.1 Contract 的前端下拉路径
|
||||
|
||||
当前相关接口主要有:
|
||||
|
||||
- `/contract/type/list`
|
||||
- `/contract/sub-type/list`
|
||||
|
||||
返回路径:
|
||||
|
||||
- `router -> ContractService.get_contract_type_list()`
|
||||
- `router -> ContractService.get_sub_type_list(contract_type)`
|
||||
|
||||
数据来源:
|
||||
|
||||
- `get_contract_type_list()` 直接读代码里的 `contarct_type_dict`
|
||||
- `get_sub_type_list()` 先根据传入 `contract_type` 选一组硬编码 `filter_key_list`
|
||||
- 再去代码里的 `contract_code_dict` 组装返回值
|
||||
|
||||
和初始化是否一致:
|
||||
|
||||
- 不一致。
|
||||
- 初始化合同候选项来自 `contract_config`。
|
||||
- 但前端拿到的合同类型/子类型下拉,来自代码字典和硬编码列表。
|
||||
|
||||
这意味着:
|
||||
|
||||
- 前端创建合同时看到的 code/type 候选,不是直接从初始化同源配置拿出来的。
|
||||
- 如果未来 `contract_config` 改了,但代码字典没改,就会分叉。
|
||||
|
||||
### 12.2 Material 的前端下拉路径
|
||||
|
||||
当前至少有两条相关路径:
|
||||
|
||||
- `/material/material-types`
|
||||
- `/material-detail/contract-product-type/list`
|
||||
|
||||
第一条路径:`/material/material-types`
|
||||
|
||||
- `router` 先根据 `project_id` 查项目类型
|
||||
- 再调用 `config_service.get_config_json(project.project_type, "material_types")`
|
||||
- 返回 `material_types` 里的 `label/value`
|
||||
|
||||
它的数据来源是:
|
||||
|
||||
- config:`material_types`
|
||||
|
||||
第二条路径:`/material-detail/contract-product-type/list`
|
||||
|
||||
- `router -> MaterialDetailService.get_material_part_options()`
|
||||
- service 内部同样会先读项目的 `material_types` config
|
||||
- 再按当前 material 的 `material_type` 找到对应 kit 和 parts
|
||||
|
||||
它的数据来源也是:
|
||||
|
||||
- config:`material_types`
|
||||
|
||||
但同时,物资模块里还保留了老的代码常量路径:
|
||||
|
||||
- `MaterialService.get_material_info_by_code()` 直接读 `contract_product_type_list`
|
||||
- `MaterialDetailService.get_kit_options()` 也直接读 `contract_product_type_list`
|
||||
|
||||
和初始化是否一致:
|
||||
|
||||
- 也不一致。
|
||||
- 前端下拉主要已经走 `material_types` config。
|
||||
- 但初始化物资时,候选项来源还是 `contract_config`,单位来源还是 `material_contract_*` dict。
|
||||
- 而创建 service 内部补 `show_name/unit` 时,又还在读代码常量 `contract_product_type_list`。
|
||||
|
||||
这是当前分叉最明显的一类:
|
||||
|
||||
- 前端选项:config
|
||||
- 初始化来源:config + dict
|
||||
- 创建补充逻辑:代码常量
|
||||
|
||||
### 12.3 Construction 的前端下拉路径
|
||||
|
||||
当前相关接口主要有:
|
||||
|
||||
- `/construction/section/type/list`
|
||||
- `/construction/section/sub/type/list`
|
||||
- `/construction/task/type/list`
|
||||
|
||||
返回路径:
|
||||
|
||||
- `router -> ConstructionService.get_section_type_list(project_type)`
|
||||
- `router -> ConstructionService.get_section_sub_type_list(section_type_code, project_type)`
|
||||
- `router -> ConstructionService.get_task_type_list(section_type_code, section_sub_type_code, project_type)`
|
||||
|
||||
数据来源:
|
||||
|
||||
- `section_type_list` 通过 `config_helper.section_type_id_dict()` 读取 `construction_section_config`
|
||||
- `section_sub_type_list` 通过 `config_helper.section_type_project_root_node_mapping()` 读取 `construction_section_config.project_root_nodes`
|
||||
- `task_type_list` 则继续基于 `construction_section_config` 和 `ConstructionTypeTree` 生成
|
||||
|
||||
和初始化是否一致:
|
||||
|
||||
- 基本一致。
|
||||
- 施工初始化本来就是 `construction_section_config + 类型树` 驱动。
|
||||
- 前端下拉接口也是同一条配置/类型树路径。
|
||||
|
||||
所以施工是目前 3 类里最接近“初始化与前端下拉同源”的。
|
||||
|
||||
### 12.4 总结
|
||||
|
||||
从前端下拉接口角度看,当前 3 类字段的真实状态可以概括成:
|
||||
|
||||
1. Contract:前端下拉走代码字典,和初始化 `contract_config` 不一致。
|
||||
2. Material:前端下拉主要走 `material_types` config,但初始化和创建内部仍分别依赖 `contract_config`、`material_contract_*` dict、`contract_product_type_list` 代码常量,分叉最严重。
|
||||
3. Construction:前端下拉和初始化都主要走 `construction_section_config + 类型树`,相对一致。
|
||||
|
||||
所以如果后续要做收口,优先级也比较明确:
|
||||
|
||||
- 合同:先统一 `contract_config` 与前端 `type/sub-type list`。
|
||||
- 物资:先统一 `material_types`、`contract_config`、`material_contract_*`、`contract_product_type_list` 四套来源。
|
||||
- 施工:优先补 schema/创建校验与配置同源,配置来源本身相对已经收敛。
|
||||
@@ -0,0 +1,201 @@
|
||||
# Material 领域重构指南
|
||||
|
||||
## 1. 范围
|
||||
|
||||
本文档只做 Material 主表梳理。
|
||||
|
||||
当前优先关注:
|
||||
|
||||
- `material.material_type`
|
||||
- `material.status`
|
||||
- `material.supply_status`
|
||||
- `material.approval_party`
|
||||
|
||||
## 2. 字段盘点
|
||||
|
||||
### 2.1 `material_type`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 本地没有独立 enum 类
|
||||
- 主要来源是 `contract_product_type_list`
|
||||
- 模型里是 `String`
|
||||
- 创建 schema 里是 `str`
|
||||
- 同步 schema 里也是 `str`
|
||||
|
||||
当前使用:
|
||||
|
||||
- 是物资主类型字段
|
||||
- schema 示例和业务逻辑都直接依赖 `contract_product_type_list`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前本地明确值:`TY`、`ZJ`、`NB`、`XB`、`ZB`、`GS`、`CN`、`DL`、`XT`、`CBJDB`、`SX`、`ECXT`、`QT`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.Material.material_type` 是按项目类型变化的
|
||||
- 光伏下有:`ZJ`、`TY`、`NB`、`XB`、`ZB`、`GS`、`CN`、`QT`
|
||||
- 陆风/海风下有:`FJ`、`TT`、`XB`、`ZB`、`GS`、`CN`、`QT`
|
||||
- 项目侧当前使用的本地列表明显偏光伏,且代码里还存在命名差异:例如本地用 `XT` / `CBJDB` / `SX` / `ECXT`,集团侧常见的是 `35KV` / `CYJD` / `SVG` / `ECPG`
|
||||
- 同步 schema 里 `material_type` 仍是 `str`
|
||||
|
||||
结论:
|
||||
|
||||
- 应迁到 `field_config`
|
||||
- 不能继续只靠本地 `contract_product_type_list`
|
||||
|
||||
### 2.2 `status`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `SmallInteger`
|
||||
- 同步 schema 里是 `Optional[int]`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前未看到单独 enum 类
|
||||
- 现有注释和使用都表明它是数值状态字段
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.Material.status` 给的是 `-1`、`0`、`1`
|
||||
- 项目侧没有在 material 模块里沉成统一常量
|
||||
|
||||
结论:
|
||||
|
||||
- 应统一正式状态定义源
|
||||
|
||||
### 2.3 `supply_status`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `SmallInteger`
|
||||
- 本地有 `SupplyStatus` enum:`-1`、`0`、`1`、`None`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- `-1`
|
||||
- `0`
|
||||
- `1`
|
||||
- `None`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 Material.status 也是按 `-1`、`0`、`1` 表达进度状态
|
||||
- 项目侧存在 `status` 和 `supply_status` 两个相近字段,语义有重叠
|
||||
|
||||
结论:
|
||||
|
||||
- 需要明确 `status` 和 `supply_status` 哪个才是正式推送字段
|
||||
|
||||
### 2.4 `approval_party`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `String`
|
||||
- schema 里是 `str`
|
||||
- 当前示例值是 `jiagong`、`yigong`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- `jiagong`
|
||||
- `yigong`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 当前没看到集团侧 `enum_config` 对这个字段给出正式候选项
|
||||
- 同步 schema 里也没有这个字段
|
||||
|
||||
结论:
|
||||
|
||||
- 先保留本地代码级候选项
|
||||
- 后续如需推送再决定是否外提
|
||||
|
||||
## 3. 重点结论
|
||||
|
||||
- `material_type` 是标准的 `field_config` 候选字段,不能再继续绑死在本地光伏型列表上。
|
||||
- `status` 与 `supply_status` 当前语义重叠,必须先定正式推送字段。
|
||||
- `approval_party` 暂时还是本地专有字段,不需要现在就硬并到集团侧。
|
||||
|
||||
## 4. 待修改项清单
|
||||
|
||||
1. 将 `material_type` 正式迁到 `field_config`。
|
||||
2. 按项目类型重建 `material_type` 候选项,不再只用本地光伏型列表。
|
||||
3. 统一 `35KV/XT`、`CYJD/CBJDB`、`SVG/SX`、`ECPG/ECXT` 这类命名差异。
|
||||
4. 明确 `status` 与 `supply_status` 的职责边界。
|
||||
5. 为 `material_type` 在 schema 层增加统一校验入口。
|
||||
|
||||
## 5. 按本轮要求的改造落点
|
||||
|
||||
material 是本轮最需要针对性改造的领域之一,因为它当前同时存在:
|
||||
|
||||
- 初始化来源分叉
|
||||
- create 校验分叉
|
||||
- 下拉来源分叉
|
||||
|
||||
### 5.1 初始化
|
||||
|
||||
当前 material 初始化不是单源:
|
||||
|
||||
- 候选项来自 `contract_config` 里的 product 合同
|
||||
- 单位和部分展示信息来自 `material_contract_{project_type}` dict
|
||||
|
||||
而且 `unit` 现在还挂在 dict `remark` 上。
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- 明确 `material.material_type` 的正式配置来源
|
||||
- 把 `label`、`unit`、`parts` 等附属字段纳入正式 config 结构
|
||||
- 去掉“从 dict remark 补单位”这种不适当用法
|
||||
|
||||
### 5.2 Create 校验
|
||||
|
||||
当前 create 路径里:
|
||||
|
||||
- `material_type` 入参本身没有正式 config 校验
|
||||
- `show_name`、`unit` 由 `contract_product_type_list` 反查
|
||||
- 随后又会调用合同创建,由合同 service 再做一层 code 白名单校验
|
||||
|
||||
这说明 material create 现在不是单一入口校验。
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- 在 `MaterialService.create()` 开头基于 config 校验 `material_type`
|
||||
- 用 `get_field_option_map()` 之类的方法直接拿 `label`、`unit`、`parts`
|
||||
- 不再依赖 `contract_product_type_list` 和默认兜底值
|
||||
|
||||
### 5.3 下拉列表
|
||||
|
||||
当前物资下拉已经部分走 config:
|
||||
|
||||
- `/material/material-types` 读 `material_types`
|
||||
- `/material-detail/contract-product-type/list` 也读 `material_types`
|
||||
|
||||
但模块里仍保留了旧的硬编码路径:
|
||||
|
||||
- `get_material_info_by_code()`
|
||||
- `get_kit_options()`
|
||||
- `contract_product_type_list`
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- 让物资类型和部件列表统一从新的 config 工具返回
|
||||
- 下拉、初始化、create 校验全部围绕同一套 `material_type` 配置
|
||||
|
||||
### 5.4 清理项
|
||||
|
||||
material 领域这轮改造完成后,应优先清理:
|
||||
|
||||
- `material_contract_*` 中用 `remark` 承载单位的旧逻辑
|
||||
- `contract_product_type_list` 里只服务于 `material_type` 真源的旧定义
|
||||
- `get_material_info_by_code()` 这类旧反查入口
|
||||
|
||||
## 6. 测试关注点
|
||||
|
||||
material 领域 service 层集成测试至少覆盖:
|
||||
|
||||
1. 初始化:项目初始化后 material 的 `material_type`、`show_name`、`unit` 是否来自目标 config。
|
||||
2. 下拉:物资类型和部件列表是否来自同一 config,且与初始化同源。
|
||||
3. Create:合法 `material_type` 能创建,非法 `material_type` 明确报错,不再依赖默认兜底。
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Personnel 领域重构指南
|
||||
|
||||
## 1. 范围
|
||||
|
||||
当前仓库里没有独立的 `personnel` 项目业务模块。
|
||||
|
||||
最接近的本地实现是:
|
||||
|
||||
- `app/modules/safety/models/safety_personnel_qualification.py`
|
||||
- `app/modules/safety/schemas/personnel_qualification.py`
|
||||
|
||||
因此本文档先按“人员资质”这一真实落点梳理,而不是假设仓库里已经有独立 `personnel` 主表。
|
||||
|
||||
当前优先关注:
|
||||
|
||||
- `personnel_type`
|
||||
- `task_type`
|
||||
- `is_duty`
|
||||
|
||||
## 2. 字段盘点
|
||||
|
||||
### 2.1 `personnel_type`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里通过类常量定义两个值:`safe`、`special`
|
||||
- 模型字段本身是 `String`
|
||||
- schema 里仍是 `str`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- `safe`
|
||||
- `special`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.Personnel` 里并没有 `personnel_type`
|
||||
- 集团侧更像是在描述“人员/吊装设备”等资源配置编码,而不是人员资质类型
|
||||
- 当前仓库里也没有 standalone 的 sync schema 对这个领域建模
|
||||
|
||||
结论:
|
||||
|
||||
- 这是本地代码级 enum 字段
|
||||
- 可保留为代码 enum
|
||||
|
||||
### 2.2 `task_type`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里通过 `PERSONNEL_TASK_TYPE` 类常量给出候选项
|
||||
- 字段本身是 `String`
|
||||
- schema 里仍是 `str`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- `电工`
|
||||
- `焊工`
|
||||
- `高空作业人员`
|
||||
- `无人机操作员`
|
||||
- `其他`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.Personnel` 没有这个字段
|
||||
- 当前仓库里也没有 sync schema 对应
|
||||
|
||||
结论:
|
||||
|
||||
- 这是本地代码级候选项字段
|
||||
- 可以保留为代码常量,但要补 schema 校验
|
||||
|
||||
### 2.3 `is_duty`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里通过类常量定义:`1` 在场,`2` 退场
|
||||
- 字段本身是 `SmallInteger`
|
||||
- schema 里是 `int`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- `1`
|
||||
- `2`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.Personnel.status` 给的是 `-1`、`0`、`1`
|
||||
- 这更像资源进度状态,不是本地 `is_duty` 语义
|
||||
- 两边不是同一字段,不应强行对齐
|
||||
|
||||
结论:
|
||||
|
||||
- 保留为本地数值常量字段
|
||||
|
||||
## 3. 重点结论
|
||||
|
||||
- 当前所谓 `personnel` 领域,在本地真实落点是“人员资质”,不是集团侧 `Personnel` 那套资源配置模型。
|
||||
- 因此这里最重要的不是照抄集团侧,而是先承认两个系统的领域边界不一致。
|
||||
|
||||
## 4. 待修改项清单
|
||||
|
||||
1. 明确 `personnel` 在本地的正式领域名是否就是 `safety_personnel_qualification`。
|
||||
2. 为 `personnel_type` 补正式 enum/schema 校验。
|
||||
3. 为 `task_type` 补统一白名单校验。
|
||||
4. 不要把集团侧 `Personnel.code/status` 直接映射到本地 `personnel_type/is_duty`。
|
||||
|
||||
## 5. 按本轮要求的改造落点
|
||||
|
||||
personnel 领域不是这轮“项目初始化 + 项目类型 field_config”第一批重点,但仍需要按统一口径写清楚 create、下拉和清理边界。
|
||||
|
||||
### 5.1 初始化
|
||||
|
||||
本地人员资质领域当前没有项目初始化批量灌数链路。
|
||||
|
||||
因此本轮不把它纳入 `_init_project_data()` 第一批改造范围。
|
||||
|
||||
### 5.2 Create 校验
|
||||
|
||||
当前 `PersonnelQualificationService.create()` 已直接在 service 层校验:
|
||||
|
||||
- `personnel_type`
|
||||
- `task_type`
|
||||
|
||||
校验来源是本地模型类常量:
|
||||
|
||||
- `PERSONNEL_TYPE_SAFE`
|
||||
- `PERSONNEL_TYPE_SPECIAL`
|
||||
- `PERSONNEL_TASK_TYPE`
|
||||
|
||||
这类字段当前更像固定语义本地枚举,不是按 `project_type` 变化的字段级配置候选项。
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- 继续把它们视为本地固定候选项
|
||||
- 统一 create / update / list 使用同一套定义源
|
||||
- 不误迁到项目类型 field_config
|
||||
|
||||
### 5.3 下拉列表
|
||||
|
||||
当前已有:
|
||||
|
||||
- `/safety/personnel/task_type/list`
|
||||
|
||||
它直接返回 `PERSONNEL_TASK_TYPE`。
|
||||
|
||||
本轮这里不要求强行改成 `project_type` 相关 config,而是要求:
|
||||
|
||||
- 明确它是本地固定候选项
|
||||
- 前端下拉和 create 校验继续同源
|
||||
|
||||
### 5.4 清理项
|
||||
|
||||
personnel 领域本轮的清理重点不是删 enum,而是避免错误地把集团侧 `Personnel` 语义强行映射进来。
|
||||
|
||||
应避免:
|
||||
|
||||
- 把本地 `personnel_type`、`task_type` 误改造成和集团侧 `code/status` 一一对应
|
||||
- 同一字段在 service 和下拉各自维护两套值
|
||||
|
||||
## 6. 测试关注点
|
||||
|
||||
personnel 领域 service 层集成测试至少覆盖:
|
||||
|
||||
1. `personnel_type` 合法值和非法值创建校验。
|
||||
2. `special` 人员必须校验 `task_type`。
|
||||
3. `task_type/list` 返回值与 create 校验使用同源常量。
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
# 项目侧字段级配置重构计划
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本次重构的目标,不是先统一项目侧全部 config / enum / dict 的历史实现,而是先补一套项目侧自己的“字段级配置总表”和配套工具,使下面这类问题有统一解法:
|
||||
|
||||
- 某个字段在某个 `project_type` 下有哪些可选项。
|
||||
- 这些可选项的中文名是什么。
|
||||
- 业务代码如何方便地做手动校验。
|
||||
- 初始化流程如何从同一套字段级配置中读取候选项。
|
||||
- 哪些字段应该继续在代码里用 enum 处理。
|
||||
- 哪些字段应该迁到配置文件处理。
|
||||
|
||||
这套能力对齐集团侧 `enum_config.json` 的思路,但项目侧不照搬集团侧的存储形式,而是基于现有 `config` 表和 CSV 数据维护方式落地。
|
||||
|
||||
因此,本次重构除了建设字段级配置总表本身,还需要同步完成一件事:
|
||||
|
||||
- 梳理字段边界,明确哪些字段属于“固定语义、固定值集合、适合保留在代码 enum 中”,哪些字段属于“随 `project_type` 或初始化流程变化、应迁到字段级配置中”。
|
||||
|
||||
也就是说,这次不是单纯补一个配置表,而是要同时建立一套可执行的字段分流规则:
|
||||
|
||||
- enum 字段:继续留在代码中,由 enum 表达固定约束。
|
||||
- field_config 字段:迁到配置中,由字段级配置表达候选项。
|
||||
|
||||
## 1.1 本轮实际要改的内容
|
||||
|
||||
这次文档和后续代码改造,不再停留在“抽象上应该统一”的层面,而是直接围绕下面 5 件事展开。
|
||||
|
||||
### 0. 通用能力
|
||||
|
||||
要补两类公共能力:
|
||||
|
||||
- 增强现有配置类,在 `ConfigService` 上新增字段级配置读取、映射、校验工具。
|
||||
- 增强基于 JSON 导出 CSV 的工具,使字段级配置和现有 config 维护流程能直接联动。
|
||||
|
||||
### 1. 优化项目初始化过程
|
||||
|
||||
目标不是只分析 `_init_project_data()`,而是把初始化链路改成统一从配置工具取候选项:
|
||||
|
||||
- 整理项目初始化当前实际使用到的 config。
|
||||
- 把适合迁移的初始化候选项改为通过新的 config 工具获取后创建。
|
||||
- 去掉不适当的 dict 用法,例如把单位塞在 dict `remark` 里。
|
||||
|
||||
### 2. 优化创建数据的验证
|
||||
|
||||
这次不改 schema 目录类型系统,但要改 service 层入口。
|
||||
|
||||
要求是:
|
||||
|
||||
- 在 create 类接口对应的 service 业务入口开头,基于 config 工具校验 code 类字段。
|
||||
- 不再继续新增代码字典白名单、硬编码 if/else 白名单。
|
||||
|
||||
### 3. 统一下拉列表来源
|
||||
|
||||
这次不仅要梳理下拉列表,还要把它们收口到新的 config 工具:
|
||||
|
||||
- 梳理所有 code 类下拉列表当前返回路径。
|
||||
- 能迁移的下拉统一改成通过 config 工具返回。
|
||||
- 少量现有写死的下拉列表,例如合同结算类型列表,也要改成动态获取。
|
||||
|
||||
### 4. 删除废弃 enum / 硬编码字典
|
||||
|
||||
完成上述迁移后,要清掉已经不再适合作为真源的:
|
||||
|
||||
- 废弃 enum
|
||||
- 废弃硬编码 dict
|
||||
- 只用于旧下拉或旧创建校验的代码常量
|
||||
|
||||
### 5. 自动化测试脚本
|
||||
|
||||
这次不是只改文档和代码,还要补自动化测试,覆盖至少 3 类集成场景:
|
||||
|
||||
- 初始化脚本 / 初始化 service 集成测试
|
||||
- 各业务下拉列表获取集成测试
|
||||
- 各业务 create service 集成测试
|
||||
|
||||
测试脚本写在 `tests` 下,不写到项目根目录。
|
||||
|
||||
## 2. 重构范围
|
||||
|
||||
本次重构只覆盖一类字段:
|
||||
|
||||
- 对应集团侧 `enum_config.json` 相应字段的项目侧字段。
|
||||
- 和 `project_type` 相关。
|
||||
- 和项目初始化过程相关。
|
||||
|
||||
也就是说,本次优先处理的是“初始化候选项字段”,而不是全部字符串字段。
|
||||
|
||||
优先关注的业务范围包括:
|
||||
|
||||
- project 初始化时需要的字段候选项
|
||||
- contract 初始化相关字段
|
||||
- construction 初始化相关字段
|
||||
- material 初始化相关字段
|
||||
- power / preparation / production 中与项目类型初始化直接相关的字段
|
||||
|
||||
从现状代码看,这些初始化来源当前混杂在:
|
||||
|
||||
- Python enum
|
||||
- Python 硬编码 dict
|
||||
- `dict_data`
|
||||
- `config` 表中的自由 JSON
|
||||
- 外部业务大表或类型树
|
||||
|
||||
本次要做的不是一次性清理所有来源,而是为这些字段补一个统一的字段级配置出口。
|
||||
|
||||
对本轮来说,这里的“统一出口”不是泛指,而是明确服务于 3 条业务链路:
|
||||
|
||||
- 初始化:通过统一配置工具取候选项并创建数据
|
||||
- 创建:通过统一配置工具校验 code 类字段
|
||||
- 下拉:通过统一配置工具返回选项列表
|
||||
|
||||
## 3. 不在本次范围内的内容
|
||||
|
||||
以下内容明确不在本次重构范围:
|
||||
|
||||
- 接口返回层的翻译统一
|
||||
- `dict` 体系的整体重构
|
||||
- 所有历史自由 config 的一次性收敛
|
||||
- 所有外部树和业务大表的一次性字段化
|
||||
- schema 目录下的类型系统改造
|
||||
- 运行时直接读取 JSON 文件作为配置源
|
||||
|
||||
这里补充两条边界:
|
||||
|
||||
- 不允许为了兼容旧逻辑继续新增 dict `remark` 之类的隐式字段承载方式。
|
||||
- 不允许为了减少改动,在新工具落地后继续保留“前端下拉走 config、初始化走 dict、create 校验走代码字典”的三头并存状态。
|
||||
|
||||
因此,本次允许字段级配置中的 `value / label` 与现有 dict 翻译短期重复。后续如有需要,可再通过脚本生成 dict 或做统一翻译收敛。
|
||||
|
||||
## 4. 核心设计
|
||||
|
||||
### 4.1 新增“字段级配置总表”概念
|
||||
|
||||
项目侧需要新增一层固定格式的字段级配置。
|
||||
|
||||
这层配置的职责只有一个:
|
||||
|
||||
- 对外统一回答“某字段在某项目类型下有哪些可选项”。
|
||||
|
||||
它不是为了取代所有业务 config,而是为了给业务代码、初始化流程、推送系统提供统一的读取接口。
|
||||
|
||||
### 4.2 存储方式
|
||||
|
||||
字段级配置继续存放在现有 `config` 表中,并通过现有 CSV 数据维护。
|
||||
|
||||
建议约束如下:
|
||||
|
||||
- `category = project_type`
|
||||
- `config_type = json`
|
||||
- `config_key` 不强制按规则自动拼接
|
||||
- 代码里仍允许手动传入 key 查询
|
||||
|
||||
虽然推荐把 key 命名为 `model_name.field_name`,例如:
|
||||
|
||||
- `construction.section_type_code`
|
||||
- `material.material_type`
|
||||
- `contract.code`
|
||||
|
||||
但这只是推荐命名,不作为框架自动推导前提。
|
||||
|
||||
也就是说:
|
||||
|
||||
- 本次不建设“根据 model + field 自动拼 config key”的机制
|
||||
- 只建设“拿到 key 后,按统一 schema 读取字段级配置”的机制
|
||||
|
||||
### 4.3 固定 JSON 结构
|
||||
|
||||
字段级配置的 `default_value` / `config_value` 必须是一个 JSON object。
|
||||
|
||||
对于字段级配置,只强约束一件事:
|
||||
|
||||
- 顶层必须有 `field_config`
|
||||
|
||||
其中:
|
||||
|
||||
- `field_config` 必须是列表
|
||||
- 列表中每个元素至少有 `value`、`label`
|
||||
|
||||
推荐最小结构如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"field_config": [
|
||||
{"value": "GF", "label": "光伏场区", "weight": 1.4},
|
||||
{"value": "SY", "label": "升压站", "weight": 1.4}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
除 `field_config` 外,其它顶层字段不做统一规定,由各业务自己定义。
|
||||
|
||||
例如,某些字段配置可能会额外包含:
|
||||
|
||||
- `description`
|
||||
- `init_config`
|
||||
- 其他与该字段强相关的附属配置
|
||||
|
||||
这些字段允许存在,但不纳入统一字段级 schema 强约束。
|
||||
|
||||
### 4.4 非字段级 config 的统一要求
|
||||
|
||||
对于仍然保留的自由业务 config,不要求包含 `field_config`。
|
||||
|
||||
但建议默认增加一个顶层字段:
|
||||
|
||||
- `description`
|
||||
|
||||
这样至少能保证所有 config 在文档和工具层面具备最低限度的语义说明。
|
||||
|
||||
## 5. `field_config` Schema 设计
|
||||
|
||||
### 5.1 字段候选项结构
|
||||
|
||||
字段级配置中的 `field_config` 列表项,最少包含:
|
||||
|
||||
- `value: str`
|
||||
- `label: str`
|
||||
|
||||
允许附加字段,例如:
|
||||
|
||||
- `weight`
|
||||
- `unit`
|
||||
- `section`
|
||||
- `legacy`
|
||||
- `flag`
|
||||
- `skip_init`
|
||||
- `skip_create`
|
||||
|
||||
这些附加字段不要求在所有字段中统一,但它们必须和值本身强相关。
|
||||
|
||||
### 5.2 建议的 Pydantic 模型
|
||||
|
||||
建议新增专门的 Pydantic schema,用于字段级配置校验。
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class FieldOptionItem(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
value: str = Field(..., description="候选值")
|
||||
label: str = Field(..., description="显示名")
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 只需要识别 `field_config` 这个字段
|
||||
- 只需要校验 `field_config` 是 `list[FieldOptionItem]`
|
||||
- 其它顶层字段不在统一 schema 里写死
|
||||
|
||||
## 6. `ConfigService` 统一承载
|
||||
|
||||
字段级配置相关的辅助方法统一放到 `ConfigService`,不再额外拆新的独立 service。
|
||||
|
||||
也就是说,这次应增强的是:
|
||||
|
||||
- `app/modules/system/services/config.py`
|
||||
|
||||
而不是在别处再起一套并行 helper。
|
||||
|
||||
### 6.1 建议新增的方法
|
||||
|
||||
```python
|
||||
async def get_field_config(project_type: str, key: str, tenant_id: str | None = None) -> dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
async def get_field_options(project_type: str, key: str, tenant_id: str | None = None) -> list[dict[str, Any]]:
|
||||
...
|
||||
|
||||
|
||||
async def get_field_option_map(project_type: str, key: str, tenant_id: str | None = None) -> dict[str, dict[str, Any]]:
|
||||
...
|
||||
|
||||
|
||||
async def get_field_label(project_type: str, key: str, value: str, tenant_id: str | None = None) -> str | None:
|
||||
...
|
||||
|
||||
|
||||
async def validate_field_value(
|
||||
project_type: str,
|
||||
key: str,
|
||||
value: str,
|
||||
tenant_id: str | None = None,
|
||||
*,
|
||||
error_prefix: str | None = None,
|
||||
) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
如果后续需要覆盖“批量校验多个字段”或“直接返回下拉结构”的场景,可以继续补:
|
||||
|
||||
```python
|
||||
async def get_field_options_for_frontend(project_type: str, key: str, tenant_id: str | None = None) -> list[dict[str, Any]]:
|
||||
...
|
||||
|
||||
|
||||
async def validate_field_values(project_type: str, items: list[tuple[str, str]], tenant_id: str | None = None) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
### 6.2 各接口职责
|
||||
|
||||
`get_field_config()`:
|
||||
|
||||
- 读取指定 `project_type + key` 的 JSON 配置
|
||||
- 找出顶层 `field_config`
|
||||
- 校验 `field_config` 是否满足 `list[FieldOptionItem]`
|
||||
- 返回完整配置对象
|
||||
|
||||
`get_field_options()`:
|
||||
|
||||
- 返回 `field_config` 列表
|
||||
- 供初始化、下拉、候选项获取直接使用
|
||||
|
||||
`get_field_option_map()`:
|
||||
|
||||
- 返回 `{value: option}` 的映射
|
||||
- 供快速查找、补充属性读取使用
|
||||
|
||||
`get_field_label()`:
|
||||
|
||||
- 根据 `value` 返回 `label`
|
||||
- 本次主要服务于业务内部使用,不承担接口翻译统一职责
|
||||
|
||||
`validate_field_value()`:
|
||||
|
||||
- 手动调用
|
||||
- 校验给定值是否在当前项目类型该字段的候选项中
|
||||
- 校验失败时抛出业务异常
|
||||
|
||||
另外,本轮会直接用到两种典型调用方式:
|
||||
|
||||
- 初始化时:`get_field_options()` / `get_field_option_map()`
|
||||
- 创建和编辑时:`validate_field_value()`
|
||||
- 下拉接口时:`get_field_options_for_frontend()` 或 `get_field_options()`
|
||||
|
||||
这里特别强调:
|
||||
|
||||
- `validate` 是手动调用的
|
||||
- 本次不做自动注入所有模型或 schema 的统一校验框架
|
||||
- 目标是让业务层“方便调用”,不是一次性接管所有校验入口
|
||||
|
||||
### 6.3 `ConfigService` 需要补齐的基础能力
|
||||
|
||||
`ConfigService` 当前除了读取外,还需要补齐两类基础能力:
|
||||
|
||||
#### 1. JSON 序列化 / 反序列化统一承载
|
||||
|
||||
不应继续让调用方自己到处 `msgspec.json.decode(...)`。
|
||||
|
||||
`ConfigService` 应统一负责:
|
||||
|
||||
- 读取 JSON 配置时反序列化
|
||||
- 更新 JSON 配置时序列化
|
||||
- 在字段级配置方法中复用这一套逻辑
|
||||
|
||||
也就是说,配置的 JSON 处理应沉到 `ConfigService` 本身,而不是散落在各业务模块里。
|
||||
|
||||
#### 2. 关键读取方法加缓存
|
||||
|
||||
`ConfigService` 中的关键读取方法应增加缓存。
|
||||
|
||||
优先包括:
|
||||
|
||||
- `get_config_json()`
|
||||
- `get_field_config()`
|
||||
- `get_field_options()`
|
||||
- `get_field_option_map()`
|
||||
- `get_field_label()`
|
||||
|
||||
要求:
|
||||
|
||||
- 读接口走缓存
|
||||
- 更新配置后显式失效相关缓存
|
||||
- 不允许缓存让旧配置长期滞留
|
||||
|
||||
`validate_field_value()` 自身不需要单独缓存,但应复用已经缓存过的字段选项读取结果。
|
||||
|
||||
### 6.4 实现要求
|
||||
|
||||
工具类内部应统一处理:
|
||||
|
||||
- 读取 `config` 表 JSON
|
||||
- 用 Pydantic 校验 `field_config`
|
||||
- 构建 options / map / label
|
||||
- 输出清晰错误,便于发现配置格式问题
|
||||
|
||||
不允许的处理方式:
|
||||
|
||||
- 遇到 schema 错误时静默降级
|
||||
- 遇到缺字段时自动猜测兼容成其他结构
|
||||
|
||||
如果配置不满足 `field_config` schema,应直接报错。
|
||||
|
||||
## 7. JSON / CSV 工具重整
|
||||
|
||||
本次不要求运行时直接读取 JSON 文件。
|
||||
|
||||
但为了方便调试和维护,应把现有 `scripts/tool/csv_data_tools/` 按新的字段级配置目标重新整理。
|
||||
|
||||
目标是:
|
||||
|
||||
- 一个 key 对应一个 JSON 文件
|
||||
- JSON 文件仍然采用 config 行列表格式
|
||||
- 工具负责 JSON 和 `config.csv`、数据库之间的转换
|
||||
|
||||
这块和本轮目标直接对应:
|
||||
|
||||
- 字段级配置调整后,可以直接导出为 `config.csv`
|
||||
- 初始化改造前后,可以快速对比 config 差异
|
||||
- 下拉列表和创建校验依赖的 config,可以单独导出检查,不再手工翻长 CSV
|
||||
|
||||
建议目录:
|
||||
|
||||
- `scripts/initdata/json_config/`
|
||||
|
||||
建议能力:
|
||||
|
||||
- `validate-json-config`: 校验 JSON 文件是否满足 config 行结构,以及 `field_config` 约束
|
||||
- `json-to-csv`: 从 `json_config` 目录批量生成 `config.csv`
|
||||
- `json-to-db`: 从 `json_config` 目录批量导入数据库
|
||||
- `csv-to-json`: 从 `config.csv` 按 key 导出 JSON 文件
|
||||
- `db-to-json`: 从数据库导出 JSON 文件用于调试
|
||||
|
||||
当前已有的 `scripts/tool/csv_data_tools/csv_data_tools.py` 可以复用思路,但需要围绕“字段级配置总表 + 一 key 一文件”的目标重新整理。
|
||||
|
||||
## 8. 领域重构指南
|
||||
|
||||
主计划只定义系统级方案。具体领域的梳理应拆到独立文件,例如:
|
||||
|
||||
- [construction.md](/Users/zyl/my_projects/ecm_sync_system/docs/enum_refactor/construction.md)
|
||||
- [project.md](/Users/zyl/my_projects/ecm_sync_system/docs/enum_refactor/project.md)
|
||||
|
||||
后续 contract / material 等领域也应按同样方式分别整理。
|
||||
|
||||
每个领域文档都不是只看“集团侧已有字段”或者“准备迁到 `field_config` 的字段”。
|
||||
|
||||
每个领域都必须同时覆盖两部分字段:
|
||||
|
||||
- depm 中所有已经使用以下来源之一的字段:代码 enum、代码中的初始化列表、硬编码 dict、类 enum、`config` 表、`dict` 表
|
||||
- 集团侧 `enum_config` 中该领域涉及的全部字段
|
||||
|
||||
两部分做并集,不做交集。
|
||||
|
||||
也就是说:
|
||||
|
||||
- `enum_config` 中出现的字段,必须全部纳入领域文档
|
||||
- 即使某字段暂时没有出现在 `enum_config` 中,只要 depm 已经在用代码 enum / 类 enum / 初始化列表 / 硬编码 dict / `config` 表 / `dict` 表承载它,并且它影响初始化、手动创建、编辑、校验、展示,也必须纳入领域文档
|
||||
|
||||
这里要特别避免一种错误写法:
|
||||
|
||||
- 不能把领域范围写成“只讨论和集团侧 `enum_config` 直接对应的字段”
|
||||
- 不能因为某字段暂时只有 depm 本地 enum / 类 enum,而集团侧还没有对应项,就把它排除在领域文档之外
|
||||
|
||||
每个领域文档都应至少覆盖以下内容:
|
||||
|
||||
### 8.1 先做字段总表
|
||||
|
||||
每个领域文档开头都应先列出字段总表,至少回答:
|
||||
|
||||
- 集团侧 `enum_config` 覆盖了哪些字段
|
||||
- depm 侧当前还额外维护了哪些相关字段
|
||||
- 每个字段当前使用的来源是什么:代码 enum、初始化列表、硬编码 dict、类 enum、`config` 表、`dict` 表,或它们的混用
|
||||
- 每个字段分别影响哪些链路:项目初始化、手动创建、手动编辑、接口展示、历史数据兼容、业务校验
|
||||
|
||||
如果同一个字段在多个来源重复定义,也必须明确记出来,而不是只记录“最终准备保留哪个”。
|
||||
|
||||
### 8.2 整理不查表的 enum 类数据
|
||||
|
||||
不是只有查表型数据才需要整理。
|
||||
|
||||
如果某领域存在不依赖字典表、也不依赖 `config` 表的纯 enum / 纯硬编码候选项,同样要梳理:
|
||||
|
||||
- 当前定义位置在哪
|
||||
- 是否重复定义
|
||||
- 是否和集团侧 `enum_config` 对应字段有关
|
||||
- 是否参与初始化、手动创建、手动编辑、展示翻译或历史校验
|
||||
- 是否需要继续保留为代码 enum
|
||||
- 是否应该迁到新的 `field_config`
|
||||
|
||||
也就是说,纯 enum 数据如果和初始化字段、手动创建字段有关,也在本轮视野内。
|
||||
|
||||
### 8.3 梳理哪些功能需要迁移到新的 `field_config`
|
||||
|
||||
每个领域都需要明确列出:
|
||||
|
||||
- 哪些初始化功能依赖字段候选项
|
||||
- 哪些手动创建 / 编辑功能依赖字段候选项
|
||||
- 哪些历史白名单校验需要迁移到 `validate_field_value()`
|
||||
- 哪些直接遍历旧 JSON 的逻辑需要迁移到 `get_field_options()` / `get_field_option_map()`
|
||||
- 哪些字段虽然目前由 `dict` 表或代码 enum 承载,但实际上已经在承担“按项目类型变化的候选项字段”职责
|
||||
|
||||
并且必须给出示例。
|
||||
|
||||
示例类型包括:
|
||||
|
||||
- 初始化时从旧 config 直接遍历候选项,改为从 `field_config` 读取
|
||||
- 手动创建时从硬编码 dict 判断合法值,改为调用 `validate_field_value()`
|
||||
- 展示名称时从字段选项里直接拿 `label`
|
||||
|
||||
对本轮来说,每个领域文档都至少要把下面 3 条链路写清楚:
|
||||
|
||||
- 初始化:这个字段初始化时从哪里取候选项,迁移后改成哪个 config key
|
||||
- 创建:这个字段 create service 当前怎么校验,迁移后改成哪个 `validate_field_value()` 调用
|
||||
- 下拉:这个字段前端当前通过哪个接口取值,迁移后改成哪个 config key 统一返回
|
||||
|
||||
### 8.4 必须完整对齐集团侧 `enum_config`
|
||||
|
||||
领域梳理时,不能只挑 depm 里已经处理过的字段,也不能只挑“看起来最容易改”的字段。
|
||||
|
||||
每个领域都必须把集团侧 `enum_config` 中该领域涉及的字段逐一对齐到 depm,明确说明它现在处于哪一种状态:
|
||||
|
||||
- depm 已有同名或同语义字段,并且来源清晰
|
||||
- depm 有近似字段,但命名、语义、来源或取值范围不完全一致
|
||||
- depm 还没有正式落点,只能先记录缺口
|
||||
|
||||
对齐 `enum_config` 时,每个字段都应继续检查两条链路:
|
||||
|
||||
- 项目初始化时,取值从哪里来,如何展开
|
||||
- 手动创建 / 更新时,取值如何校验,是否和初始化使用同一来源
|
||||
|
||||
如果两条链路来源不一致,应明确记录并给出迁移方案。
|
||||
|
||||
## 9. 实施步骤
|
||||
|
||||
### 第一阶段:公共能力
|
||||
|
||||
- 明确字段级配置固定 JSON 结构
|
||||
- 新增 `FieldOptionItem`
|
||||
- 在 `ConfigService` 中新增字段级配置方法
|
||||
- 给关键读取方法加缓存
|
||||
- 补齐 JSON 序列化 / 反序列化统一处理
|
||||
- 明确错误处理规则:配置结构不合法时直接报错
|
||||
- 增强 JSON / CSV 工具,支持字段级配置导入导出
|
||||
|
||||
### 第二阶段:初始化改造
|
||||
|
||||
- 逐项整理 `_init_project_data()` 当前真实依赖的 config / dict
|
||||
- 把 `preparation`、`production`、`power` 这类平铺型初始化模板迁到 config
|
||||
- 整理 `material` 初始化,把单位等附属属性从不适当的 dict 备注迁出
|
||||
- 清理 `_init_project_data()` 中取了但不该继续作为真源的 dict 依赖
|
||||
|
||||
### 第三阶段:创建校验改造
|
||||
|
||||
- 为 contract / material / construction 等 create service 入口补基于 config 的 code 校验
|
||||
- 把当前分散在 service 内的硬编码白名单逐步迁到 config 工具
|
||||
- 保留真正应该继续存在的代码 enum,不把固定语义状态误迁到 config
|
||||
|
||||
### 第四阶段:下拉列表改造
|
||||
|
||||
- 梳理 code 类下拉接口
|
||||
- 下拉统一改成通过 config 工具返回
|
||||
- 少量仍写死的下拉改为动态获取
|
||||
- 保证前端下拉、初始化候选项、create 校验三者同源
|
||||
|
||||
### 第五阶段:清理和测试
|
||||
|
||||
- 删除废弃 enum 和废弃代码字典
|
||||
- 删除不再适合作为真源的 dict 依赖
|
||||
- 补 service 层集成测试和初始化测试
|
||||
|
||||
## 10. 产出要求
|
||||
|
||||
本次重构最终至少要产出:
|
||||
|
||||
- 一套明确的字段级配置 schema
|
||||
- 一套明确的 `ConfigService` 字段级配置接口
|
||||
- 一套明确的缓存与缓存失效规则
|
||||
- 一套 JSON / CSV / DB 三者之间的转换与维护规范
|
||||
- 一组领域级重构文档
|
||||
- 一组初始化 / 下拉 / create 的 service 层集成测试
|
||||
|
||||
判断本次重构是否成功的标准不是“历史实现全部统一”,而是:
|
||||
|
||||
- 后续新增一个与项目类型初始化相关的字段时,知道应优先写入字段级配置总表
|
||||
- 业务代码可以通过 `ConfigService` 统一获取 options / map / label / validate
|
||||
- 初始化流程和手动创建校验可以逐步收敛到同一来源
|
||||
- 前端下拉、初始化、create 校验不再各自读不同真源
|
||||
|
||||
## 11. 自动化测试要求
|
||||
|
||||
这次测试不是只补 unit test,而是以 service 层集成测试为主。
|
||||
|
||||
至少覆盖下面 3 组:
|
||||
|
||||
### 11.1 初始化测试
|
||||
|
||||
- 覆盖项目初始化入口
|
||||
- 校验初始化创建出的 preparation / production / contract / material / power / construction 数据是否来自目标 config
|
||||
- 校验错误配置时直接报错,不做静默降级
|
||||
|
||||
### 11.2 下拉列表测试
|
||||
|
||||
- 覆盖 contract / material / construction 等核心下拉接口或对应 service
|
||||
- 校验返回项是否来自预期 config key
|
||||
- 校验下拉返回和初始化候选项是否一致
|
||||
|
||||
### 11.3 Create 校验测试
|
||||
|
||||
- 直接走 service 层 create
|
||||
- 对合法 code 校验通过
|
||||
- 对非法 code 明确抛错
|
||||
- 校验不再依赖旧硬编码字典或 dict `remark`
|
||||
|
||||
### 11.4 测试组织方式
|
||||
|
||||
- 测试代码写在 `tests/` 下
|
||||
- 优先写 service 层集成测试,不在项目根目录放临时脚本
|
||||
- 如果需要初始化测试数据,应复用现有测试体系,不改 `schemas/`、不改 `filtered_datasource/`
|
||||
@@ -0,0 +1,151 @@
|
||||
# Preparation 领域重构指南
|
||||
|
||||
## 1. 范围
|
||||
|
||||
本文档对应 backend 的 `preparation` 里程碑节点表。
|
||||
|
||||
当前优先关注:
|
||||
|
||||
- `preparation.code`
|
||||
- `preparation.approval_status`
|
||||
- `preparation.is_exist`
|
||||
|
||||
## 2. 字段盘点
|
||||
|
||||
### 2.1 `code`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `String`
|
||||
- 没有本地 enum 类
|
||||
- 项目初始化时,节点集合来自 `preparation_{project_type}` 字典数据
|
||||
- 同步 schema `project_extensions/preparation.py` 里也是 `str`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前无法从单一代码 enum 列出完整范围
|
||||
- 已知典型值包括:`JSZB`、`HZBA`、`KYNS`、`TZJC`、`YDYS`、`LCPF`、`YDPF`、`JRPF`、`HPPF`、`SGXK`、`SBPF`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.Preparation.code` 已明确把该字段作为按项目类型变化的正式候选项
|
||||
- 本地当前仍主要靠初始化字典/字符串承载
|
||||
|
||||
结论:
|
||||
|
||||
- 更适合迁到 `field_config`
|
||||
- 不应继续只靠初始化字典和自由字符串
|
||||
|
||||
### 2.2 `approval_status`
|
||||
|
||||
当前实现:
|
||||
|
||||
- backend 依赖 `ApprovalMixin`
|
||||
- 本地 `PreparationApproval` schema 仍使用 int
|
||||
- 同步 schema `PreparationDetail` 里是 `Optional[str]`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前本地未在 preparation 模块中沉成独立 enum
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 同步 schema 有字段,但没有正式 enum
|
||||
|
||||
结论:
|
||||
|
||||
- 应统一接入通用审批状态体系
|
||||
|
||||
### 2.3 `is_exist`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `Boolean`
|
||||
- backend 和 sync schema 均有该字段
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- `true`
|
||||
- `false`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 当前没看到 enum_config 对这个字段给出候选项
|
||||
|
||||
结论:
|
||||
|
||||
- 保持布尔字段即可
|
||||
|
||||
## 3. 重点结论
|
||||
|
||||
- `preparation.code` 是标准的初始化候选项字段,最适合迁到 `field_config`。
|
||||
- `approval_status` 不需要在本领域单独发明枚举,应直接接入通用审批体系。
|
||||
- `is_exist` 是布尔存在性字段,不在本轮字段级配置重点内。
|
||||
|
||||
## 4. 待修改项清单
|
||||
|
||||
1. 将 `preparation.code` 正式迁到 `field_config`。
|
||||
2. 区分“节点编码范围”与“审批状态/存在性”这两类字段,不要混在一个配置源里。
|
||||
3. 将 `approval_status` 接入通用审批状态定义。
|
||||
|
||||
## 5. 按本轮要求的改造落点
|
||||
|
||||
preparation 属于本轮最适合先收口的一类:
|
||||
|
||||
- 初始化平铺
|
||||
- code 字段明确
|
||||
- 旧代码里已经出现多份硬编码映射
|
||||
|
||||
### 5.1 初始化
|
||||
|
||||
当前 preparation 初始化直接来自 `preparation_{project_type}` dict。
|
||||
|
||||
同时本地还存在:
|
||||
|
||||
- `PreparationCode` enum
|
||||
- `name_to_code`
|
||||
- `dict_to_preparation_code_name`
|
||||
- `list_to_preparation_code_name`
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- 把 `preparation.code` 迁到 field config
|
||||
- 初始化改成通过 config 工具读取候选项再批量创建
|
||||
- 不再依赖 dict 作为初始化真源
|
||||
|
||||
### 5.2 Create 校验
|
||||
|
||||
当前 `PreparationCreate` / `PreparationService` 没有统一的 code 白名单校验入口。
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- 在 create / update 对应的 service 入口开头,基于 config 校验 `preparation.code`
|
||||
- 不再新增本地 code 白名单或名称反查表来兜底
|
||||
|
||||
### 5.3 下拉列表
|
||||
|
||||
preparation 当前没有单独的“节点编码下拉接口”,前端更多拿的是项目实例列表 `/preparation/list`。
|
||||
|
||||
这轮文档上的要求是:
|
||||
|
||||
- 如果后续需要独立 code 候选项下拉,应直接从 `preparation.code` field config 返回
|
||||
- 不再把 `list_to_preparation_code_name` 当成前端下拉真源
|
||||
|
||||
### 5.4 清理项
|
||||
|
||||
preparation 领域本轮改造完成后,应优先清理:
|
||||
|
||||
- `preparation_{project_type}` dict 的初始化真源职责
|
||||
- `PreparationCode` 相关重复映射表
|
||||
- 列表排序里写死的 `sort_code`
|
||||
|
||||
排序如果仍需要固定顺序,应迁到 field config 的附属字段中表达,例如 `weight` 或 `sort`。
|
||||
|
||||
## 6. 测试关注点
|
||||
|
||||
preparation 领域 service 层集成测试至少覆盖:
|
||||
|
||||
1. 初始化:项目初始化时生成的节点是否来自目标 field config。
|
||||
2. Create / Update:合法 `code` 可以写入,非法 `code` 明确报错。
|
||||
3. 列表:项目实例列表顺序是否来自配置顺序,而不是硬编码 `sort_code`。
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Production 领域重构指南
|
||||
|
||||
## 1. 范围
|
||||
|
||||
本文档对应 backend 的 `production` 投产节点表。
|
||||
|
||||
当前优先关注:
|
||||
|
||||
- `production.code`
|
||||
- `production.approval_status`
|
||||
- `production.is_exist`
|
||||
|
||||
## 2. 字段盘点
|
||||
|
||||
### 2.1 `code`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `String`
|
||||
- 没有本地 enum 类
|
||||
- 项目初始化时,节点集合来自 `production_{project_type}` 字典数据
|
||||
- 同步 schema `project_extensions/production.py` 里也是 `str`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前无法从单一 enum 文件列出完整范围
|
||||
- 已知典型值包括:`SYZDSD`、`SPBW`、`QBBW`、`KHWC240`、`YJSC`、`DBTC`、`CQZS`、`WGJS`、`JGJS`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧 `enum_config.Production.code` 已把该字段按项目类型列成正式候选项
|
||||
- 本地仍主要靠初始化字典和自由字符串
|
||||
|
||||
结论:
|
||||
|
||||
- 更适合迁到 `field_config`
|
||||
|
||||
### 2.2 `approval_status`
|
||||
|
||||
当前实现:
|
||||
|
||||
- backend 依赖 `ApprovalMixin`
|
||||
- 本地 `ProductionApproval` schema 仍使用 int
|
||||
- 同步 schema `ProductionDetail` 里是 `Optional[str]`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前本地未在 production 模块中沉成独立 enum
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 同步 schema 有字段,但没有正式 enum
|
||||
|
||||
结论:
|
||||
|
||||
- 应统一接入通用审批状态体系
|
||||
|
||||
### 2.3 `is_exist`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `Boolean`
|
||||
- backend 和 sync schema 都有
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- `true`
|
||||
- `false`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 当前没看到 enum_config 对该字段给候选项
|
||||
|
||||
结论:
|
||||
|
||||
- 保持布尔字段即可
|
||||
|
||||
## 3. 重点结论
|
||||
|
||||
- `production.code` 和 `preparation.code` 一样,属于典型的初始化节点编码字段,应迁到 `field_config`。
|
||||
- `approval_status` 仍应走通用审批体系,而不是 production 自己维护一套值域。
|
||||
- `is_exist` 不属于本轮需要抽成字段候选表的重点字段。
|
||||
|
||||
## 4. 待修改项清单
|
||||
|
||||
1. 将 `production.code` 正式迁到 `field_config`。
|
||||
2. 将项目初始化里对 `production_{project_type}` 的依赖逐步收敛到统一字段配置入口。
|
||||
3. 将 `approval_status` 接入通用审批状态定义。
|
||||
|
||||
## 5. 按本轮要求的改造落点
|
||||
|
||||
production 和 preparation 一样,属于本轮适合优先收口的平铺型节点编码领域。
|
||||
|
||||
### 5.1 初始化
|
||||
|
||||
当前 production 初始化直接来自 `production_{project_type}` dict。
|
||||
|
||||
同时本地还存在:
|
||||
|
||||
- `ProductionCode` enum
|
||||
- `dict_to_production_code_name`
|
||||
- `list_to_production_code_name`
|
||||
- 列表排序里的 `sort_code`
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- 把 `production.code` 迁到 field config
|
||||
- 初始化改成通过 config 工具读取候选项后批量创建
|
||||
- 不再依赖 dict 作为初始化真源
|
||||
|
||||
### 5.2 Create 校验
|
||||
|
||||
当前 production 领域也没有统一的 code 候选项校验入口。
|
||||
|
||||
本轮目标是:
|
||||
|
||||
- 在 create / update 的 service 入口开头,基于 config 校验 `production.code`
|
||||
- 不再继续用本地硬编码列表承担合法值白名单职责
|
||||
|
||||
### 5.3 下拉列表
|
||||
|
||||
production 当前也没有单独的 code 候选项下拉接口,更多是返回项目实例列表 `/production/list`。
|
||||
|
||||
本轮要求是:
|
||||
|
||||
- 如果需要单独 code 候选项下拉,应直接从 `production.code` field config 返回
|
||||
- 不再把 `list_to_production_code_name` 当成前端下拉真源
|
||||
|
||||
### 5.4 清理项
|
||||
|
||||
production 领域本轮改造完成后,应优先清理:
|
||||
|
||||
- `production_{project_type}` dict 的初始化真源职责
|
||||
- `ProductionCode` 相关重复映射和展示列表
|
||||
- 列表排序中写死的 `sort_code`
|
||||
|
||||
排序如果仍需要固定顺序,也应迁到配置附属字段中表达。
|
||||
|
||||
## 6. 测试关注点
|
||||
|
||||
production 领域 service 层集成测试至少覆盖:
|
||||
|
||||
1. 初始化:项目初始化时生成的投产节点是否来自目标 field config。
|
||||
2. Create / Update:合法 `code` 可以写入,非法 `code` 明确报错。
|
||||
3. 列表:项目实例列表顺序是否来自配置顺序,而不是硬编码 `sort_code`。
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
# Project 领域重构指南
|
||||
|
||||
## 1. 范围
|
||||
|
||||
本文档只做 Project 领域的项目侧梳理。
|
||||
|
||||
必须覆盖两类字段:
|
||||
|
||||
- 集团侧 `enum_config` 对应到 Project 的字段:`project_type`、`project_subtype`、`syxs`、`status`
|
||||
- depm 本地已经做成 enum / 类 enum,或者已经承担初始化、创建校验、业务分支职责的字段:`progress_type`、`key_constraints`、`build_methods`
|
||||
|
||||
本文重点只回答四件事:
|
||||
|
||||
- 当前有几种实现
|
||||
- 模型层怎么存
|
||||
- 是否真的用到了
|
||||
- 创建、校验、初始化时怎么取值,以及下一步怎么改
|
||||
|
||||
## 2. 字段盘点
|
||||
|
||||
### 2.1 `project_type`
|
||||
|
||||
当前实现:
|
||||
|
||||
- `app/common/enums.py` 有一套 `ProjectType`
|
||||
- `app/modules/project/enums.py` 又有一套 `ProjectType`
|
||||
- `Project` 模型里是 `String`
|
||||
- `ProjectCreate` / `ProjectUpdate` 里是 `str`
|
||||
|
||||
当前使用:
|
||||
|
||||
- `ProjectService._init_project_data()` 用它作为初始化路由键,去取 `preparation_{project_type}`、`production_{project_type}`、`power_{project_type}` 等配置
|
||||
- construction 模块的部分接口和工具也直接复用 `ProjectType`
|
||||
|
||||
当前问题:
|
||||
|
||||
- 定义重复
|
||||
- 模型层不是 enum
|
||||
- 创建 / 更新入口没有真正按 enum 校验
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前代码里的正式候选项是:`photovoltaic`、`onshore_wind`、`offshore_wind`、`pumped_storage`、`hydropower`、`thermal_power`、`gas_turbine`
|
||||
- `app/common/enums.py`、`app/modules/project/enums.py`、初始化 `dict_data.csv` 当前三处是一致的
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 当前未看到和项目侧不同的取值项
|
||||
- 这个字段目前可视为两边一致
|
||||
- 但同步 schema 里的 `project_type` 字段当前仍写成 `str`,并没有直接收紧成 `ProjectType`
|
||||
|
||||
结论:
|
||||
|
||||
- 保留为代码 enum
|
||||
- 它是核心路由字段,不迁到 `field_config`
|
||||
|
||||
### 2.2 `project_subtype`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `String`
|
||||
- `ProjectCreate` / `ProjectUpdate` 里是 `str`
|
||||
- 没看到本地正式 enum
|
||||
|
||||
当前使用:
|
||||
|
||||
- 当前主要是项目主表字段
|
||||
- 在 Project 模块内没看到稳定初始化使用点
|
||||
|
||||
当前问题:
|
||||
|
||||
- 没有统一候选项来源
|
||||
- 没有统一校验
|
||||
- 现在只是“字符串字段”
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 目前列不出权威范围
|
||||
- 当前项目侧没有正式 enum、没有 `field_config`、没有 schema 白名单
|
||||
- 从代码看,它现在理论上就是任意字符串
|
||||
|
||||
项目侧可举例范围:
|
||||
|
||||
- 目前只能借集团侧样例来猜测,例如光伏下可能是 `centralized`、`distributed`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧至少给出了光伏下的两个候选项:`centralized`、`distributed`
|
||||
- 项目侧目前一个正式值都没有落下来
|
||||
- 其它项目类型在集团侧也基本还是空占位,所以当前只能确认“项目侧缺正式范围,集团侧只给了局部样例”
|
||||
- 同步 schema 对应字段名是 `sub_type`,当前类型是 `Optional[str]`,也还没有 enum 化
|
||||
|
||||
困难:
|
||||
|
||||
- 项目侧没有正式定义源,无法从代码中反推出完整候选项集合
|
||||
- 集团侧除光伏外也没有给出有效值,导致现在只能列出局部范围,不能写成全项目类型完整表
|
||||
|
||||
结论:
|
||||
|
||||
- 改为 `field_config`
|
||||
- 建议 key:`project.project_subtype`
|
||||
|
||||
### 2.3 `syxs`
|
||||
|
||||
当前实现:
|
||||
|
||||
- 模型里是 `String`
|
||||
- 当前 `ProjectCreate` / `ProjectUpdate` 里没有这个字段
|
||||
- 没看到本地正式 enum
|
||||
|
||||
当前使用:
|
||||
|
||||
- 在 Project 模块内暂时没看到明确创建、初始化或校验使用点
|
||||
- 当前更像是“模型里已有字段,但项目侧链路尚未收敛”的状态
|
||||
|
||||
当前问题:
|
||||
|
||||
- 有字段,但没有统一入口
|
||||
- 没有统一候选项来源
|
||||
- 没有统一校验
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 目前列不出权威范围
|
||||
- 项目模型里有这个字段,但 Project 创建 schema 里没有该字段
|
||||
- 代码中也没找到项目侧正式 enum 或统一校验入口
|
||||
|
||||
项目侧可举例范围:
|
||||
|
||||
- 当前只能参考集团侧海风场景,例如 `hssy`、`lssy`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧至少给出了海风下的两个候选项:`hssy`、`lssy`
|
||||
- 项目侧目前还没有正式范围、正式入口和正式校验
|
||||
- 其它项目类型在集团侧也基本还是空占位,所以当前差异主要是“集团侧有局部定义,项目侧还没落下来”
|
||||
- 同步 schema 中 `syxs` 当前也是 `Optional[str]`,还没有 enum 化
|
||||
|
||||
困难:
|
||||
|
||||
- 项目侧没有正式输入入口,也没有权威候选项定义
|
||||
- 集团侧只有海风场景给了有效值,因此暂时只能列局部范围
|
||||
|
||||
结论:
|
||||
|
||||
- 改为 `field_config`
|
||||
- 建议 key:`project.syxs`
|
||||
|
||||
### 2.4 `status`
|
||||
|
||||
当前实现:
|
||||
|
||||
- `app/modules/project/enums.py` 里有 `ProjectStatus`
|
||||
- `app/common/enums.py` 里也有一套 `ProjectStatus`
|
||||
- 模型里是 `String`
|
||||
- 响应 schema 里是 `str`
|
||||
|
||||
当前使用:
|
||||
|
||||
- `ProjectService.change_project_status()` 会根据 `investment_decision_date`、`actual_construction_date`、`actual_production_date`、`actual_full_production_date` 计算并写回 `status`
|
||||
|
||||
当前问题:
|
||||
|
||||
- 定义重复
|
||||
- 模型层不是 enum
|
||||
- 当前状态值由服务逻辑推导,但接口层没有统一约束
|
||||
- 当前正式状态集合必须补上 `early`
|
||||
- 当前库里还存在 `NULL`,这说明状态机和历史数据都还没收口干净
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 目标正式值应为:`early`、`preparation`、`started`、`partially_connected`、`completed`
|
||||
- 当前代码定义和服务推导还没有完整覆盖这 5 个值
|
||||
- 当前实际数据里还存在 `NULL`,这应视为待清理异常值,而不是正式取值
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 项目侧最终也要纳入 `early`
|
||||
- 因此这个字段的目标差异不是“是否要 early”,而是“项目侧何时补齐 early 并清掉 NULL”
|
||||
- 同步 schema 里的 `status` 当前也是 `Optional[str]`,没有正式 enum 类型
|
||||
|
||||
结论:
|
||||
|
||||
- 保留为代码 enum
|
||||
- 正式状态集合按 `early`、`preparation`、`started`、`partially_connected`、`completed` 收口
|
||||
- `NULL` 不是正式值,必须通过数据修复和状态回填消除
|
||||
|
||||
### 2.5 `progress_type`
|
||||
|
||||
当前实现:
|
||||
|
||||
- `app/common/enums.py` 和 `app/modules/project/enums.py` 都定义了 `ProgressType`
|
||||
- 模型里是 `SQLEnum(ProgressType)`
|
||||
- `ProjectInfoResponse` 里使用 `ProgressType`
|
||||
- `ProjectCreate` 里仍是 `str`
|
||||
|
||||
当前使用:
|
||||
|
||||
- 是 Project 主表正式字段
|
||||
- `project_detail_data` 初始化时会写入 `progress_type`
|
||||
- 列表筛选也会直接按它查询
|
||||
|
||||
当前问题:
|
||||
|
||||
- 定义重复
|
||||
- 模型层已收紧,但创建入口还没收紧
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前正式值是:`A`、`B`、`C`
|
||||
- `app/common/enums.py`、`app/modules/project/enums.py`、初始化 `dict_data.csv` 当前是一致的
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧同步 schema 中,`progress_type` 也是正式 enum
|
||||
- 同步 schema 的取值项是:`A`、`B`、`C`
|
||||
- 这一点和项目侧代码 enum 一致
|
||||
- 当前真正的差异不在取值范围,而在项目侧创建入口仍是 `str`,还没有像同步 schema 一样收紧到 enum 类型
|
||||
|
||||
困难:
|
||||
|
||||
- 这个字段属于 depm 本地已枚举化字段,不是集团侧 `Project` 段直出字段
|
||||
- 因此这里只能写“项目侧权威范围明确,集团侧暂无直连字段”
|
||||
|
||||
结论:
|
||||
|
||||
- 保留为代码 enum
|
||||
- 不迁到 `field_config`
|
||||
|
||||
### 2.6 `key_constraints`
|
||||
|
||||
当前实现:
|
||||
|
||||
- `app/common/enums.py` 和 `app/modules/project/enums.py` 都定义了 `KeyConstraints`
|
||||
- 两套定义已分叉:common 里有 `OTHER`,project 模块里没有
|
||||
- 模型里是 `SQLEnum(KeyConstraints)`
|
||||
- `ProjectInfoResponse` 里使用 `KeyConstraints`
|
||||
- `ProjectSquareUpdate` 里还是 `str`
|
||||
|
||||
当前使用:
|
||||
|
||||
- 是 Project 主表正式字段
|
||||
- `project_detail_data` 初始化时会和 `progress_type` 一起写入
|
||||
- `project_detail_data` 审批回写时也会更新 Project 主表
|
||||
|
||||
当前问题:
|
||||
|
||||
- 定义分叉
|
||||
- 主表是 enum,但部分接口入参仍是字符串
|
||||
- 项目侧正式口径应保留 `OTHER`
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 项目侧正式值应为:`NONE`、`LAND`、`TRANSMISSION`、`BOTH`、`OTHER`
|
||||
- 当前 `app/common/enums.py` 和初始化 `dict_data.csv` 已包含这 5 个值
|
||||
- 当前 `app/modules/project/enums.py` 还少 `OTHER`
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧同步 schema 中,`key_constraints` 也是正式 enum
|
||||
- 同步 schema 的取值项是:`NONE`、`LAND`、`TRANSMISSION`、`BOTH`、`QT`
|
||||
- 项目侧正式口径保留 `OTHER`
|
||||
- 这里需要备注的差异只有一条:集团侧用 `QT`,项目侧用 `OTHER`
|
||||
|
||||
结论:
|
||||
|
||||
- 保留为代码 enum
|
||||
- 项目侧按 `OTHER` 收口
|
||||
- 在同步和推送链路上单独备注 `OTHER/QT` 差异
|
||||
- 同时补齐 project 模块那套缺失的 `OTHER`
|
||||
|
||||
### 2.7 `build_methods`
|
||||
|
||||
当前实现:
|
||||
|
||||
- `app/modules/project/enums.py` 里有 `BuildMethod`
|
||||
- 模型里是 `String`
|
||||
- `ProjectCreate` 里是 `str`
|
||||
- 响应 schema 通过 `enum_to_build_method_name` 做名称映射
|
||||
|
||||
当前使用:
|
||||
|
||||
- construction 服务里确实使用了 `project.build_methods`
|
||||
- 它会影响施工分支判断,例如 `power`、`mixed`、`none`、`self`
|
||||
|
||||
当前问题:
|
||||
|
||||
- 模型层不是 enum
|
||||
- 创建入口不是 enum
|
||||
- 已经参与业务分支,但没有统一校验
|
||||
|
||||
项目侧取值范围:
|
||||
|
||||
- 当前代码 enum 给出的值是:`self`、`power`、`mixed`、`none`
|
||||
- construction 分支判断当前也确实在使用这 4 个值
|
||||
|
||||
集团侧差异:
|
||||
|
||||
- 集团侧同步 schema 里没有 `build_methods` 这个名字,对应字段是 `scgc_type`
|
||||
- `scgc_type` 当前在同步 schema 里是 `Optional[str]`
|
||||
- 目前没有看到同步 schema 中为 `scgc_type` 定义正式 enum
|
||||
|
||||
也就是说:
|
||||
|
||||
- 语义上两边大概率是在说同一个字段
|
||||
- 但集团侧当前还没有把它收紧成正式 enum
|
||||
- 因此这个字段更像“项目侧本地先有 enum,集团侧 schema 还停留在字符串字段”
|
||||
|
||||
困难:
|
||||
|
||||
- 这是项目侧本地业务字段,不是集团侧 `Project` 段直出字段
|
||||
- 项目侧虽然已有 enum,但模型和 schema 还没完全收紧
|
||||
|
||||
结论:
|
||||
|
||||
- 保留为代码 enum
|
||||
- 不迁到 `field_config`
|
||||
- 需要把模型层和 schema 层一起收紧
|
||||
|
||||
## 3. 重点结论
|
||||
|
||||
- `project_type` 是初始化路由键,必须保留为代码 enum。
|
||||
- `project_subtype`、`syxs` 是典型的按项目类型变化字段,应迁到 `field_config`。
|
||||
- `progress_type`、`key_constraints`、`build_methods` 都已经在项目侧真实使用,不是可忽略字段;它们应保留为代码 enum,但当前实现没有收紧干净。
|
||||
- `status` 也应保留为代码 enum,正式集合必须包含 `early`,并且要清掉历史 `NULL`。
|
||||
- `key_constraints` 项目侧正式口径保留 `OTHER`,只需要在文档和同步链路上备注集团侧使用 `QT`。
|
||||
|
||||
## 4. 待修改项清单
|
||||
|
||||
1. 统一 `ProjectType` 定义,只保留一套正式 enum。
|
||||
2. 统一 `ProjectStatus` 定义,并把 `early` 纳入正式状态集合。
|
||||
3. 统一 `ProgressType` 定义,只保留一套正式 enum。
|
||||
4. 统一 `KeyConstraints` 定义,项目侧正式保留 `OTHER`。
|
||||
5. 将 `project.project_subtype` 改为字段级配置,放入 `field_config`。
|
||||
6. 将 `project.syxs` 改为字段级配置,放入 `field_config`。
|
||||
7. 为 `project_type` 在 schema 层增加正式 enum 校验,不再使用裸 `str`。
|
||||
8. 为 `progress_type` 在创建入口增加正式 enum 校验。
|
||||
9. 为 `build_methods` 在 schema 层增加正式 enum 校验。
|
||||
10. 为 `key_constraints` 在仍使用字符串入参的接口上补 enum 校验。
|
||||
11. 清理 `Project.status` 的历史 `NULL` 数据,并补状态回填逻辑,确保 `NULL` 不再出现。
|
||||
12. 评估 `Project` 模型中的 `project_type`、`status`、`build_methods` 是否要收紧为数据库 enum;如果暂不收紧,也要先统一 schema 校验。
|
||||
13. 为 `project_subtype`、`syxs` 增加统一读取入口:`get_field_options()` / `validate_field_value()`。
|
||||
14. 明确 `status` 的唯一来源:是继续由 `change_project_status()` 推导,还是允许手动写入。
|
||||
|
||||
## 5. 推荐落地顺序
|
||||
|
||||
1. 先统一 `ProjectType`、`ProgressType`、`KeyConstraints`、`ProjectStatus` 的定义源。
|
||||
2. 再补 schema 层校验,把 `project_type`、`progress_type`、`build_methods`、`key_constraints` 的裸字符串入口收紧。
|
||||
3. 先补 `status=early` 和历史 `NULL` 清理,再把 `project_subtype`、`syxs` 迁到 `field_config`。
|
||||
|
||||
## 6. 按本轮要求的改造落点
|
||||
|
||||
project 领域这轮要区分两类字段:
|
||||
|
||||
- 固定语义枚举:继续保留代码 enum
|
||||
- 按项目类型变化的候选项:迁到 field config
|
||||
|
||||
### 6.1 初始化
|
||||
|
||||
Project 本身不直接批量初始化子表候选项,但 `project_type` 是整个初始化流程的路由键。
|
||||
|
||||
因此本轮在 project 领域的初始化要求是:
|
||||
|
||||
- `project_type` 继续保留代码 enum,作为 config 路由键
|
||||
- `project_subtype`、`syxs` 如果进入初始化或默认值链路,应直接从 field config 读取
|
||||
|
||||
### 6.2 Create / Update 校验
|
||||
|
||||
这轮 project 领域要分开处理:
|
||||
|
||||
- `project_type`、`status`、`progress_type`、`key_constraints`、`build_methods`:继续按代码 enum 收紧
|
||||
- `project_subtype`、`syxs`:通过新的 config 工具校验
|
||||
|
||||
也就是说,project 领域不是“全部配置化”,而是“固定 enum 和 field config 分流后分别收紧”。
|
||||
|
||||
### 6.3 下拉列表
|
||||
|
||||
当前 project 领域里:
|
||||
|
||||
- `project_type`、`progress_type`、`key_constraints`、`build_methods` 更适合继续由代码 enum 提供候选项
|
||||
- `project_subtype`、`syxs` 后续如果提供前端下拉,应直接走 field config
|
||||
|
||||
本轮目标是明确边界,避免把本地固定枚举误迁到 config,也避免把按项目类型变化字段继续留成自由字符串。
|
||||
|
||||
### 6.4 清理项
|
||||
|
||||
project 领域本轮改造完成后,应清理:
|
||||
|
||||
- 重复的 enum 定义源
|
||||
- 仍以裸字符串接收固定 enum 字段的入口
|
||||
- `status` 历史 `NULL` 值
|
||||
|
||||
## 7. 测试关注点
|
||||
|
||||
project 领域 service 层集成测试至少覆盖:
|
||||
|
||||
1. `project_type`、`progress_type`、`key_constraints`、`build_methods` 的合法/非法值校验。
|
||||
2. `project_subtype`、`syxs` 的 field config 读取与校验。
|
||||
3. `status` 状态推导补齐 `early` 且不再产生 `NULL`。
|
||||
@@ -0,0 +1,189 @@
|
||||
# 测试指南
|
||||
|
||||
本文档说明三件事:
|
||||
- 单元测试怎么跑。
|
||||
- 集成测试怎么组织。
|
||||
- 测试辅助工具如何使用,尤其是远程 ECM 环境初始化工具。
|
||||
|
||||
## 1. 测试分层
|
||||
|
||||
### 1.1 单元测试
|
||||
|
||||
位置:`tests/unit/`
|
||||
|
||||
目标:
|
||||
- 校验状态机不变量、事件约束、策略辅助逻辑。
|
||||
- 校验 datasource/handler 工具链、配置严格性、导入面稳定性。
|
||||
- 对具体 domain 的 update/create 行为做快速回归。
|
||||
|
||||
常用命令:
|
||||
- 全量单元测试:`pytest tests/unit -q`
|
||||
- 指定文件:`pytest tests/unit/test_update_ops.py -q`
|
||||
|
||||
适用场景:
|
||||
- 修改 `sync_state_machine/` 公共逻辑。
|
||||
- 修改某个 domain 的 handler/strategy。
|
||||
- 校验 schema 相关逻辑没有引入行为回退。
|
||||
|
||||
### 1.2 集成测试
|
||||
|
||||
位置:`tests/integration/`
|
||||
|
||||
目标:
|
||||
- 覆盖 pipeline 级行为,包括 load/bind/create/update/poll/persist。
|
||||
- 校验多节点、多轮次、mock datasource 与 UI smoke 场景。
|
||||
- 验证运行配置、状态机配置和工具链连通性。
|
||||
|
||||
常用命令:
|
||||
- 全量集成测试:`pytest tests/integration -q`
|
||||
- UI 烟测:`pytest tests/integration/test_ui_api_smoke.py -q`
|
||||
- 工具链专项:`pytest tests/integration/test_toolchain_config_and_graph.py -q`
|
||||
|
||||
适用场景:
|
||||
- 修改 pipeline、collection、状态机执行路径。
|
||||
- 修改持久化或跨 domain 依赖行为。
|
||||
- 修改运行配置解析、注册表装配等入口逻辑。
|
||||
|
||||
### 1.3 脚本化大规模回归
|
||||
|
||||
位置:`scripts/auto_test_domains/`
|
||||
|
||||
目标:
|
||||
- 用“多轮数据集 + 报告”的方式覆盖复杂业务 domain。
|
||||
- 适合真实接口联调、回归复现、差异收敛分析。
|
||||
|
||||
特点:
|
||||
- 这类脚本不直接纳入 pytest。
|
||||
- 它们更偏向测试编排与问题定位,而不是轻量级快速回归。
|
||||
|
||||
相关文档:
|
||||
- `docs/auto_test_spec/agent_auto_test_method.md`
|
||||
|
||||
## 2. 远程环境初始化工具
|
||||
|
||||
位置:`tools/remote_env.py`
|
||||
|
||||
这个工具是给真实 ECM 环境联调用的最小控制器,提供 4 个命令:
|
||||
- `clear`:停止服务、恢复 `.env`、清空自动测试专用 CouchDB 数据库。
|
||||
- `init`:停止服务,按 JSONL 数据目录执行 `run.py restore-database` 恢复测试数据库。
|
||||
- `run`:启动远程 ECM 服务,不阻塞当前命令。
|
||||
- `stop`:关闭远程 ECM 服务,并恢复原始 `.env`。
|
||||
|
||||
它不是 pipeline 执行器,只负责测试环境的基础生命周期管理。
|
||||
|
||||
### 2.1 配置项
|
||||
|
||||
示例配置:`tools/remote_env.example.yaml`
|
||||
|
||||
核心配置:
|
||||
- `couchdb.base_url` / `couchdb.database` / `couchdb.username` / `couchdb.password`
|
||||
- `service.ecm_root`
|
||||
- `service.python_path`
|
||||
- `data.data_dir`
|
||||
- `env_file.path`
|
||||
- `env_file.overrides`
|
||||
|
||||
按命令的最低要求:
|
||||
- `run` / `stop`:只要求 `service.*` 可用。
|
||||
- `clear`:要求 `service.*` 和 `couchdb.*` 可用。
|
||||
- `init`:要求 `service.*`、`couchdb.*`、`data.*` 全部可用。
|
||||
|
||||
可选配置:
|
||||
- `service.start_command`
|
||||
- `service.restore_command`
|
||||
- `service.ready_url`
|
||||
- `service.log_file`
|
||||
- `service.env`
|
||||
- `env_file.restore_on_stop`
|
||||
|
||||
支持占位符:
|
||||
- `${PROJECT_ROOT}`:当前仓库根目录。
|
||||
- `${CONFIG_DIR}`:配置文件所在目录。
|
||||
- `${ECM_ROOT}`:`service.ecm_root` 的值。
|
||||
- `${PYTHON}`:`service.python_path` 的值。
|
||||
- `${DATA_DIR}`:`data.data_dir` 的值。
|
||||
- `${COUCHDB_DATABASE}`:`couchdb.database` 的值。
|
||||
- `${COUCHDB_USERNAME}`:`couchdb.username` 的值。
|
||||
- `${COUCHDB_PASSWORD}`:`couchdb.password` 的值。
|
||||
- `${COUCHDB_URL}`:`couchdb.url` 的值。
|
||||
- `${COUCHDB_PORT}`:`couchdb.port` 的值。
|
||||
|
||||
### 2.2 基本用法
|
||||
|
||||
1. 复制示例配置并填入真实路径。
|
||||
2. 先执行 `init` 恢复测试数据库。
|
||||
3. 再执行 `run` 启动远程服务。
|
||||
4. 测试结束后执行 `stop`。
|
||||
|
||||
命令示例:
|
||||
|
||||
```bash
|
||||
python tools/remote_env.py --config tools/remote_env.example.yaml init
|
||||
python tools/remote_env.py --config tools/remote_env.example.yaml run
|
||||
python tools/remote_env.py --config tools/remote_env.example.yaml stop
|
||||
python tools/remote_env.py --config tools/remote_env.example.yaml clear
|
||||
```
|
||||
|
||||
### 2.3 命令行覆盖
|
||||
|
||||
如果只想临时覆盖少数字段,可以直接传命令行参数:
|
||||
|
||||
```bash
|
||||
python tools/remote_env.py \
|
||||
--config tools/remote_env.example.yaml \
|
||||
--db-name test_push_auto_tmp \
|
||||
--ecm-root /data/ecm_app \
|
||||
--python-path /data/venv/bin/python \
|
||||
init
|
||||
```
|
||||
|
||||
支持覆盖的字段:
|
||||
- `--db-name`
|
||||
- `--db-user`
|
||||
- `--db-password`
|
||||
- `--couchdb-base-url`
|
||||
- `--couchdb-url`
|
||||
- `--couchdb-port`
|
||||
- `--ecm-root`
|
||||
- `--python-path`
|
||||
- `--data-dir`
|
||||
- `--ready-url`
|
||||
|
||||
### 2.4 行为约束
|
||||
|
||||
- `clear` 和 `init` 会先执行停服,避免服务仍占用 CouchDB 连接。
|
||||
- `init` 不会自动执行 `run`;数据库恢复和服务启动分开控制。
|
||||
- `run` 如果检测到远端仓库下已有 `run.py` 进程在运行,会直接返回 `already_running`。
|
||||
- `.env` 只覆盖 `env_file.overrides` 中声明的键,未声明的键会原样保留。
|
||||
- 第一次覆盖 `.env` 时会自动备份,`stop` 和 `clear` 默认会恢复原始 `.env`。
|
||||
- 进程状态写到 `runtime/remote_env/state.json`,不需要手工配置 pid 文件。
|
||||
|
||||
## 3. 推荐测试流程
|
||||
|
||||
### 3.1 本地开发回归
|
||||
|
||||
1. 先跑 `pytest tests/unit -q`。
|
||||
2. 如果改动影响 pipeline,再跑目标集成测试。
|
||||
3. 如果改动影响真实接口流程,再使用 `tools/remote_env.py` 管理远程环境。
|
||||
|
||||
### 3.2 真实接口联调
|
||||
|
||||
1. 用 `init` 恢复 ECM 测试库。
|
||||
2. 用 `run` 拉起 ECM 服务。
|
||||
3. 执行同步脚本或联调脚本。
|
||||
4. 查看 `runtime/remote_env/` 下的状态文件与日志。
|
||||
5. 完成后执行 `stop`。
|
||||
|
||||
### 3.3 复杂 domain 回归
|
||||
|
||||
1. 用 `scripts/auto_test_domains/` 中的 case 脚本准备多轮数据。
|
||||
2. 先缩小到单个 domain 复现。
|
||||
3. 确认差异与日志后,再扩大到聚合回归。
|
||||
|
||||
## 4. 相关文件
|
||||
|
||||
- `tests/README.md`
|
||||
- `tools/remote_env.py`
|
||||
- `tools/remote_env.example.yaml`
|
||||
- `docs/operations.md`
|
||||
- `docs/auto_test_spec/agent_auto_test_method.md`
|
||||
@@ -70,7 +70,7 @@ class ProjectResponseBase(BaseResponseWithID):
|
||||
complete_investment: float = Field(0.0, description="完成投资", examples=[80000.0])
|
||||
|
||||
# 其他信息
|
||||
progress_type: ProgressType = Field(..., description="推进分类", examples=[ProgressType.A])
|
||||
progress_type: ProgressType | None = Field(None, description="推进分类", examples=[ProgressType.A])
|
||||
key_constraints: KeyConstraints = Field(KeyConstraints.NONE, description="关键制约", examples=[KeyConstraints.NONE])
|
||||
key_constraints_remark: Optional[str] = Field("", description="关键制约备注", examples=["无"])
|
||||
status: Optional[str] = Field(None, description="项目状态")
|
||||
@@ -304,6 +304,7 @@ class ProjectSettlementInvestment(BaseModel):
|
||||
"""
|
||||
PROJECT_SETTLEMENT_INVESTMENT
|
||||
合同结算投资情况
|
||||
只有抽蓄-水电-煤电-气电项目类型可操作
|
||||
"""
|
||||
|
||||
implementation_estimate: float = Field(..., description="执行概算", examples=[80000.0])
|
||||
|
||||
@@ -38,7 +38,7 @@ class ApiDataSourceConfig(BaseDataSourceConfig):
|
||||
poll_interval: float = 0.5
|
||||
api_rate_limit_max_requests: int = 10
|
||||
api_rate_limit_window_seconds: float = 10.0
|
||||
target_project_ids: List[str] = Field(min_length=1)
|
||||
target_project_ids: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
DataSourceConfig = Annotated[
|
||||
|
||||
@@ -5,7 +5,16 @@ from schemas.project.project_base import ProjectResponseBase
|
||||
|
||||
|
||||
class ProjectSyncSchema(ProjectResponseBase):
|
||||
"""项目同步内部 schema,补充 all_info 扩展字段。"""
|
||||
"""
|
||||
项目同步内部 schema。
|
||||
|
||||
以 project 主表字段为主体,并补充少量从 /project/all_info 的多项目类型
|
||||
extension 中拍平得到的投资/结算相关字段。
|
||||
|
||||
这些字段不是 ProjectResponseBase 原生字段,而是为了让 project 节点在保持
|
||||
扁平存储形态的同时,能够参与 project update diff,以及后续的
|
||||
settlement_investment 等更新接口。
|
||||
"""
|
||||
|
||||
implementation_estimate: float | None = Field(None, description="执行概算")
|
||||
static_total_investment: float | None = Field(None, description="静态总投资")
|
||||
|
||||
@@ -17,7 +17,7 @@ class SupplierSyncStrategy(DefaultSyncStrategy[SupplierDetailResponse]):
|
||||
|
||||
default_config = StrategyConfig(
|
||||
auto_bind=True,
|
||||
auto_bind_fields=["code"],
|
||||
auto_bind_fields=["credit_code"],
|
||||
depend_fields={},
|
||||
local_orphan_action=OrphanAction.NONE,
|
||||
remote_orphan_action=OrphanAction.CREATE_LOCAL,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- 集成:`pytest tests/integration -q`
|
||||
- 工具链专项:`pytest tests/integration/test_toolchain_config_and_graph.py -q`
|
||||
- UI 专项:`pytest tests/integration/test_ui_api_smoke.py -q`
|
||||
- 测试总览与远程环境工具:`docs/testing_guide.md`
|
||||
|
||||
## 设计原则
|
||||
- 不绕过 schema/状态机校验;配置错误必须显式失败。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from sync_state_machine.domain.supplier.sync_strategy import SupplierSyncStrategy
|
||||
|
||||
|
||||
def test_supplier_sync_strategy_defaults_to_credit_code_auto_bind():
|
||||
assert SupplierSyncStrategy.default_config.auto_bind is True
|
||||
assert SupplierSyncStrategy.default_config.auto_bind_fields == ["credit_code"]
|
||||
@@ -0,0 +1,414 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import sqlite3
|
||||
import sys
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
REASON_DESCRIPTIONS = {
|
||||
"hierarchy_shape": "层级结构不一致:一侧是顶层/短路径,另一侧保留了父级、完整 path 或 parent 信息。",
|
||||
"region_enrichment": "地区信息补全程度不一致:远端带国家、省市名称或完整 region_path,本地缺失或更短。",
|
||||
"audit_identity": "审计人字段不一致:远端保留了 created_by/updated_by,本地为空或使用不同来源。",
|
||||
"audit_time_format": "时间格式或更新时间不一致:本地常用 ISO 时间,远端常用空格分隔时间,且更新时间可能已被远端二次维护。",
|
||||
"company_nature_semantics": "company_nature 语义不一致:本地是较保守的单值/少值,远端是人工维护后的多选性质。",
|
||||
"sort_policy": "排序策略不一致:本地默认值或来源字段与远端现存排序值不同。",
|
||||
"null_empty_normalization": "空值归一化不一致:本地用 null,远端用空字符串或空数组。",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersistedNode:
|
||||
collection_id: str
|
||||
node_id: str
|
||||
node_type: str
|
||||
data_id: str | None
|
||||
bind_data_id: str | None
|
||||
action: str | None
|
||||
status: str | None
|
||||
binding_status: str | None
|
||||
error: str | None
|
||||
sync_log: str
|
||||
data: Any
|
||||
origin_data: Any
|
||||
context: Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class BoundPair:
|
||||
node_type: str
|
||||
local_id: str
|
||||
remote_id: str
|
||||
local_node: PersistedNode
|
||||
remote_node: PersistedNode
|
||||
diffs: list[tuple[str, Any, Any]]
|
||||
|
||||
|
||||
def _classify_reasons(pair: BoundPair) -> list[str]:
|
||||
diff_paths = {path for path, _, _ in pair.diffs}
|
||||
reasons: list[str] = []
|
||||
|
||||
if diff_paths & {"parent_id", "parent_name", "path", "path_name"}:
|
||||
reasons.append("hierarchy_shape")
|
||||
|
||||
if diff_paths & {"country", "country_name", "province_name", "city_name", "region_path", "province", "city"}:
|
||||
reasons.append("region_enrichment")
|
||||
|
||||
if diff_paths & {"created_by", "updated_by", "created_by_name", "updated_by_name"}:
|
||||
reasons.append("audit_identity")
|
||||
|
||||
if diff_paths & {"created_at", "updated_at"}:
|
||||
reasons.append("audit_time_format")
|
||||
|
||||
if "company_nature" in diff_paths:
|
||||
reasons.append("company_nature_semantics")
|
||||
|
||||
if "sort" in diff_paths:
|
||||
reasons.append("sort_policy")
|
||||
|
||||
if any(
|
||||
(local_value is None and remote_value in ("", [])) or (remote_value is None and local_value in ("", []))
|
||||
for _, local_value, remote_value in pair.diffs
|
||||
):
|
||||
reasons.append("null_empty_normalization")
|
||||
|
||||
return reasons
|
||||
|
||||
|
||||
def _build_reason_summary(mismatches: list[BoundPair]) -> str:
|
||||
counter: Counter[str] = Counter()
|
||||
for pair in mismatches:
|
||||
counter.update(_classify_reasons(pair))
|
||||
|
||||
if not counter:
|
||||
return "No mismatch reasons inferred."
|
||||
|
||||
lines = ["Likely mismatch reasons:"]
|
||||
for reason, count in counter.most_common():
|
||||
description = REASON_DESCRIPTIONS.get(reason, reason)
|
||||
lines.append(f" - {reason}: {count}")
|
||||
lines.append(f" {description}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_business_conclusion(node_type: str, mismatches: list[BoundPair]) -> str:
|
||||
if not mismatches:
|
||||
return "No mismatched bound pairs found."
|
||||
|
||||
reason_counts: Counter[str] = Counter()
|
||||
for pair in mismatches:
|
||||
reason_counts.update(_classify_reasons(pair))
|
||||
|
||||
if node_type == "company":
|
||||
conclusions = [
|
||||
"company 当前不一致主要不是绑定错,而是本地 company handler 输出更扁平。",
|
||||
]
|
||||
if reason_counts["hierarchy_shape"]:
|
||||
conclusions.append("远端 company 已维护集团层级,本地只保留当前公司自身,导致 parent_id/path/path_name 不一致。")
|
||||
if reason_counts["region_enrichment"]:
|
||||
conclusions.append("远端 company 还维护了省市编码与名称,本地目前未补这些字段。")
|
||||
if reason_counts["sort_policy"]:
|
||||
conclusions.append("sort 来自两套不同维护规则,本地默认值与远端现存排序不一致。")
|
||||
return "\n".join(f"- {item}" for item in conclusions)
|
||||
|
||||
if node_type == "supplier":
|
||||
conclusions = [
|
||||
"supplier 当前大多数绑定能命中,但远端 supplier 是一套人工维护后更完整的数据快照。",
|
||||
]
|
||||
if reason_counts["company_nature_semantics"]:
|
||||
conclusions.append("本地 company_nature 往往只有单值,远端 supplier 常是多选性质,因此语义层面天然不一致。")
|
||||
if reason_counts["region_enrichment"]:
|
||||
conclusions.append("远端 supplier 补了国家层、地区名称和完整 region_path,本地只有编码级最小信息。")
|
||||
if reason_counts["audit_identity"] or reason_counts["audit_time_format"]:
|
||||
conclusions.append("远端保存了创建/更新操作者和远端维护时间,本地导出使用的是 DEPM 审计字段,时间格式也不同。")
|
||||
if reason_counts["hierarchy_shape"] or reason_counts["null_empty_normalization"]:
|
||||
conclusions.append("parent_id/path 的空值和路径表达也不同,本地更偏同步输入形态,远端更偏展示/运营形态。")
|
||||
return "\n".join(f"- {item}" for item in conclusions)
|
||||
|
||||
return "\n".join(f"- {REASON_DESCRIPTIONS.get(reason, reason)}" for reason, _ in reason_counts.most_common())
|
||||
|
||||
|
||||
def _parse_json(value: str | None) -> Any:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def _format_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
def _short_repr(value: Any, *, max_len: int = 160) -> str:
|
||||
text = repr(value)
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def _diff_values(local_value: Any, remote_value: Any, *, path: str = "") -> list[tuple[str, Any, Any]]:
|
||||
diffs: list[tuple[str, Any, Any]] = []
|
||||
|
||||
if type(local_value) is not type(remote_value):
|
||||
diffs.append((path or "<root>", local_value, remote_value))
|
||||
return diffs
|
||||
|
||||
if isinstance(local_value, dict):
|
||||
keys = sorted(set(local_value.keys()) | set(remote_value.keys()))
|
||||
for key in keys:
|
||||
child_path = f"{path}.{key}" if path else str(key)
|
||||
if key not in local_value:
|
||||
diffs.append((child_path, None, remote_value[key]))
|
||||
continue
|
||||
if key not in remote_value:
|
||||
diffs.append((child_path, local_value[key], None))
|
||||
continue
|
||||
diffs.extend(_diff_values(local_value[key], remote_value[key], path=child_path))
|
||||
return diffs
|
||||
|
||||
if isinstance(local_value, list):
|
||||
if local_value != remote_value:
|
||||
diffs.append((path or "<root>", local_value, remote_value))
|
||||
return diffs
|
||||
|
||||
if local_value != remote_value:
|
||||
diffs.append((path or "<root>", local_value, remote_value))
|
||||
return diffs
|
||||
|
||||
|
||||
def _load_nodes(conn: sqlite3.Connection, node_type: str) -> dict[tuple[str, str], PersistedNode]:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT collection_id, node_id, node_type, data_id, bind_data_id, action, status,
|
||||
binding_status, error, sync_log, data, origin_data, context
|
||||
FROM nodes
|
||||
WHERE node_type = ?
|
||||
""",
|
||||
(node_type,),
|
||||
)
|
||||
|
||||
rows: dict[tuple[str, str], PersistedNode] = {}
|
||||
for row in cur.fetchall():
|
||||
node = PersistedNode(
|
||||
collection_id=row[0],
|
||||
node_id=row[1],
|
||||
node_type=row[2],
|
||||
data_id=row[3],
|
||||
bind_data_id=row[4],
|
||||
action=row[5],
|
||||
status=row[6],
|
||||
binding_status=row[7],
|
||||
error=row[8],
|
||||
sync_log=row[9] or "",
|
||||
data=_parse_json(row[10]),
|
||||
origin_data=_parse_json(row[11]),
|
||||
context=_parse_json(row[12]),
|
||||
)
|
||||
rows[(node.collection_id, node.node_id)] = node
|
||||
return rows
|
||||
|
||||
|
||||
def _load_bound_pairs(conn: sqlite3.Connection, node_type: str) -> list[BoundPair]:
|
||||
nodes = _load_nodes(conn, node_type)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT node_type, local_id, remote_id FROM bindings WHERE node_type = ? AND remote_id IS NOT NULL",
|
||||
(node_type,),
|
||||
)
|
||||
|
||||
pairs: list[BoundPair] = []
|
||||
for bound_node_type, local_id, remote_id in cur.fetchall():
|
||||
local_node = nodes.get(("local", local_id))
|
||||
remote_node = nodes.get(("remote", remote_id))
|
||||
if local_node is None or remote_node is None:
|
||||
continue
|
||||
local_data = local_node.data or {}
|
||||
remote_data = remote_node.data or {}
|
||||
diffs = _diff_values(local_data, remote_data)
|
||||
pairs.append(
|
||||
BoundPair(
|
||||
node_type=bound_node_type,
|
||||
local_id=local_id,
|
||||
remote_id=remote_id,
|
||||
local_node=local_node,
|
||||
remote_node=remote_node,
|
||||
diffs=diffs,
|
||||
)
|
||||
)
|
||||
return pairs
|
||||
|
||||
|
||||
def _build_summary(pairs: list[BoundPair]) -> str:
|
||||
total = len(pairs)
|
||||
mismatches = [pair for pair in pairs if pair.diffs]
|
||||
matched = total - len(mismatches)
|
||||
diff_counter: Counter[str] = Counter()
|
||||
for pair in mismatches:
|
||||
for path, _, _ in pair.diffs:
|
||||
diff_counter[path] += 1
|
||||
|
||||
lines = [
|
||||
f"Bound pairs: {total}",
|
||||
f"Matched pairs: {matched}",
|
||||
f"Mismatched pairs: {len(mismatches)}",
|
||||
]
|
||||
if diff_counter:
|
||||
lines.append("Top diff fields:")
|
||||
for field, count in diff_counter.most_common(20):
|
||||
lines.append(f" - {field}: {count}")
|
||||
lines.append(_build_reason_summary(mismatches))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_pair(pair: BoundPair, *, max_diff_items: int) -> str:
|
||||
diff_lines = []
|
||||
for path, local_value, remote_value in pair.diffs[:max_diff_items]:
|
||||
diff_lines.append(
|
||||
f" - {path}: local={_short_repr(local_value)} | remote={_short_repr(remote_value)}"
|
||||
)
|
||||
if len(pair.diffs) > max_diff_items:
|
||||
diff_lines.append(f" - ... {len(pair.diffs) - max_diff_items} more diff fields")
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
f"node_type={pair.node_type}",
|
||||
f"local.node_id={pair.local_node.node_id}",
|
||||
f"local.data_id={pair.local_node.data_id}",
|
||||
f"local.bind_data_id={pair.local_node.bind_data_id}",
|
||||
f"local.action={pair.local_node.action} local.status={pair.local_node.status} local.binding_status={pair.local_node.binding_status}",
|
||||
f"local.error={pair.local_node.error}",
|
||||
f"remote.node_id={pair.remote_node.node_id}",
|
||||
f"remote.data_id={pair.remote_node.data_id}",
|
||||
f"remote.bind_data_id={pair.remote_node.bind_data_id}",
|
||||
f"remote.action={pair.remote_node.action} remote.status={pair.remote_node.status} remote.binding_status={pair.remote_node.binding_status}",
|
||||
f"remote.error={pair.remote_node.error}",
|
||||
"Reason tags:",
|
||||
*(f" - {reason}: {REASON_DESCRIPTIONS.get(reason, reason)}" for reason in _classify_reasons(pair)),
|
||||
"Diff fields:",
|
||||
*diff_lines,
|
||||
"",
|
||||
"Local sync_log:",
|
||||
pair.local_node.sync_log or "<empty>",
|
||||
"",
|
||||
"Remote sync_log:",
|
||||
pair.remote_node.sync_log or "<empty>",
|
||||
"",
|
||||
"Local context:",
|
||||
_format_json(pair.local_node.context),
|
||||
"",
|
||||
"Remote context:",
|
||||
_format_json(pair.remote_node.context),
|
||||
"",
|
||||
"Local data:",
|
||||
_format_json(pair.local_node.data),
|
||||
"",
|
||||
"Remote data:",
|
||||
_format_json(pair.remote_node.data),
|
||||
"",
|
||||
"Local origin_data:",
|
||||
_format_json(pair.local_node.origin_data),
|
||||
"",
|
||||
"Remote origin_data:",
|
||||
_format_json(pair.remote_node.origin_data),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def build_report(
|
||||
*,
|
||||
db_path: Path,
|
||||
node_type: str,
|
||||
limit: int,
|
||||
seed: int,
|
||||
max_diff_items: int,
|
||||
) -> str:
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
pairs = _load_bound_pairs(conn, node_type)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
mismatches = [pair for pair in pairs if pair.diffs]
|
||||
rng = random.Random(seed)
|
||||
sampled = list(mismatches)
|
||||
rng.shuffle(sampled)
|
||||
sampled = sampled[:limit]
|
||||
|
||||
sections = [
|
||||
f"# Persist Mismatch Report\n",
|
||||
f"db_path: {db_path}",
|
||||
f"node_type: {node_type}",
|
||||
f"sample_limit: {limit}",
|
||||
f"seed: {seed}",
|
||||
"",
|
||||
"## Summary",
|
||||
_build_summary(pairs),
|
||||
"",
|
||||
"## Business Conclusion",
|
||||
_build_business_conclusion(node_type, mismatches),
|
||||
]
|
||||
|
||||
if not sampled:
|
||||
sections.extend(["", "## Sampled Mismatches", "No mismatched bound pairs found."])
|
||||
return "\n".join(sections)
|
||||
|
||||
sections.append("")
|
||||
sections.append("## Sampled Mismatches")
|
||||
for index, pair in enumerate(sampled, start=1):
|
||||
sections.extend(
|
||||
[
|
||||
"",
|
||||
f"### Sample {index}",
|
||||
_format_pair(pair, max_diff_items=max_diff_items),
|
||||
]
|
||||
)
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Analyze mismatched bound pairs in a persisted sync SQLite DB")
|
||||
parser.add_argument("db_path", help="Path to persisted sqlite db, e.g. backend/runtime/ecm_sync/.../depm_to_ecm.db")
|
||||
parser.add_argument("--node-type", required=True, help="Node type to analyze, e.g. company or supplier")
|
||||
parser.add_argument("--limit", type=int, default=3, help="Number of mismatched bound pairs to sample")
|
||||
parser.add_argument("--seed", type=int, default=11, help="Random seed for sampling")
|
||||
parser.add_argument("--max-diff-items", type=int, default=20, help="Max diff fields to print per sampled pair")
|
||||
parser.add_argument("--output", help="Optional output file path. If omitted, prints to stdout.")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv or sys.argv[1:])
|
||||
db_path = Path(args.db_path).resolve()
|
||||
if not db_path.exists():
|
||||
print(f"DB file not found: {db_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
report = build_report(
|
||||
db_path=db_path,
|
||||
node_type=args.node_type,
|
||||
limit=max(0, args.limit),
|
||||
seed=args.seed,
|
||||
max_diff_items=max(1, args.max_diff_items),
|
||||
)
|
||||
|
||||
if args.output:
|
||||
output_path = Path(args.output).resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(report, encoding="utf-8")
|
||||
print(f"Report written to: {output_path}")
|
||||
return 0
|
||||
|
||||
print(report)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,44 @@
|
||||
couchdb:
|
||||
enabled: true
|
||||
base_url: http://localhost:5984
|
||||
url: http://127.0.0.1
|
||||
port: "5984"
|
||||
database: test_push_auto
|
||||
username: admin
|
||||
password: password123
|
||||
recreate_after_delete: false
|
||||
|
||||
data:
|
||||
data_dir: ${PROJECT_ROOT}/working_local_datasource/filtered
|
||||
|
||||
service:
|
||||
ecm_root: /Users/zyl/my_projects/GroupSideProjectManagementSystem
|
||||
python_path: /Users/zyl/my_projects/GroupSideProjectManagementSystem/.venv/bin/python
|
||||
cwd: ${ECM_ROOT}
|
||||
start_command:
|
||||
- ${PYTHON}
|
||||
- run.py
|
||||
restore_command:
|
||||
- ${PYTHON}
|
||||
- run.py
|
||||
- restore-database
|
||||
- -d
|
||||
- ${COUCHDB_DATABASE}
|
||||
- -s
|
||||
- ${DATA_DIR}
|
||||
env: {}
|
||||
ready_url: http://localhost:8000/docs
|
||||
ready_timeout_seconds: 120
|
||||
ready_interval_seconds: 2
|
||||
log_file: ${PROJECT_ROOT}/runtime/remote_env/ecm_remote.log
|
||||
|
||||
env_file:
|
||||
enabled: true
|
||||
path: ${ECM_ROOT}/.env
|
||||
restore_on_stop: true
|
||||
overrides:
|
||||
COUCHDB_DBNAME: ${COUCHDB_DATABASE}
|
||||
COUCHDB_USER: ${COUCHDB_USERNAME}
|
||||
COUCHDB_PASSWORD: ${COUCHDB_PASSWORD}
|
||||
COUCHDB_URL: ${COUCHDB_URL}
|
||||
COUCHDB_PORT: ${COUCHDB_PORT}
|
||||
@@ -0,0 +1,699 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from urllib import error, request
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
STATE_DIR = PROJECT_ROOT / "runtime" / "remote_env"
|
||||
STATE_FILE = STATE_DIR / "state.json"
|
||||
|
||||
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"couchdb": {
|
||||
"enabled": True,
|
||||
"base_url": "http://localhost:5984",
|
||||
"url": "http://127.0.0.1",
|
||||
"port": "5984",
|
||||
"database": "test_push_auto",
|
||||
"username": "admin",
|
||||
"password": "password123",
|
||||
"recreate_after_delete": False,
|
||||
},
|
||||
"data": {
|
||||
"data_dir": "${PROJECT_ROOT}/working_local_datasource/filtered",
|
||||
},
|
||||
"service": {
|
||||
"ecm_root": "/Users/zyl/my_projects/GroupSideProjectManagementSystem",
|
||||
"python_path": "/Users/zyl/my_projects/GroupSideProjectManagementSystem/.venv/bin/python",
|
||||
"cwd": "${ECM_ROOT}",
|
||||
"start_command": ["${PYTHON}", "run.py"],
|
||||
"restore_command": [
|
||||
"${PYTHON}",
|
||||
"run.py",
|
||||
"restore-database",
|
||||
"-d",
|
||||
"${COUCHDB_DATABASE}",
|
||||
"-s",
|
||||
"${DATA_DIR}",
|
||||
],
|
||||
"env": {},
|
||||
"ready_url": "http://localhost:8000/docs",
|
||||
"ready_timeout_seconds": 120,
|
||||
"ready_interval_seconds": 2,
|
||||
"log_file": "${PROJECT_ROOT}/runtime/remote_env/ecm_remote.log",
|
||||
},
|
||||
"env_file": {
|
||||
"enabled": True,
|
||||
"path": "${ECM_ROOT}/.env",
|
||||
"restore_on_stop": True,
|
||||
"overrides": {
|
||||
"COUCHDB_DBNAME": "${COUCHDB_DATABASE}",
|
||||
"COUCHDB_USER": "${COUCHDB_USERNAME}",
|
||||
"COUCHDB_PASSWORD": "${COUCHDB_PASSWORD}",
|
||||
"COUCHDB_URL": "${COUCHDB_URL}",
|
||||
"COUCHDB_PORT": "${COUCHDB_PORT}",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = deepcopy(base)
|
||||
for key, value in override.items():
|
||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||
merged[key] = _deep_merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def _replace_placeholders(value: Any, placeholders: dict[str, str]) -> Any:
|
||||
if isinstance(value, str):
|
||||
result = value
|
||||
for _ in range(5):
|
||||
previous = result
|
||||
for key, replacement in placeholders.items():
|
||||
result = result.replace(f"${{{key}}}", replacement)
|
||||
if result == previous:
|
||||
break
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [_replace_placeholders(item, placeholders) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _replace_placeholders(item, placeholders) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _ensure_parent(path: str | Path) -> Path:
|
||||
resolved = Path(path)
|
||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
return resolved
|
||||
|
||||
|
||||
def _load_raw_config(config_path: str | Path) -> tuple[dict[str, Any], Path]:
|
||||
path = Path(config_path)
|
||||
if not path.is_absolute():
|
||||
path = PROJECT_ROOT / path
|
||||
payload = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"Config root must be a mapping: {path}")
|
||||
return payload, path
|
||||
|
||||
|
||||
def _apply_cli_overrides(config: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
|
||||
result = deepcopy(config)
|
||||
couchdb = result.setdefault("couchdb", {})
|
||||
service = result.setdefault("service", {})
|
||||
data = result.setdefault("data", {})
|
||||
|
||||
if args.db_name is not None:
|
||||
couchdb["database"] = args.db_name
|
||||
if args.db_user is not None:
|
||||
couchdb["username"] = args.db_user
|
||||
if args.db_password is not None:
|
||||
couchdb["password"] = args.db_password
|
||||
if args.couchdb_base_url is not None:
|
||||
couchdb["base_url"] = args.couchdb_base_url
|
||||
if args.couchdb_url is not None:
|
||||
couchdb["url"] = args.couchdb_url
|
||||
if args.couchdb_port is not None:
|
||||
couchdb["port"] = str(args.couchdb_port)
|
||||
if args.ecm_root is not None:
|
||||
service["ecm_root"] = args.ecm_root
|
||||
if args.python_path is not None:
|
||||
service["python_path"] = args.python_path
|
||||
if args.data_dir is not None:
|
||||
data["data_dir"] = args.data_dir
|
||||
if args.ready_url is not None:
|
||||
service["ready_url"] = args.ready_url
|
||||
return result
|
||||
|
||||
|
||||
def load_config(config_path: str | Path, args: argparse.Namespace) -> tuple[dict[str, Any], Path]:
|
||||
raw_config, resolved_path = _load_raw_config(config_path)
|
||||
merged = _deep_merge(DEFAULT_CONFIG, raw_config)
|
||||
merged = _apply_cli_overrides(merged, args)
|
||||
|
||||
config_dir = resolved_path.parent.resolve()
|
||||
service = merged.setdefault("service", {})
|
||||
couchdb = merged.setdefault("couchdb", {})
|
||||
data = merged.setdefault("data", {})
|
||||
|
||||
placeholders = {
|
||||
"PROJECT_ROOT": str(PROJECT_ROOT),
|
||||
"CONFIG_DIR": str(config_dir),
|
||||
"ECM_ROOT": str(service.get("ecm_root", "")),
|
||||
"PYTHON": str(service.get("python_path", "")),
|
||||
"DATA_DIR": str(data.get("data_dir", "")),
|
||||
"COUCHDB_DATABASE": str(couchdb.get("database", "")),
|
||||
"COUCHDB_USERNAME": str(couchdb.get("username", "")),
|
||||
"COUCHDB_PASSWORD": str(couchdb.get("password", "")),
|
||||
"COUCHDB_URL": str(couchdb.get("url", "")),
|
||||
"COUCHDB_PORT": str(couchdb.get("port", "")),
|
||||
}
|
||||
|
||||
resolved = _replace_placeholders(merged, placeholders)
|
||||
service = resolved.setdefault("service", {})
|
||||
couchdb = resolved.setdefault("couchdb", {})
|
||||
data = resolved.setdefault("data", {})
|
||||
env_file = resolved.setdefault("env_file", {})
|
||||
|
||||
for key in ("ecm_root", "python_path", "cwd", "log_file"):
|
||||
if service.get(key):
|
||||
service[key] = str(Path(service[key]).expanduser().resolve())
|
||||
if data.get("data_dir"):
|
||||
data["data_dir"] = str(Path(data["data_dir"]).expanduser().resolve())
|
||||
if env_file.get("path"):
|
||||
env_file["path"] = str(Path(env_file["path"]).expanduser().resolve())
|
||||
|
||||
service["ready_timeout_seconds"] = int(service.get("ready_timeout_seconds", 120))
|
||||
service["ready_interval_seconds"] = int(service.get("ready_interval_seconds", 2))
|
||||
return resolved, resolved_path
|
||||
|
||||
|
||||
def validate_config(config: dict[str, Any], command: str) -> None:
|
||||
couchdb = config.get("couchdb", {})
|
||||
data = config.get("data", {})
|
||||
service = config.get("service", {})
|
||||
env_file = config.get("env_file", {})
|
||||
|
||||
required_service = [
|
||||
(service.get("ecm_root"), "service.ecm_root"),
|
||||
(service.get("python_path"), "service.python_path"),
|
||||
]
|
||||
missing_service = [name for value, name in required_service if value in (None, "")]
|
||||
if missing_service:
|
||||
raise ValueError("Missing required config fields: " + ", ".join(missing_service))
|
||||
|
||||
if not Path(service["ecm_root"]).exists():
|
||||
raise FileNotFoundError(f"ECM root does not exist: {service['ecm_root']}")
|
||||
if not Path(service["python_path"]).exists():
|
||||
raise FileNotFoundError(f"Python path does not exist: {service['python_path']}")
|
||||
|
||||
if not isinstance(service.get("start_command"), list) or not service.get("start_command"):
|
||||
raise ValueError("service.start_command must be a non-empty list")
|
||||
if not isinstance(service.get("restore_command"), list) or not service.get("restore_command"):
|
||||
raise ValueError("service.restore_command must be a non-empty list")
|
||||
|
||||
if env_file.get("enabled", True):
|
||||
env_path = Path(env_file.get("path", ""))
|
||||
if not env_path.exists():
|
||||
raise FileNotFoundError(f"env_file.path does not exist: {env_path}")
|
||||
|
||||
if command in {"clear", "init"} and couchdb.get("enabled", True):
|
||||
required_couchdb = [
|
||||
(couchdb.get("base_url"), "couchdb.base_url"),
|
||||
(couchdb.get("database"), "couchdb.database"),
|
||||
]
|
||||
missing_couchdb = [name for value, name in required_couchdb if value in (None, "")]
|
||||
if missing_couchdb:
|
||||
raise ValueError("Missing required config fields: " + ", ".join(missing_couchdb))
|
||||
|
||||
if command == "init":
|
||||
if data.get("data_dir") in (None, ""):
|
||||
raise ValueError("Missing required config fields: data.data_dir")
|
||||
if not Path(data["data_dir"]).exists():
|
||||
raise FileNotFoundError(f"Data directory does not exist: {data['data_dir']}")
|
||||
|
||||
|
||||
def _auth_header(username: str, password: str) -> dict[str, str]:
|
||||
if not username:
|
||||
return {}
|
||||
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
|
||||
return {"Authorization": f"Basic {token}"}
|
||||
|
||||
|
||||
def _delete_couchdb_database(base_url: str, database: str, username: str = "", password: str = "") -> int:
|
||||
url = f"{base_url.rstrip('/')}/{database}"
|
||||
req = request.Request(url, method="DELETE", headers=_auth_header(username, password))
|
||||
try:
|
||||
with request.urlopen(req, timeout=10) as response:
|
||||
return response.status
|
||||
except error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return 404
|
||||
raise
|
||||
|
||||
|
||||
def _create_couchdb_database(base_url: str, database: str, username: str = "", password: str = "") -> int:
|
||||
url = f"{base_url.rstrip('/')}/{database}"
|
||||
req = request.Request(url, method="PUT", headers=_auth_header(username, password))
|
||||
with request.urlopen(req, timeout=10) as response:
|
||||
return response.status
|
||||
|
||||
|
||||
def _run_command(command: list[str], *, cwd: str | Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=str(cwd),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _run_or_raise(command: list[str], *, cwd: str | Path, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
result = _run_command(command, cwd=cwd, env=env)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
"Command failed\n"
|
||||
f"command={command}\n"
|
||||
f"stdout:\n{result.stdout}\n\n"
|
||||
f"stderr:\n{result.stderr}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _find_backend_pgids(backend_root_dir: str | Path) -> list[int]:
|
||||
backend_root = str(Path(backend_root_dir).resolve())
|
||||
result = _run_command(["ps", "-ax", "-o", "pid=,pgid=,command="], cwd=PROJECT_ROOT)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
pgids: list[int] = []
|
||||
for line in result.stdout.splitlines():
|
||||
raw = line.strip()
|
||||
if not raw or backend_root not in raw or " run.py" not in raw:
|
||||
continue
|
||||
parts = raw.split(None, 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
try:
|
||||
pgid = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
if pgid not in pgids:
|
||||
pgids.append(pgid)
|
||||
return pgids
|
||||
|
||||
|
||||
def _port_from_ready_url(url: str | None) -> int | None:
|
||||
if not url:
|
||||
return None
|
||||
parsed = urlparse(url)
|
||||
if parsed.port is not None:
|
||||
return int(parsed.port)
|
||||
if parsed.scheme == "https":
|
||||
return 443
|
||||
if parsed.scheme == "http":
|
||||
return 80
|
||||
return None
|
||||
|
||||
|
||||
def _find_port_listener_pgids(port: int | None) -> list[int]:
|
||||
if port is None:
|
||||
return []
|
||||
result = _run_command(["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN", "-t"], cwd=PROJECT_ROOT)
|
||||
if result.returncode not in (0, 1):
|
||||
return []
|
||||
|
||||
pgids: list[int] = []
|
||||
for line in result.stdout.splitlines():
|
||||
raw = line.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
pid = int(raw)
|
||||
pgid = os.getpgid(pid)
|
||||
except (ValueError, ProcessLookupError, PermissionError):
|
||||
continue
|
||||
if pgid not in pgids:
|
||||
pgids.append(pgid)
|
||||
return pgids
|
||||
|
||||
|
||||
def _find_managed_pgids(service: dict[str, Any]) -> list[int]:
|
||||
pgids: list[int] = []
|
||||
for pgid in _find_backend_pgids(service["ecm_root"]):
|
||||
if pgid not in pgids:
|
||||
pgids.append(pgid)
|
||||
for pgid in _find_port_listener_pgids(_port_from_ready_url(service.get("ready_url"))):
|
||||
if pgid not in pgids:
|
||||
pgids.append(pgid)
|
||||
return pgids
|
||||
|
||||
|
||||
def _kill_process_group(pid: int) -> bool:
|
||||
try:
|
||||
os.killpg(pid, signal.SIGTERM)
|
||||
time.sleep(1)
|
||||
return True
|
||||
except PermissionError:
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
time.sleep(1)
|
||||
return True
|
||||
except (PermissionError, ProcessLookupError):
|
||||
return False
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
|
||||
|
||||
def _wait_http_ready(url: str, timeout_seconds: int, interval_seconds: int) -> None:
|
||||
deadline = time.time() + timeout_seconds
|
||||
last_error = ""
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with request.urlopen(url, timeout=5) as response:
|
||||
if 200 <= response.status < 500:
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = str(exc)
|
||||
time.sleep(interval_seconds)
|
||||
raise TimeoutError(f"Service not ready: {url}; last_error={last_error}")
|
||||
|
||||
|
||||
def _load_state() -> dict[str, Any]:
|
||||
if not STATE_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(state: dict[str, Any]) -> None:
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _clear_state() -> None:
|
||||
STATE_FILE.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _parse_env_line(line: str) -> tuple[str, str] | None:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
return None
|
||||
key, value = stripped.split("=", 1)
|
||||
return key.strip(), value
|
||||
|
||||
|
||||
def _read_env_file(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return path.read_text(encoding="utf-8").splitlines()
|
||||
|
||||
|
||||
def _write_env_file(path: Path, lines: list[str]) -> None:
|
||||
_ensure_parent(path)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _apply_env_overlay(config: dict[str, Any]) -> dict[str, Any]:
|
||||
env_config = config.get("env_file", {})
|
||||
if not env_config.get("enabled", True):
|
||||
return {"applied": False, "reason": "disabled"}
|
||||
|
||||
env_path = Path(env_config["path"])
|
||||
overrides = {str(key): str(value) for key, value in (env_config.get("overrides") or {}).items()}
|
||||
state = _load_state()
|
||||
env_state = state.setdefault("env_file", {})
|
||||
|
||||
if not env_state.get("backup_path"):
|
||||
backup_dir = STATE_DIR / "env_backup"
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_path = backup_dir / env_path.name
|
||||
shutil.copy2(env_path, backup_path)
|
||||
env_state["backup_path"] = str(backup_path)
|
||||
env_state["path"] = str(env_path)
|
||||
|
||||
lines = _read_env_file(env_path)
|
||||
key_to_index: dict[str, int] = {}
|
||||
for index, line in enumerate(lines):
|
||||
parsed = _parse_env_line(line)
|
||||
if parsed is None:
|
||||
continue
|
||||
key_to_index[parsed[0]] = index
|
||||
|
||||
for key, value in overrides.items():
|
||||
new_line = f"{key}={value}"
|
||||
if key in key_to_index:
|
||||
lines[key_to_index[key]] = new_line
|
||||
else:
|
||||
lines.append(new_line)
|
||||
|
||||
_write_env_file(env_path, lines)
|
||||
_save_state(state)
|
||||
return {
|
||||
"applied": True,
|
||||
"path": str(env_path),
|
||||
"overrides": sorted(overrides.keys()),
|
||||
"backup_path": env_state["backup_path"],
|
||||
}
|
||||
|
||||
|
||||
def _restore_env_overlay(config: dict[str, Any]) -> dict[str, Any]:
|
||||
env_config = config.get("env_file", {})
|
||||
if not env_config.get("enabled", True) or not env_config.get("restore_on_stop", True):
|
||||
return {"restored": False, "reason": "disabled"}
|
||||
|
||||
state = _load_state()
|
||||
env_state = state.get("env_file") or {}
|
||||
backup_path = env_state.get("backup_path")
|
||||
env_path = env_state.get("path") or env_config.get("path")
|
||||
if not backup_path or not env_path:
|
||||
return {"restored": False, "reason": "no_backup"}
|
||||
|
||||
backup = Path(backup_path)
|
||||
target = Path(env_path)
|
||||
if not backup.exists():
|
||||
state.pop("env_file", None)
|
||||
_save_state(state)
|
||||
return {"restored": False, "reason": "backup_missing"}
|
||||
|
||||
shutil.copy2(backup, target)
|
||||
backup.unlink(missing_ok=True)
|
||||
state.pop("env_file", None)
|
||||
if state:
|
||||
_save_state(state)
|
||||
else:
|
||||
_clear_state()
|
||||
return {"restored": True, "path": str(target)}
|
||||
|
||||
|
||||
def _build_process_env(config: dict[str, Any]) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env.update({key: str(value) for key, value in (config.get("service", {}).get("env") or {}).items()})
|
||||
return env
|
||||
|
||||
|
||||
def _state_pid() -> int | None:
|
||||
state = _load_state()
|
||||
pid = state.get("pid")
|
||||
return int(pid) if isinstance(pid, int) else None
|
||||
|
||||
|
||||
def _set_state_pid(pid: int | None) -> None:
|
||||
state = _load_state()
|
||||
if pid is None:
|
||||
state.pop("pid", None)
|
||||
else:
|
||||
state["pid"] = pid
|
||||
if state:
|
||||
_save_state(state)
|
||||
else:
|
||||
_clear_state()
|
||||
|
||||
|
||||
def clear_database(config: dict[str, Any]) -> dict[str, Any]:
|
||||
couchdb = config["couchdb"]
|
||||
if not couchdb.get("enabled", True):
|
||||
return {"enabled": False}
|
||||
|
||||
delete_status = _delete_couchdb_database(
|
||||
couchdb["base_url"],
|
||||
couchdb["database"],
|
||||
couchdb.get("username", ""),
|
||||
couchdb.get("password", ""),
|
||||
)
|
||||
create_status = None
|
||||
if couchdb.get("recreate_after_delete", False):
|
||||
create_status = _create_couchdb_database(
|
||||
couchdb["base_url"],
|
||||
couchdb["database"],
|
||||
couchdb.get("username", ""),
|
||||
couchdb.get("password", ""),
|
||||
)
|
||||
return {
|
||||
"database": couchdb["database"],
|
||||
"delete_status": delete_status,
|
||||
"create_status": create_status,
|
||||
}
|
||||
|
||||
|
||||
def import_init_data(config: dict[str, Any]) -> dict[str, Any]:
|
||||
service = config["service"]
|
||||
result = _run_or_raise(service["restore_command"], cwd=service["cwd"], env=_build_process_env(config))
|
||||
return {
|
||||
"data_dir": config["data"]["data_dir"],
|
||||
"restore_command": service["restore_command"],
|
||||
"stdout_tail": result.stdout[-2000:],
|
||||
"stderr_tail": result.stderr[-2000:],
|
||||
}
|
||||
|
||||
|
||||
def stop_service(config: dict[str, Any], *, restore_env: bool = True) -> dict[str, Any]:
|
||||
service = config["service"]
|
||||
killed: list[int] = []
|
||||
pid = _state_pid()
|
||||
if pid is not None and _kill_process_group(pid):
|
||||
killed.append(pid)
|
||||
for pgid in _find_managed_pgids(service):
|
||||
if pgid in killed:
|
||||
continue
|
||||
if _kill_process_group(pgid):
|
||||
killed.append(pgid)
|
||||
_set_state_pid(None)
|
||||
env_restore = _restore_env_overlay(config) if restore_env else {"restored": False, "reason": "skipped"}
|
||||
return {
|
||||
"stopped": bool(killed),
|
||||
"killed_pgids": killed,
|
||||
"env_file": env_restore,
|
||||
}
|
||||
|
||||
|
||||
def run_service(config: dict[str, Any]) -> dict[str, Any]:
|
||||
service = config["service"]
|
||||
existing_pgids = _find_managed_pgids(service)
|
||||
if existing_pgids:
|
||||
return {
|
||||
"started": False,
|
||||
"reason": "already_running",
|
||||
"pgids": existing_pgids,
|
||||
"log_file": service["log_file"],
|
||||
}
|
||||
|
||||
env_result = _apply_env_overlay(config)
|
||||
log_path = _ensure_parent(service["log_file"])
|
||||
handle = open(log_path, "a", encoding="utf-8")
|
||||
process = subprocess.Popen(
|
||||
service["start_command"],
|
||||
cwd=service["cwd"],
|
||||
stdout=handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
start_new_session=True,
|
||||
env=_build_process_env(config),
|
||||
)
|
||||
_set_state_pid(process.pid)
|
||||
_wait_http_ready(
|
||||
str(service["ready_url"]),
|
||||
int(service.get("ready_timeout_seconds", 120)),
|
||||
int(service.get("ready_interval_seconds", 2)),
|
||||
)
|
||||
return {
|
||||
"started": True,
|
||||
"pid": process.pid,
|
||||
"log_file": str(log_path),
|
||||
"cwd": service["cwd"],
|
||||
"command": service["start_command"],
|
||||
"ready_url": service["ready_url"],
|
||||
"env_file": env_result,
|
||||
}
|
||||
|
||||
|
||||
def do_clear(config: dict[str, Any]) -> dict[str, Any]:
|
||||
stop_result = stop_service(config, restore_env=True)
|
||||
clear_result = clear_database(config)
|
||||
return {
|
||||
"command": "clear",
|
||||
"stop": stop_result,
|
||||
"database": clear_result,
|
||||
}
|
||||
|
||||
|
||||
def do_init(config: dict[str, Any]) -> dict[str, Any]:
|
||||
stop_result = stop_service(config, restore_env=False)
|
||||
env_result = _apply_env_overlay(config)
|
||||
clear_result = clear_database(config)
|
||||
init_result = import_init_data(config)
|
||||
return {
|
||||
"command": "init",
|
||||
"stop": stop_result,
|
||||
"env_file": env_result,
|
||||
"database": clear_result,
|
||||
"init": init_result,
|
||||
}
|
||||
|
||||
|
||||
def do_run(config: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"command": "run",
|
||||
"service": run_service(config),
|
||||
}
|
||||
|
||||
|
||||
def do_stop(config: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"command": "stop",
|
||||
"service": stop_service(config, restore_env=True),
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="远程 ECM 测试环境管理工具")
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["clear", "init", "run", "stop"],
|
||||
help="clear: 清空 CouchDB 测试库;init: 通过 run.py restore-database 恢复数据;run: 后台启动远程服务;stop: 停止远程服务并恢复 .env",
|
||||
)
|
||||
parser.add_argument("--config", default="tools/remote_env.example.yaml", help="YAML 配置文件路径")
|
||||
parser.add_argument("--db-name", help="覆盖 couchdb.database")
|
||||
parser.add_argument("--db-user", help="覆盖 couchdb.username")
|
||||
parser.add_argument("--db-password", help="覆盖 couchdb.password")
|
||||
parser.add_argument("--couchdb-base-url", help="覆盖 couchdb.base_url")
|
||||
parser.add_argument("--couchdb-url", help="覆盖 couchdb.url")
|
||||
parser.add_argument("--couchdb-port", help="覆盖 couchdb.port")
|
||||
parser.add_argument("--ecm-root", help="覆盖 service.ecm_root")
|
||||
parser.add_argument("--python-path", help="覆盖 service.python_path")
|
||||
parser.add_argument("--data-dir", help="覆盖 data.data_dir")
|
||||
parser.add_argument("--ready-url", help="覆盖 service.ready_url")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
config, config_path = load_config(args.config, args)
|
||||
validate_config(config, args.command)
|
||||
|
||||
if args.command == "clear":
|
||||
result = do_clear(config)
|
||||
elif args.command == "init":
|
||||
result = do_init(config)
|
||||
elif args.command == "run":
|
||||
result = do_run(config)
|
||||
elif args.command == "stop":
|
||||
result = do_stop(config)
|
||||
else:
|
||||
raise AssertionError(f"Unhandled command: {args.command}")
|
||||
|
||||
result["config_path"] = str(config_path)
|
||||
result["state_file"] = str(STATE_FILE)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(json.dumps({"error": str(exc)}, ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user