Files
zkxw/docs/superpowers/plans/2026-06-15-process-equipment-link.md
T
2026-06-16 07:38:27 +08:00

790 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 工艺设备关联设备管理 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将工艺库、工艺路线步骤、生产任务步骤中的「设备名称」自由文本改为关联 `biz_equipment` 主数据(`equipment_id` + `equipment_name` 快照),三处 UI 改为设备下拉选择,步骤级可覆盖。
**Architecture:** 三张业务表新增 `equipment_id` 字段,保留 `equipment_name` 快照;后端统一 `resolve_equipment_snapshot()` 校验租户并写入名称;复制链路(工艺→路线→任务)同步复制 ID+名称;设备删除前检查工艺/路线引用。前端三页复用 `fetchAllEquipments()` 下拉。
**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2 (backend) | Vue 3 + Element Plus (web-admin) | MySQL 5.7
**设计规格文档:** `docs/superpowers/specs/2026-06-15-process-equipment-link-design.md`
---
## 文件清单
| 操作 | 文件 |
|------|------|
| 新建 | `docs/sql/2026-06-15-process-equipment-id.sql` |
| 新建 | `backend/scripts/migrate_process_equipment_id.py` |
| 修改 | `docs/sql/zkxw.sql` |
| 修改 | `backend/app/models/models.py` |
| 修改 | `backend/app/crud/equipment.py` |
| 修改 | `backend/app/crud/process.py` |
| 修改 | `backend/app/crud/process_route.py` |
| 修改 | `backend/app/crud/production_task.py` |
| 修改 | `backend/app/schemas/process_route.py` |
| 修改 | `backend/app/schemas/production_task.py` |
| 修改 | `backend/app/api/production.py` |
| 修改 | `web-admin/src/views/production/process/index.vue` |
| 修改 | `web-admin/src/views/production/process/ProcessRouteTab.vue` |
| 修改 | `web-admin/src/views/production/production-work-order/StepDetailPanel.vue` |
| 修改 | `web-admin/src/views/production/production-work-order/useTaskStepEditor.ts` |
| 可选新建 | `web-admin/src/utils/equipment-select.ts`(下拉 label/历史数据提示 helper,三页复用) |
---
### Task 1: SQL 迁移 — 三表新增 equipment_id
**Files:**
- Create: `docs/sql/2026-06-15-process-equipment-id.sql`
- Modify: `docs/sql/zkxw.sql`(三表 CREATE TABLE 段落同步加字段)
- [ ] **Step 1: 创建增量 SQL**
```sql
-- 工艺设备关联:三表新增 equipment_id
ALTER TABLE biz_process
ADD COLUMN equipment_id varchar(64) DEFAULT NULL COMMENT '关联设备ID(biz_equipment.id)' AFTER description;
ALTER TABLE biz_process_route_step
ADD COLUMN equipment_id varchar(64) DEFAULT NULL COMMENT '关联设备ID(biz_equipment.id)' AFTER description;
ALTER TABLE biz_production_task_step
ADD COLUMN equipment_id varchar(64) DEFAULT NULL COMMENT '关联设备ID(biz_equipment.id)' AFTER description;
ALTER TABLE biz_process
ADD KEY idx_process_equipment (equipment_id, del_flag);
```
- [ ] **Step 2: 在开发/测试库执行**
Run(按实际连接串调整):
```bash
mysql -u root -p zkxw < docs/sql/2026-06-15-process-equipment-id.sql
```
Expected: 3 条 `ALTER TABLE` 成功,无报错。
- [ ] **Step 3: 同步 zkxw.sql 基线**
在 `docs/sql/zkxw.sql` 的 `biz_process`、`biz_process_route_step`、`biz_production_task_step` 建表语句中,`description` 后插入同样的 `equipment_id` 列定义;`biz_process` 增加 `idx_process_equipment` 索引。
- [ ] **Step 4: Commit**
```bash
git add docs/sql/2026-06-15-process-equipment-id.sql docs/sql/zkxw.sql
git commit -m "feat: 三表新增 equipment_id 字段以关联设备管理"
```
---
### Task 2: Model — 三实体加 equipment_id
**Files:**
- Modify: `backend/app/models/models.py`
- [ ] **Step 1: BizProcess 增加字段**
在 `BizProcess.description` 之后添加:
```python
equipment_id = Column(String(64), nullable=True, comment="关联设备ID")
```
- [ ] **Step 2: BizProcessRouteStep 增加字段**
在 `BizProcessRouteStep.description` 之后添加同样一行。
- [ ] **Step 3: BizProductionTaskStep 增加字段**
在 `BizProductionTaskStep.description` 之后添加同样一行。
- [ ] **Step 4: 重启后端确认 ORM 无报错**
Run:
```bash
cd backend && source venv/bin/activate && python -c "from app.models.models import BizProcess, BizProcessRouteStep, BizProductionTaskStep; print('ok')"
```
Expected: `ok`
- [ ] **Step 5: Commit**
```bash
git add backend/app/models/models.py
git commit -m "feat: Model 增加 equipment_id 字段"
```
---
### Task 3: 设备快照 Helper + 删除引用检查
**Files:**
- Modify: `backend/app/crud/equipment.py`
- [ ] **Step 1: 添加 resolve_equipment_snapshot**
在文件顶部 import 区追加 `Tuple`(若尚未导入),并在 `get_equipment` 之后添加:
```python
def resolve_equipment_snapshot(
db: Session,
equipment_id: Optional[str],
tenant_no: str,
) -> Tuple[Optional[str], Optional[str]]:
"""校验设备并返回 (equipment_id, equipment_name) 快照。空 ID 则返回 (None, None)。"""
eid = (equipment_id or "").strip()
if not eid:
return None, None
obj = (
db.query(BizEquipment)
.filter(
BizEquipment.id == eid,
BizEquipment.tenant_no == tenant_no,
BizEquipment.del_flag == 0,
)
.first()
)
if not obj:
raise ValueError("所选设备不存在或已删除")
return obj.id, (obj.equipment_name or "").strip() or obj.equipment_code
def equipment_is_referenced(db: Session, equipment_id: str) -> bool:
from app.models.models import BizProcess, BizProcessRouteStep
eid = (equipment_id or "").strip()
if not eid:
return False
proc_hit = (
db.query(BizProcess.id)
.filter(BizProcess.equipment_id == eid, BizProcess.del_flag == 0)
.first()
)
if proc_hit:
return True
step_hit = (
db.query(BizProcessRouteStep.id)
.filter(BizProcessRouteStep.equipment_id == eid, BizProcessRouteStep.del_flag == 0)
.first()
)
return step_hit is not None
```
- [ ] **Step 2: 修改 soft_delete_equipment**
```python
def soft_delete_equipment(db: Session, equipment_id: str) -> None:
obj = get_equipment(db, equipment_id)
if not obj:
raise ValueError("设备不存在")
if equipment_is_referenced(db, equipment_id):
raise ValueError("该设备已被工艺或工艺路线引用,无法删除")
obj.del_flag = 1
obj.update_time = datetime.now()
db.add(obj)
db.commit()
```
- [ ] **Step 3: 手动验证**
Run:
```bash
cd backend && source venv/bin/activate && python -c "
from app.crud.equipment import resolve_equipment_snapshot, equipment_is_referenced
print('import ok')
"
```
Expected: `import ok`
- [ ] **Step 4: Commit**
```bash
git add backend/app/crud/equipment.py
git commit -m "feat: 设备快照解析与删除引用检查"
```
---
### Task 4: 工艺 CRUD — equipment_id 读写
**Files:**
- Modify: `backend/app/crud/process.py`
- [ ] **Step 1: _row_to_dict 增加 equipmentId**
```python
def _row_to_dict(p: BizProcess) -> Dict[str, Any]:
return {
"id": p.id,
"processCode": p.process_code,
"categoryId": p.category_id or "",
"processName": p.process_name,
"description": p.description or "",
"equipmentId": p.equipment_id or "",
"equipmentName": p.equipment_name or "",
# ... 其余不变
}
```
- [ ] **Step 2: create_process 改签名与写入**
函数参数 `equipment_name` 改为 `equipment_id: Optional[str] = None`,并在 `obj = BizProcess(...)` 前调用:
```python
from app.crud.equipment import resolve_equipment_snapshot
eid, ename = resolve_equipment_snapshot(db, equipment_id, tenant_no)
# BizProcess(...) 内:
equipment_id=eid,
equipment_name=ename,
```
- [ ] **Step 3: update_process 同样处理**
当 `equipment_id is not None`(含显式传空字符串清空)时:
```python
if equipment_id is not None:
eid, ename = resolve_equipment_snapshot(db, equipment_id, obj.tenant_no)
obj.equipment_id = eid
obj.equipment_name = ename
```
移除对 `equipment_name` 参数的直接 setattr 逻辑。
- [ ] **Step 4: Commit**
```bash
git add backend/app/crud/process.py
git commit -m "feat: 工艺 CRUD 支持 equipment_id 并写入名称快照"
```
---
### Task 5: 工艺 API Schema + 路由入参
**Files:**
- Modify: `backend/app/api/production.py`
- [ ] **Step 1: ProcessCreateBody / ProcessUpdateBody**
将 `equipment_name` 字段替换为:
```python
equipment_id: Optional[str] = Field(None, alias="equipmentId")
```
Create/Update 路由调用 `create_process` / `update_process` 时传 `equipment_id=body.equipment_id`(Update 从 payload 取 `equipment_id` key)。
- [ ] **Step 2: 确认 create/update 路由**
`create_process` 调用处示例:
```python
process_svc.create_process(
db,
process_code=body.process_code,
process_name=body.process_name,
category_id=body.category_id,
description=body.description,
equipment_id=body.equipment_id,
# ...
)
```
- [ ] **Step 3: 启动后端 smoke test**
Run:
```bash
cd backend && source venv/bin/activate && python -c "from app.api.production import ProcessCreateBody; print(ProcessCreateBody.model_fields.keys())"
```
Expected: 输出含 `equipment_id`,不含可写 `equipment_name`。
- [ ] **Step 4: Commit**
```bash
git add backend/app/api/production.py
git commit -m "feat: 工艺 API 改为接收 equipmentId"
```
---
### Task 6: 工艺路线步骤 — 复制与更新
**Files:**
- Modify: `backend/app/crud/process_route.py`
- Modify: `backend/app/schemas/process_route.py`
- [ ] **Step 1: _step_row_to_dict 增加 equipmentId**
```python
"equipmentId": s.equipment_id or "",
"equipmentName": s.equipment_name or "",
```
- [ ] **Step 2: add_step 复制 equipment_id**
`BizProcessRouteStep(...)` 构造处增加:
```python
equipment_id=proc.equipment_id,
equipment_name=proc.equipment_name,
```
- [ ] **Step 3: copy_step 复制 equipment_id**
```python
equipment_id=src.equipment_id,
equipment_name=src.equipment_name,
```
- [ ] **Step 4: update_step 支持 equipment_id**
在 `update_step` 中,若 `kwargs.get("equipment_id")` 不为 None(用 sentinel 区分未传 vs 清空,或直接检查 key in kwargs):
```python
from app.crud.equipment import resolve_equipment_snapshot
if "equipment_id" in kwargs:
eid, ename = resolve_equipment_snapshot(db, kwargs.get("equipment_id"), step.tenant_no)
step.equipment_id = eid
step.equipment_name = ename
```
从 `for field in [... "equipment_name" ...]` 循环中**移除** `equipment_name`(不再接受纯文本写入)。
- [ ] **Step 5: StepUpdateBody schema**
`backend/app/schemas/process_route.py`:
```python
equipment_id: Optional[str] = Field(None, alias="equipmentId")
# 删除 equipment_name 字段
```
- [ ] **Step 6: production.py 中 step update 路由传 equipment_id**
找到 `update_route_step` / `StepUpdateBody` 使用处,确保 payload 映射 `equipment_id`。
- [ ] **Step 7: Commit**
```bash
git add backend/app/crud/process_route.py backend/app/schemas/process_route.py backend/app/api/production.py
git commit -m "feat: 工艺路线步骤支持 equipmentId 复制与更新"
```
---
### Task 7: 生产任务步骤 — 复制与更新
**Files:**
- Modify: `backend/app/crud/production_task.py`
- Modify: `backend/app/schemas/production_task.py`
- [ ] **Step 1: _step_to_dict 增加 equipmentId**
```python
"equipmentId": s.equipment_id or "",
```
- [ ] **Step 2: _clone_route_to_task 复制 equipment_id**
```python
equipment_id=rs.equipment_id,
equipment_name=rs.equipment_name,
```
- [ ] **Step 3: add_task_step(process_id=...) 复制 equipment_id**
`BizProductionTaskStep(...)` 从工艺复制处增加 `equipment_id=proc.equipment_id`。
- [ ] **Step 4: add_task_step 无 process_id 分支**
改为接受 `equipment_id`,经 `resolve_equipment_snapshot` 写入;移除 `equipment_name` 直写。
- [ ] **Step 5: update_task_step**
```python
if "equipment_id" in data:
eid, ename = resolve_equipment_snapshot(db, data.get("equipment_id"), tenant_no)
step.equipment_id = eid
step.equipment_name = ename
```
从通用 field 循环中移除 `equipment_name`。
- [ ] **Step 6: copy_task_step 复制 equipment_id**
```python
equipment_id=src.equipment_id,
equipment_name=src.equipment_name,
```
- [ ] **Step 7: TaskStepAdd / TaskStepUpdate schema**
```python
# TaskStepAdd
equipment_id: Optional[str] = Field(None, alias="equipmentId")
# TaskStepUpdate
equipment_id: Optional[str] = Field(None, alias="equipmentId")
```
删除两个 schema 中的 `equipment_name` 可写字段。
- [ ] **Step 8: Commit**
```bash
git add backend/app/crud/production_task.py backend/app/schemas/production_task.py backend/app/api/production.py
git commit -m "feat: 生产任务步骤支持 equipmentId 复制与更新"
```
---
### Task 8: 历史数据迁移脚本
**Files:**
- Create: `backend/scripts/migrate_process_equipment_id.py`
- [ ] **Step 1: 编写迁移脚本**
```python
#!/usr/bin/env python3
"""按 equipment_name 精确匹配回填 equipment_id。用法: python scripts/migrate_process_equipment_id.py [--dry-run]"""
import argparse
from sqlalchemy import and_
from app.core.database import SessionLocal
from app.models.models import BizProcess, BizProcessRouteStep, BizProductionTaskStep, BizEquipment
TABLES = [
("biz_process", BizProcess),
("biz_process_route_step", BizProcessRouteStep),
("biz_production_task_step", BizProductionTaskStep),
]
def migrate_table(db, model, dry_run: bool) -> int:
updated = 0
rows = db.query(model).filter(model.del_flag == 0, model.equipment_id.is_(None)).all()
for row in rows:
name = (row.equipment_name or "").strip()
if not name:
continue
eq = (
db.query(BizEquipment)
.filter(
BizEquipment.tenant_no == row.tenant_no,
BizEquipment.del_flag == 0,
BizEquipment.equipment_name == name,
)
.first()
)
if not eq:
continue
if not dry_run:
row.equipment_id = eq.id
updated += 1
return updated
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
db = SessionLocal()
try:
for label, model in TABLES:
n = migrate_table(db, model, args.dry_run)
print(f"{label}: matched {n}")
if not args.dry_run:
db.commit()
print("committed")
else:
print("dry-run, no commit")
finally:
db.close()
if __name__ == "__main__":
main()
```
- [ ] **Step 2: dry-run 执行**
Run:
```bash
cd backend && source venv/bin/activate && python scripts/migrate_process_equipment_id.py --dry-run
```
Expected: 打印各表匹配数量,无异常。
- [ ] **Step 3: Commit**
```bash
git add backend/scripts/migrate_process_equipment_id.py
git commit -m "feat: 历史工艺设备名称回填 equipment_id 脚本"
```
---
### Task 9: 前端 — 工艺列表/表单设备下拉
**Files:**
- Modify: `web-admin/src/views/production/process/index.vue`
- 可选: `web-admin/src/utils/equipment-select.ts`
- [ ] **Step 1: 引入 fetchAllEquipments**
```typescript
import { fetchAllEquipments, /* 现有 imports */ } from '@/api/production'
```
- [ ] **Step 2: 增加设备选项与 label helper**
```typescript
const equipmentOptions = ref<any[]>([])
function equipmentOptionLabel(row: { equipmentCode?: string; equipmentName?: string }) {
const code = row.equipmentCode || ''
const name = row.equipmentName || ''
return code && name ? `${code} / ${name}` : name || code || '-'
}
async function loadEquipmentOptions() {
equipmentOptions.value = await fetchAllEquipments()
}
```
在 `loadReferenceData()` 或 `handleAdd`/`handleEdit` 的 `Promise.all` 中加入 `loadEquipmentOptions()`。
- [ ] **Step 3: formData 改 equipmentId**
```typescript
const formData = reactive({
// ...
equipmentId: '',
// 移除 equipmentName 作为表单字段(展示用 detail 快照即可)
})
```
`resetForm`、`handleEdit`、`handleSubmit` 中:
- 读取 `detail.equipmentId`
- 提交 `{ equipmentId: formData.equipmentId || undefined }`,**不再传 equipmentName**
- [ ] **Step 4: 模板替换 el-input → el-select**
```vue
<el-form-item label="设备名称">
<el-select
v-model="formData.equipmentId"
placeholder="请选择设备"
clearable
filterable
style="width: 100%"
>
<el-option
v-for="eq in equipmentOptions"
:key="eq.id"
:label="equipmentOptionLabel(eq)"
:value="eq.id"
:disabled="eq.status === '0'"
/>
</el-select>
<p v-if="!formData.equipmentId && formDataLegacyEquipmentName" class="form-tip">
历史文本:{{ formDataLegacyEquipmentName }},请重新选择设备
</p>
</el-form-item>
```
列表列仍展示 `equipmentName`(快照),无需改 prop。
- [ ] **Step 5: 本地 dev 冒烟**
Run: `cd web-admin && pnpm dev`,打开 `/#/production/process`(或实际菜单路径),新增/编辑工艺选设备并保存。
- [ ] **Step 6: Commit**
```bash
git add web-admin/src/views/production/process/index.vue
git commit -m "feat: 工艺表单设备改为下拉关联设备管理"
```
---
### Task 10: 前端 — 工艺路线步骤设备下拉
**Files:**
- Modify: `web-admin/src/views/production/process/ProcessRouteTab.vue`
- [ ] **Step 1: 加载 equipmentOptions(同 Task 9)**
在 `loadReferenceData` 或组件 mount 时调用 `fetchAllEquipments()`。
- [ ] **Step 2: 模板替换设备输入框**
将 `selectedStep.equipmentName` 的 `el-input` 改为 `el-select`,`v-model="selectedStep.equipmentId"`。
- [ ] **Step 3: saveStepBasic 改 payload**
```typescript
async function saveStepBasic() {
// ...
await fetchUpdateRouteStep({
stepId: selectedStep.value.id,
// ...
equipmentId: selectedStep.value.equipmentId || null
})
// 本地同步展示名(可选,便于 UI 即时反馈)
const eq = equipmentOptions.value.find((e) => e.id === selectedStep.value.equipmentId)
if (eq) selectedStep.value.equipmentName = eq.equipmentName
}
```
- [ ] **Step 4: 历史数据提示**
当 `!selectedStep.equipmentId && selectedStep.equipmentName` 时显示「历史文本:xxx」。
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/views/production/process/ProcessRouteTab.vue
git commit -m "feat: 工艺路线步骤设备改为下拉选择"
```
---
### Task 11: 前端 — 生产任务步骤设备下拉
**Files:**
- Modify: `web-admin/src/views/production/production-work-order/StepDetailPanel.vue`
- Modify: `web-admin/src/views/production/production-work-order/useTaskStepEditor.ts`
- [ ] **Step 1: useTaskStepEditor 加载 equipmentOptions**
在 editor 初始化或 `loadTaskDetail` 后加载 `fetchAllEquipments()`,export `equipmentOptions` 与 `equipmentOptionLabel`。
- [ ] **Step 2: saveStepBasic 改 payload**
```typescript
await fetchUpdateTaskStep(taskId.value, selectedStep.value.id, {
process_name: selectedStep.value.processName,
category_id: selectedStep.value.categoryId,
description: selectedStep.value.description,
equipment_id: selectedStep.value.equipmentId || null,
estimated_duration: selectedStep.value.estimatedDuration,
sop_ids: selectedStep.value.sopIds
}, operateScope.value)
```
- [ ] **Step 3: StepDetailPanel.vue 模板**
```vue
<el-form-item label="设备名称">
<el-select
v-model="editor.selectedStep.value.equipmentId"
placeholder="请选择设备"
clearable
filterable
:disabled="readonly"
style="width: 100%"
@change="editor.saveStepBasic()"
>
<el-option
v-for="eq in editor.equipmentOptions.value"
:key="eq.id"
:label="editor.equipmentOptionLabel(eq)"
:value="eq.id"
:disabled="eq.status === '0'"
/>
</el-select>
</el-form-item>
```
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/views/production/production-work-order/StepDetailPanel.vue \
web-admin/src/views/production/production-work-order/useTaskStepEditor.ts
git commit -m "feat: 生产任务步骤设备改为下拉选择"
```
---
### Task 12: 联调验证 + 字段一致性 + 知识图谱
**Files:**
- 无新文件
- [ ] **Step 1: 字段一致性检查**
Run:
```bash
cd backend && source venv/bin/activate && python scripts/check_field_consistency.py
```
Expected: process 相关无新增不一致告警(若有,按 `_row_to_dict` 补全 Create/Update schema 与前端 formData)。
- [ ] **Step 2: 端到端手工测试清单**
| # | 操作 | 预期 |
|---|------|------|
| 1 | 设备管理新建设备 A | 成功 |
| 2 | 工艺库新建工艺,选设备 A | 列表显示 A 名称;DB 有 equipment_id |
| 3 | 工艺路线拖入该工艺 | 步骤 equipmentId = A |
| 4 | 路线步骤改选设备 B | 仅步骤变更,工艺仍为 A |
| 5 | 生产任务选该路线 | 任务步骤 equipmentId = B |
| 6 | 任务步骤改选设备 C | 保存成功 |
| 7 | 删除设备 A(工艺仍引用) | 报错「已被引用」 |
| 8 | 清空工艺设备 | equipment_id/name 均为 NULL |
- [ ] **Step 3: 更新知识图谱**
Run:
```bash
./scripts/update-graph.sh all
```
- [ ] **Step 4: 更新设计文档状态**
将 `docs/superpowers/specs/2026-06-15-process-equipment-link-design.md` 头部 `状态: 待确认` 改为 `状态: 已实现`。
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/specs/2026-06-15-process-equipment-link-design.md backend/graphify-out web-admin/graphify-out
git commit -m "docs: 工艺设备关联功能完成并更新设计状态"
```
---
## Spec 覆盖自检
| Spec 章节 | 对应 Task |
|-----------|-----------|
| 2 需求共识(步骤级覆盖) | Task 6–7, 10–11 |
| 3 数据流复制点 | Task 6–7 |
| 4 数据库变更 | Task 1–2 |
| 4.4 历史迁移 | Task 8 |
| 5.2 CRUD helper | Task 3 |
| 5.3 API Schema | Task 5–7 |
| 5.4 复制逻辑 | Task 6–7 |
| 6 前端三页 | Task 9–11 |
| 7 设备删除约束 | Task 3 |
| 8 测试要点 | Task 12 |
无遗漏项。
---
## 执行选项
Plan complete and saved to `docs/superpowers/plans/2026-06-15-process-equipment-link.md`.
**两种执行方式:**
1. **Subagent-Driven(推荐)** — 按 Task 1→12 逐任务派发子 agent,每任务完成后 review
2. **Inline Execution** — 在本会话中按 Task 批量实现,每 2–3 个 Task 设检查点
你希望用哪种方式开始实现?