Compare commits

...

7 Commits

Author SHA1 Message Date
strepsiades a677220f4d 增加项目侧重构文档 2026-03-12 16:03:26 +08:00
strepsiades 758b1c4f77 增加重构文档 2026-03-12 14:23:15 +08:00
strepsiades 0ae2db1808 增加字段config重构文档 2026-03-12 11:39:57 +08:00
strepsiades e786bd5bf0 增加一处注释,说明project_extension字段的来源和作用。 2026-03-11 16:16:37 +08:00
strepsiades a22c527daf 修改了investment的schema 2026-03-11 15:30:46 +08:00
strepsiades bd81c6dae6 现在progress_type允许为None 2026-03-11 14:49:35 +08:00
strepsiades b9fbf357a4 增加了一个测试工具用于初始化测试环境 2026-03-11 11:40:23 +08:00
23 changed files with 4668 additions and 4 deletions
+4
View File
@@ -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/`
+258
View File
@@ -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. 当前迁移示例
### 示例 1section 候选项读取
当前:
- 通过 construction 私有 helper 直接读取并解释 `construction_section_config`
目标:
- 通过 `ConfigService.get_field_options(project_type, "construction.section_type_code")` 获取 section 列表
### 示例 2section 合法值校验
当前:
- 可能由业务代码自行判断,或者根本没有统一校验入口
目标:
- 统一走 `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` 可以创建,非法值明确报错。
+108
View File
@@ -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` 建立统一常量定义,避免散落数值字面量。
+192
View File
@@ -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 明确报错。
+149
View File
@@ -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` 跟随合同主表时,不出现与合同主表不一致的脏数据。
+157
View File
@@ -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
+757
View File
@@ -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/创建校验与配置同源,配置来源本身相对已经收敛。
+201
View File
@@ -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` 明确报错,不再依赖默认兜底。
+167
View File
@@ -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 校验使用同源常量。
+612
View File
@@ -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/`
+151
View File
@@ -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`
+146
View File
@@ -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`
+399
View File
@@ -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`
+189
View File
@@ -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`
+2 -1
View File
@@ -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[
+10 -1
View File
@@ -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,
+1
View File
@@ -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"]
+414
View File
@@ -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())
+44
View File
@@ -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}
+699
View File
@@ -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)