Metadata-Version: 2.4
Name: tsysmart_dev_topo
Version: 1.1
Summary: Device topology data management module
Description-Content-Type: text/markdown
Requires-Dist: SQLAlchemy~=2.0
Requires-Dist: fastapi>=0.115
Requires-Dist: uvicorn>=0.34
Requires-Dist: pydantic
Requires-Dist: tsysmart_appmng
Requires-Dist: tsysmart_proj
Dynamic: description
Dynamic: description-content-type
Dynamic: requires-dist
Dynamic: summary

# tsysmart_dev_topo - 设备拓扑

设备拓扑数据管理模块，拓扑版本 **1.0**。提供拓扑数据的查询、导入和 API 服务。可通过 pip wheel 安装。

## 安装

```bash
# 先安装依赖包
pip install tsysmart_appmng -i https://pypi.tsysmart.com/simple/

# 构建 wheel
cd source/scada/python/tsysmart_dev_topo
python -m build --wheel --outdir dist .

# 安装
pip install dist/tsysmart_dev_topo-1.0-py3-none-any.whl

# 验证
python -c "from tsysmart_dev_topo import app, TopoService; print('OK')"
```

## 目录结构

```
tsysmart_dev_topo/
├── pyproject.toml              # 打包配置（wheel 构建）
├── setup.py                    # setuptools 兼容打包入口
├── __init__.py                 # 导出 app, TopoService
├── meta.json                   # 模块元数据（名称、版本、简介）
├── install.py                  # 安装 / 卸载 / 升级（安装时自动建表）
├── upgrade.py                  # 数据库升级脚本（增量升级 / 智能重建）
├── tables.py                   # 92 张拓扑表 SQLAlchemy ORM 模型
├── requirements.txt            # Python 依赖
├── topo_define/                # 92 个 CSV 表定义文件
├── app/
│   └── services/
│       ├── topo_service.py     # 内部接口：返回对象 array
│       └── topo_api.py         # 外部接口：FastAPI JSON REST API
└── tools/
    ├── import_topo.py          # 通用数据导入工具
    ├── gen_tables.py           # CSV → tables.py 代码生成器
    ├── gen_cpp_models.py       # tables.py → C++ 模型头生成器
    └── mappings/               # 字段映射配置文件
        ├── scada_ac_net_bus.py
        ├── scada_ac_net_load.py
        └── ...
├── dist/                        # wheel 输出目录

# C++ 部分已独立拆分到平台 lib 目录：
../../lib/devtopolib/               # C++ 库源码与工程
    ├── devtopolib.pro / .cpp       # 库实现
    ├── examples/                   # 示例程序
    └── tests/                      # gtest 单元测试
../../include/devtopolib/           # C++ 公共头文件（生成 + 手工）
    ├── tsysmart_dev_topo.h
    ├── tsysmart_dev_topo_model_api.h
    └── tsysmart_dev_topo_models/   # 92 个分表头（gen_cpp_models.py 生成）
```

## 拓扑表清单（92 张）

| 类别 | 数量 | 包含表 |
|------|------|--------|
| 基础公共 | 5 | `scada_basevalue`, `scada_basevoltage`, `scada_company`, `scada_subcontrolarea`, `scada_substation` |
| 厂站拓扑 | 4 | `scada_bay`, `scada_ac_net_island`, `scada_voltagelevel`, `scada_ac_net_node` |
| 交流设备 | 12 | `scada_ac_net_breaker`, `scada_ac_net_bus`, `scada_ac_net_disconnector`, `scada_ac_net_grounddisconnector`, `scada_ac_net_lineend`, `scada_ac_net_linesegment`, `scada_ac_net_load`, `scada_ac_net_taptype`, `scada_ac_net_transformer`, `scada_ac_net_transformerwinding`, `scada_ac_net_unit`, `scada_ac_net_line` |
| 直流设备 | 6 | `scada_dc_net_breaker`, `scada_dc_net_bus`, `scada_dc_net_lineend`, `scada_dc_net_load`, `scada_dc_net_unit`, `scada_dc_net_linesegment` |
| 转换器 | 8 | `scada_econv_acac`, `scada_econv_acdc`, `scada_econv_dcdc`, `scada_econv_mt_dev`, `scada_econv_mt_terminal`, `scada_econv_pv`, `scada_econv_storage`, `scada_econv_wd` |
| 电力设备 | 8 | `scada_elec_dev_der`, `scada_elec_dev_diesel`, `scada_dev_heat_gb`, `scada_dev_heat_hstorage`, `scada_elec_dev_pv`, `scada_elec_dev_storage`, `scada_elec_dev_wt`, `scada_agg_unit` |
| SCADA | 30 | `scada_fes_rtu_define`, `scada_fes_channel_define`, `scada_fes_yc_define`, `scada_fes_yx_define`, `scada_fes_yk_define`, `scada_fes_yt_define`, `scada_fes_remote_ctrl_para`, `scada_fes_iec104_define`, `scada_fes_iec104_yc_define`, `scada_fes_iec104_yx_define`, `scada_fes_iec104_yk_define`, `scada_fes_iec104_yt_define`, `scada_fes_modbus_define_data`, `scada_fes_modbus_define_call`, `scada_fes_modbus_define_ytyk`, `scada_fes_opc_define`, `scada_fes_bacnet_define`, `scada_fes_mqtt_define`, `scada_fes_schedule_task`, `scada_fes_task_message`, `scada_fes_offline_setvalue`, `scada_fes_ytyk_message`, `scada_scd_warn_define`, `scada_scd_warn_history`, `scada_scd_warn_level_define`, `scada_scd_warn_type_define`, `scada_scd_elec_statics`, `scada_scd_stat_demand`, `scada_scd_dev_status_define`, `scada_scd_dev_warn_bind` |
| 电力交易/断面 | 5 | `scada_elec_tariff_time`, `scada_elec_tariff_trans_dist`, `scada_tie`, `scada_tiedev`, `scada_tiesens` |
| 热力设备 | 6 | `scada_heat_basevalue`, `scada_therm_net_bus`, `scada_therm_net_unit`, `scada_therm_net_load`, `scada_therm_dev_inline`, `scada_therm_dev_storage` |
| 多能转换 | 5 | `scada_mconv_dev_boiler`, `scada_mconv_dev_cchp`, `scada_mconv_dev_chp`, `scada_mconv_dev_e2h`, `scada_mconv_dev_h2c` |

> 完整表定义见 [tables.py](tables.py)。各表字段类型/主键详情见对应的 CSV 定义文件（`topo_define/`）。

---

## 快速开始

### 1. 安装并建表

```bash
# 先安装前置依赖
pip install tsysmart_appmng -i https://pypi.tsysmart.com/simple/

# wheel 安装后，优先使用脚本入口
tsysmart_dev_topo --tenant_code henan_guozhong --project_code topic3_da --action install

# 也可直接按模块方式运行
python -m tsysmart_dev_topo.install --tenant_code henan_guozhong --project_code topic3_da --action install

# 如果当前只想先开发/建表，跳过模块注册
tsysmart_dev_topo --tenant_code henan_guozhong --project_code topic3_da --action install --skip-register

# 查询模块信息
tsysmart_dev_topo --tenant_code henan_guozhong --action query
```

说明：
- `install.py` 强依赖 `tsysmart_appmng`，未安装时会直接报错。
- `install` 默认会先建表，再更新租户 `module_list`；若租户元数据侧有冲突，可先加 `--skip-register` 只建表。
- `--skip-register` 同样可用于 `upgrade` / `uninstall`，用于跳过模块注册更新。
- 修改 `install.py` 后，需要重新构建并重新安装 wheel，已安装环境中的 `site-packages/tsysmart_dev_topo/install.py` 不会自动同步源码改动。

### 2. 导入数据

```bash
# 从源数据库导入到目标 PG
python -m tsysmart_dev_topo.tools.import_topo \
    --target_tenant henan_guozhong --target_project henan_test \
    --source_tenant henan_guozhong --source_project bk4cpfe_

# 指定源数据版本
python -m tsysmart_dev_topo.tools.import_topo \
    --target_tenant henan_guozhong --target_project henan_test \
    --source_tenant henan_guozhong --source_project bk4cpfe_ \
    --source_version THU_EFILE

# 同时导入空表
python -m tsysmart_dev_topo.tools.import_topo \
    --target_tenant henan_guozhong --target_project henan_test \
    --source_tenant henan_guozhong --source_project bk4cpfe_ \
    --empty
```

参数说明：

| 参数 | 必填 | 说明 |
|------|------|------|
| `--target_tenant` | 是 | 目标租户代码 |
| `--target_project` | 是 | 目标项目/模式名 |
| `--source_tenant` | 是 | 源数据库租户代码 |
| `--source_project` | 是 | 源数据库项目/模式名 |
| `--source_version` | 否 | 源数据版本（默认 `THU_1.0`，可选 `THU_EFILE`、`QYOPS_0.5`） |
| `--empty` | 否 | 同时导入空表 |

### 3. 启动 API 服务

```bash
cd source/scada/python/tsysmart_dev_topo
python -m uvicorn tsysmart_dev_topo:app --host 0.0.0.0 --port 8123
```

---

## 数据导入工具（import_topo.py）

通用数据导入工具，支持任意 SQLAlchemy 兼容的源数据库（SQLite / PostgreSQL / MySQL 等）。

### 工作原理

```
┌──────────────────────┐     ┌─────────────────────────┐     ┌──────────────────┐
│  源数据库（任意）      │ --> │  mappings/ 字段映射配置   │ --> │  PG 目标表        │
│  get_db_url 连接      │     │  源表名 → PG 表名          │     │  scada_ac_net_bus │
│  自动识别表            │     │  源字段 → PG 字段          │     │  scada_ac_net_bus  │
│                       │     │  枚举值转换                │     │  scada_ac_net_unit │
│                       │     │                          │     │  ...              │
└──────────────────────┘     └─────────────────────────┘     └──────────────────┘
```

### 映射文件结构（mappings/）

每个 `.py` 文件对应一张 PG 目标表，文件名为 PG 表名：

```python
# scada_ac_net_unit 字段映射

source_version_defines = {
    'THU_EFILE': {
        'table_name': 'unit',           # 源表名
        'primary_keys': ['id'],         # 主键
        'header_list': ['id', ...],     # 源字段清单
        'mapping': {'nd': 'ind', ...},  # 源字段 → PG 字段
        'value_map': {'un_type': {'V': 2, 'P': 8}},  # 枚举值转换
    },
    'THU_1.0': { ... },
}
```

程序启动时扫描 `mappings/` 目录，根据 `--source_version` 自动建立源表 → PG 表的完整映射关系，无需手动维护映射表清单。

---

## 内部接口（topo_service.py）

供其他 Python 模块直接调用的对象式接口。初始化后通过 ORM 模型类（`tables.py` 中定义的类）进行增删改查。

```python
from tsysmart_dev_topo import TopoService
from tsysmart_dev_topo.tables import ScadaAcNetBus, ScadaAcNetLoad, ScadaDevDiesel

svc = TopoService('henan_test', 'topic3_da')
```

### 查询

#### `query(model_class, project_code, filters=None, columns=None, limit=None)`

按模型类查询，返回 `list[dict]`。支持复杂过滤条件。

```python
# 全表查询
all_buses = svc.query(AcNetBus)

# 等值过滤
loads = svc.query(ScadaAcNetLoad, filters={'ld_type': 0})

# 指定返回列
names = svc.query(DevDiesel, columns=['id', 'name', 'p_rated'])

# 限制行数
top5 = svc.query(ScadaAcNetLoad, filters={'ld_type': 0}, limit=5)
```

#### 查询过滤语法

`filters` 参数支持丰富的操作符，多个条件用 AND 连接：

| 语法 | SQL | 示例 |
|------|-----|------|
| `{'col': val}` | `col = val` | `{'ld_type': 0}` |
| `{'col': [a, b, c]}` | `col IN (a, b, c)` | `{'id': ['bus1', 'bus2', 'bus3']}` |
| `{'col__gt': v}` | `col > v` | `{'p_rated__gt': 300}` |
| `{'col__gte': v}` | `col >= v` | `{'p_rated__gte': 300}` |
| `{'col__lt': v}` | `col < v` | `{'p_rated__lt': 500}` |
| `{'col__lte': v}` | `col <= v` | `{'p_rated__lte': 500}` |
| `{'col__ne': v}` | `col <> v` | `{'name__ne': 'default'}` |
| `{'col__like': '%x%'}` | `col LIKE '%x%'` | `{'name__like': '%电站%'}` |
| `{'col__ilike': '%x%'}` | 自动适配方言 | `{'name__ilike': '%station%'}` |
| 组合 | AND | `{'ld_type': 0, 'p_rated__gt': 100}` |

```python
# IN 批量查询
devs = svc.query(DevDiesel, filters={'id': ['d1', 'd2', 'd3']})

# 范围查询
heavy = svc.query(ScadaAcNetLoad, filters={'p_rated__gte': 500})

# 组合 AND
result = svc.query(DevDiesel,
    filters={'p_rated__gte': 250, 'p_rated__lt': 400, 'name__like': '%批量%'})
```

#### `get_devices(device_names, project_code, filters=None)`

跨表批量查询（返回 dict，内部接口中较少用，主要用于 API 层）：

```python
result = svc.get_devices(
    ['scada_ac_net_load', 'scada_elec_dev_storage', 'scada_tie'], 'topic3_da',
    filters={'scada_ac_net_load': {'ld_type': 1}}
)
# => {'version': '1.0', 'project_code': 'topic3_da', 'tables': {'scada_ac_net_load': [...], ...}}
```

### 插入

#### `insert(obj, project_code)`

插入单条记录。**主键重复会抛出异常**（不更新）。

```python
# 构造对象
dev = DevDiesel(id='D001', name='柴油机1号', p_rated=500.0)

# 插入
svc.insert(dev)

# 重复插入报错
try:
    svc.insert(DevDiesel(id='D001', name='重复'))
except Exception:
    print('主键重复，插入失败')  # 符合预期
```

#### `insert_batch(objs, project_code)`

批量插入，单条 SQL 执行。**任一主键重复则整批失败**。

```python
batch = [
    DevDiesel(id='B01', name='批量1', p_rated=200.0),
    DevDiesel(id='B02', name='批量2', p_rated=250.0),
    DevDiesel(id='B03', name='批量3', p_rated=300.0),
]
result = svc.insert_batch(batch)
# => {'inserted': 3}
```

### 更新

#### `update(model_class, data, filters, project_code)`

按条件批量更新，可一次匹配多行。返回 `{'updated': True, 'matched': N}`。

```python
# 单条更新（按主键）
svc.update(DevDiesel, {'name': '新名称'}, {'id': 'D001'})

# 批量更新（IN）
svc.update(DevDiesel, {'p_rated': 999.0},
    {'id': ['B01', 'B02', 'B03']})

# 按范围批量更新
svc.update(ScadaAcNetLoad, {'run_state': 1},
    {'p_rated__gt': 500})

# 组合条件
svc.update(DevDiesel, {'name': '已退役'},
    {'name__like': '%批量%', 'p_rated__lt': 300})
```

### 删除

#### `delete(obj, project_code)`

按主键删除单条记录。

```python
# 构造对象（只需要主键值）
svc.delete(DevDiesel(id='D001'))

# 删除不存在的记录（count=0）
result = svc.delete(DevDiesel(id='nonexist'))
# => {'deleted': True, 'count': 0}
```

#### `delete_by(model_class, filters, project_code)`

按条件批量删除。

```python
# 批量删除（IN）
svc.delete_by(DevDiesel, {'id': ['B01', 'B02', 'B03']})

# 按范围删除
svc.delete_by(DevDiesel, {'p_rated__lt': 100})

# 组合条件
svc.delete_by(DevDiesel,
    {'name__like': '%_test%', 'p_rated__lt': 50})
```

### Upsert

#### `upsert(obj, project_code)`

插入或更新：主键不存在则插入，存在则更新。自动适配 PostgreSQL / SQLite / MySQL 方言。

```python
# 第一次：插入
svc.upsert(DevDiesel(id='U01', name='upsert1', p_rated=50.0))

# 第二次：同 id → 更新
svc.upsert(DevDiesel(id='U01', name='upsert-更新', p_rated=99.0))
# 数据库中 id='U01' 只有 1 行，name='upsert-更新', p_rated=99.0
```

### 方法总览

| 方法 | 类型 | 说明 |
|------|------|------|
| `query(model, schema, filters, select, limit)` | 查 | 按模型类查询，返回 `list[dict]` |
| `get_devices(names, schema, filters)` | 查 | 跨表批量查询 |
| `insert(obj, schema)` | 增 | 插入单条，重复 key 报错 |
| `insert_batch(objs, schema)` | 增 | 批量插入，重复 key 整批失败 |
| `update(model, data, filters, schema)` | 改 | 按条件批量更新 |
| `delete(obj, schema)` | 删 | 按 PK 删除单条 |
| `delete_by(model, filters, schema)` | 删 | 按条件批量删除 |
| `upsert(obj, schema)` | 增/改 | 存在则更新，不存在则插入 |

### 表名/列名安全校验

所有增删改查方法都会对表名和列名做白名单校验（基于 `tables.py` 中注册的列定义），防止 SQL 注入。非法表名或列名会抛出 `ValueError`。

---

## 外部接口（topo_api.py）

遵循 RESTful 设计，按查→增→改→删组织：

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/topo/version` | API 版本号 |
| GET | `/topo/topology` | 全部拓扑数据 |
| GET | `/topo/device/{device_type}` | 单表查询 |
| GET | `/topo/devices` | 多表查询 + where 过滤 |
| POST | `/topo/device/{device_type}` | 插入单条 |
| POST | `/topo/devices/{device_type}` | 批量插入 |
| PUT | `/topo/device/{device_type}/{id}` | 完整更新（按 URI 中 id） |
| PATCH | `/topo/device/{device_type}` | 部分更新（query 传 where，body 传 data） |
| DELETE | `/topo/device/{device_type}/{id}` | 按 ID 删除 |
| DELETE | `/topo/device/{device_type}` | 条件删除（query 传 where）|

所有写接口（POST/PUT/PATCH/DELETE）会实际修改数据库。

### 公共参数

| 参数 | 位置 | 必填 | 示例值 |
|------|------|------|--------|
| `tenant_code` | Query | 是 | `henan_test` |
| `project_code` | Query | 是 | `topic3_da` |
| `device_type` | Path | 是 | `scada_elec_dev_diesel`、`scada_ac_net_load` |

### where 过滤操作符

所有带 `where` 的接口（GET devices / PATCH / DELETE 条件删除）支持以下操作符：

| 语法 | 含义 | 示例 |
|------|------|------|
| `{"col": "val"}` | 等值 | `{"id": "D001"}` |
| `{"col": ["a","b"]}` | IN | `{"id": ["D001","D002"]}` |
| `{"col__gt": 100}` | 大于 | `{"p_rated__gt": 100}` |
| `{"col__gte": 100}` | 大于等于 | `{"p_rated__gte": 100}` |
| `{"col__lt": 300}` | 小于 | `{"p_rated__lt": 300}` |
| `{"col__lte": 300}` | 小于等于 | `{"p_rated__lte": 300}` |
| `{"col__ne": "v"}` | 不等于 | `{"id__ne": "D001"}` |
| `{"col__like": "%x%"}` | LIKE | `{"id__like": "API_%"}` |
| 多个条件 | AND | `{"p_rated__gte":100, "p_rated__lt":300}` |

### GET /topo/version

```bash
curl http://127.0.0.1:8123/topo/version
# => {"version": "1.0"}
```

### GET /topo/topology

```bash
curl "http://127.0.0.1:8123/topo/topology?tenant_code=henan_test&project_code=topic3_da"
```

### GET /topo/device/{device_type}

```bash
curl "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?tenant_code=henan_test&project_code=topic3_da"
```

### GET /topo/devices

```bash
# 多表查询
curl "http://127.0.0.1:8123/topo/devices?device_types=scada_elec_dev_diesel,scada_ac_net_load&tenant_code=henan_test&project_code=topic3_da"

# 带过滤（where 参数需 URL 编码）
curl "http://127.0.0.1:8123/topo/devices?device_types=scada_ac_net_load&where=%7B%22scada_ac_net_load%22%3A%7B%22ld_type%22%3A0%7D%7D&tenant_code=henan_test&project_code=topic3_da"
```

### POST /topo/device/{device_type} — 插入单条

```bash
curl -X POST "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?tenant_code=henan_test&project_code=topic3_da" \
  -H "Content-Type: application/json" \
  -d '{"id": "D001", "name": "柴油机1号", "p_rated": 500.0}'
# 主键重复 → 400
```

### POST /topo/devices/{device_type} — 批量插入

```bash
curl -X POST "http://127.0.0.1:8123/topo/devices/scada_elec_dev_diesel?tenant_code=henan_test&project_code=topic3_da" \
  -H "Content-Type: application/json" \
  -d '[{"id": "D001", "name": "柴油机1", "p_rated": 500.0}, {"id": "D002", "name": "柴油机2", "p_rated": 300.0}]'
# 任一条主键重复 → 整批失败（事务回滚）
```

### PUT /topo/device/{device_type}/{id} — 完整更新

```bash
curl -X PUT "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel/D001?tenant_code=henan_test&project_code=topic3_da" \
  -H "Content-Type: application/json" \
  -d '{"id": "D001", "name": "完整更新后名称", "p_rated": 800.0}'
# 需提供所有列的值（完整替换）
```

### PATCH /topo/device/{device_type} — 部分更新

```bash
# 按 id 更新名称（仅更新 name 列）
curl -X PATCH "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?where=%7B%22id%22%3A%22D001%22%7D&tenant_code=henan_test&project_code=topic3_da" \
  -H "Content-Type: application/json" \
  -d '{"name": "部分更新后的名称"}'

# 批量更新（p_rated > 100 的所有行）
curl -X PATCH "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?where=%7B%22p_rated__gt%22%3A100%7D&tenant_code=henan_test&project_code=topic3_da" \
  -H "Content-Type: application/json" \
  -d '{"name": "批量改名"}'
```

### DELETE /topo/device/{device_type}/{id} — 按 ID 删除

```bash
curl -X DELETE "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel/D001?tenant_code=henan_test&project_code=topic3_da"
```

### DELETE /topo/device/{device_type} — 条件删除

```bash
# 删除单条
curl -X DELETE "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?where=%7B%22id%22%3A%22D001%22%7D&tenant_code=henan_test&project_code=topic3_da"

# 批量删除（IN）
curl -X DELETE "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?where=%7B%22id%22%3A%5B%22D001%22%2C%22D002%22%5D%7D&tenant_code=henan_test&project_code=topic3_da"

# 条件删除（p_rated > 500）
curl -X DELETE "http://127.0.0.1:8123/topo/device/scada_elec_dev_diesel?where=%7B%22p_rated__gt%22%3A500%7D&tenant_code=henan_test&project_code=topic3_da"
```

---

## 安装 / 卸载 / 升级

通过 `tsysmart_appmng` 框架管理：

```bash
# 安装（注册模块 + 自动建表）
python install.py --tenant_code henan_guozhong --project_code henan_test --action install

# 卸载（注销模块，不删表）
python install.py --tenant_code henan_guozhong --project_code henan_test --action uninstall

# 升级（更新注册 + 执行 upgrade.py 数据迁移）
python install.py --tenant_code henan_guozhong --project_code henan_test --action upgrade

# 查询模块信息
python install.py --tenant_code henan_guozhong --project_code henan_test --action query
```

---

## 数据库升级（upgrade.py）

独立于 `install.py` 的升级脚本，支持两种模式。

### 增量升级（默认）

对比 `tables.py` 模型与数据库中实际表结构，仅对**新增列**执行 `ALTER TABLE ADD COLUMN`。不删数据，不删列，类型变更仅报告。

```bash
# 升级默认 7 个 schema
python upgrade.py --tenant_code henan_test

# 升级指定 schema
python upgrade.py --tenant_code henan_test --all_schemas topic3_da

# 升级单个 project
python upgrade.py --tenant_code henan_test --project_code topic3_da
```

### 智能重建（--rebuild）

逐个表对比结构，**仅重建有变化的表**。流程：

```
有变化的表
  ├── 有数据 → CREATE TABLE _upgrade_bak_{表名} AS SELECT（备份到同一 schema）
  ├── 有数据 → 导出 bakdata/{时间戳}_{tenant}_{schema}/{表名}.csv（文件备份）
  ├── DROP TABLE {表名} CASCADE
  ├── CREATE TABLE {表名}（按 tables.py 新结构）
  ├── INSERT INTO {表名} SELECT FROM _upgrade_bak_{表名}（恢复数据）
  └── DROP TABLE _upgrade_bak_{表名}（清理备份）
无变化的表 → 跳过
```

```bash
# 智能重建（自动备份 + 恢复）
python upgrade.py --tenant_code henan_test --rebuild

# 指定 schema
python upgrade.py --tenant_code henan_test --rebuild --all_schemas topic3_da
```

### 强制重建（--rebuild --force）

跳过结构对比，所有表全部 `DROP + CREATE`，**不备份**。

```bash
python upgrade.py --tenant_code henan_test --rebuild --force
```

### 参数一览

| 参数 | 必填 | 说明 |
|------|------|------|
| `--tenant_code` | 是 | 租户代码 |
| `--project_code` | 否 | 单个项目/schema |
| `--all_schemas` | 否 | 指定 schema 列表（空格分隔），默认全部 7 个 |
| `--rebuild` | 否 | 启用智能重建模式 |
| `--force` | 否 | 配合 `--rebuild`，跳过智能检测，全部重建不备份 |

### 备份位置

智能重建会生成**双重备份**：

1. **PG 备份表** `_upgrade_bak_{表名}` — 与源表位于同一个 schema，恢复完成后自动删除
2. **文件备份** `bakdata/{YYYYMMDD_HHMMSS}_{tenant}_{schema}/{表名}.csv` — 位于 `tsysmart_dev_topo/bakdata/` 目录下，**不会自动删除**，方便追溯每次升级

```bash
# 查看文件备份
ls bakdata/topic3_da/

# 异常中断时 PG 残留备份表
python -c "
from sqlalchemy import create_engine, text
e = create_engine('postgresql+psycopg://...')
rows = e.execute(text(\"SELECT schemaname, tablename FROM pg_tables WHERE tablename LIKE '_upgrade_bak%'\")).fetchall()
print(rows)
"

# 清理 PG 残留
DROP TABLE IF EXISTS _upgrade_bak_xxx;
```

---

## 测试

测试程序位于 `tools/_test_api.py`，覆盖内部接口和外部接口的增删改查全场景。

### 运行

```bash
python -m tsysmart_dev_topo.tools._test_api                  # 仅内部接口（默认）
python -m tsysmart_dev_topo.tools._test_api --external       # 仅外部接口（需先启动 uvicorn）
python -m tsysmart_dev_topo.tools._test_api --all            # 全部（74 项）
```

启动外部服务：

```bash
uvicorn tsysmart_dev_topo:app --host 127.0.0.1 --port 8123
```

### 内部接口（TopoService）测试覆盖（29 项）

| 类别 | 测试内容 | 项数 |
|------|----------|------|
| 插入 | 单条插入 + 回读验证 + 重复 key 报错 | 4 |
| 批量插入 | insert_batch 3 条 + IN 查询验证 + 批量重复报错 | 3 |
| 查询过滤 | 等值 / IN / gt / gte / lt / lte / ne / like / 组合 AND / limit / 全表 | 11 |
| 批量更新 | IN 批量 matched=2 + 验证 | 2 |
| 对象删除 | 按 PK + 验证已删除 | 2 |
| 批量删除 | delete_by IN count=2 | 1 |
| Upsert | 新 key 插入 + 已有 key 更新 | 2 |
| 跨表查询 | ScadaAcNetLoad 过滤 + 空值 | 3 |
| 清理 | TEST_ 前缀数据全清 | 1 |

### 外部接口（HTTP REST）测试覆盖（45 项）

| 类别 | 测试内容 | 项数 |
|------|----------|------|
| 健康检查 | 版本号 | 1 |
| GET 查询 | topology / single device / multi devices / where 过滤 | 8 |
| POST 插入 | 单条 + 回读(name/p_rated) + 重复报错 + 批量 3 条 + IN 验证 + 批量重复报错 | 9 |
| PATCH 更新 | 等值/IN/gt/gte/lt/lte/ne/like/组合AND（9 个操作符）+ 批量 | 9 |
| PUT 更新 | 完整更新 + 回读(name/p_rated) + 不存在 ID matched=0 | 5 |
| DELETE 删除 | 按 URI ID + 回读验证 + 等值条件 + IN 批量 | 5 |
| 跨表查询 | scada_ac_net_load + 过滤 | 4 |
| Cleanup | 清理后验证 0 残留 | 2 |

---

## 开发说明

### 新增/修改拓扑表

1. 编辑 `topo_define/{表名}.csv` 定义字段
2. 运行 `python tools/gen_tables.py` 重新生成 `tables.py`
3. 运行 `python tools/gen_cpp_models.py --input tables.py --output ../../include/devtopolib/tsysmart_dev_topo_model_common.h` 重新生成 C++ 模型头
4. 在 `tools/mappings/` 下新建/更新 `{表名}.py` 字段映射文件（如需要数据导入）
5. 运行 `upgrade.py --rebuild` 自动对比结构并重建变化的表

### 拓扑版本升级

1. 修改 `topo_define/*.csv` 表定义文件
2. 运行 `python tools/gen_tables.py` 重新生成 `tables.py`
3. 运行 `gen_cpp_models.py` 重新生成 C++ 头文件（路径同上）
4. 更新 `meta.json` 版本号
5. 运行 `upgrade.py --rebuild` 自动对比结构并重建变化的表（有数据自动备份恢复）
6. 或运行 `upgrade.py`（增量模式）仅 ADD COLUMN，不丢数据

### 依赖

| 包 | 用途 |
|----|------|
| `fastapi` | REST API 框架 |
| `uvicorn` | ASGI 服务器 |
| `sqlalchemy` | ORM / 数据库抽象 |
| `psycopg-binary` | PostgreSQL 驱动 |
| `tsysmart_proj` | 数据库连接管理（平台依赖） |
| `tsysmart_appmng` | 模块安装管理（平台依赖） |
