453 lines
15 KiB
Markdown
453 lines
15 KiB
Markdown
# 工艺路线默认设备 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:** 在工艺路线「路线图配置」中预制路线级默认设备,派发生产任务选中该路线时自动带入任务「设备」面板。
|
||
|
||
**Architecture:** `biz_process_route` 新增 `equipment_ids` / `equipment_name`;路线更新接口落库快照;创建任务在 `_clone_route_to_task` 同时把路线默认设备拷到 `biz_production_task`。前端在 `ProcessRouteTab` 路线图配置中间栏加「默认设备」虚拟卡,右侧复用 `TaskEquipmentPanel`。
|
||
|
||
**Tech Stack:** FastAPI / SQLAlchemy / Pydantic v2、Vue 3、Element Plus、现有 `resolve_equipment_list_snapshot` / `TaskEquipmentPanel`
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-07-28-process-route-default-equipment-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Responsibility |
|
||
|------|----------------|
|
||
| `docs/sql/alter_biz_process_route_equipment.sql` | ALTER 增加路线默认设备字段 |
|
||
| `backend/app/models/models.py` | `BizProcessRoute` 列定义 |
|
||
| `backend/app/schemas/process_route.py` | UpdateBody 增加 `equipmentIds` |
|
||
| `backend/app/crud/process_route.py` | 序列化 + `update_route` 快照写入 |
|
||
| `backend/app/api/production.py` | update route 传入 `equipment_ids` |
|
||
| `backend/app/crud/production_task.py` | 创建任务时拷贝路线默认设备 |
|
||
| `web-admin/src/views/production/process/ProcessRouteTab.vue` | 路线图配置 UI + 保存 |
|
||
| `web-admin/src/views/production/production-work-order/TaskEquipmentPanel.vue` | 复用(只读引用,不改行为除非必要) |
|
||
|
||
派发任务前端无需改:创建后 `useTaskStepEditor` 已从任务详情读 `equipmentIds`。
|
||
|
||
---
|
||
|
||
### Task 1: DB 迁移 + Model
|
||
|
||
**Files:**
|
||
- Create: `docs/sql/alter_biz_process_route_equipment.sql`
|
||
- Modify: `backend/app/models/models.py`(`BizProcessRoute`,约 2768–2786 行)
|
||
|
||
- [x] **Step 1: 写迁移 SQL**
|
||
|
||
```sql
|
||
-- 工艺路线增加路线级默认设备(派发任务时带入任务设备面板)
|
||
-- 执行:在目标库运行本脚本。
|
||
|
||
ALTER TABLE `biz_process_route`
|
||
ADD COLUMN `equipment_ids` varchar(1000) DEFAULT NULL COMMENT '默认设备ID(biz_equipment.id,逗号分隔)' AFTER `is_current`,
|
||
ADD COLUMN `equipment_name` varchar(1000) DEFAULT NULL COMMENT '默认设备名称快照(逗号分隔)' AFTER `equipment_ids`;
|
||
```
|
||
|
||
- [ ] **Step 2: 更新 SQLAlchemy 模型**
|
||
|
||
在 `BizProcessRoute` 中,`is_current` 与 `tenant_no` 之间插入:
|
||
|
||
```python
|
||
equipment_ids = Column(String(1000), nullable=True, comment="默认设备ID(逗号分隔)")
|
||
equipment_name = Column(String(1000), nullable=True, comment="默认设备名称快照(逗号分隔)")
|
||
```
|
||
|
||
- [ ] **Step 3: 语法校验**
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && python -c "from app.models.models import BizProcessRoute; assert hasattr(BizProcessRoute, 'equipment_ids'); print('ok')"
|
||
```
|
||
|
||
Expected: `ok`
|
||
|
||
- [ ] **Step 4: Commit(若用户要求提交)**
|
||
|
||
```bash
|
||
git add docs/sql/alter_biz_process_route_equipment.sql backend/app/models/models.py
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(process-route): add route-level default equipment columns
|
||
|
||
Store default equipment_ids/equipment_name on biz_process_route for task dispatch autofill.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Schema + CRUD 序列化与更新
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/schemas/process_route.py`
|
||
- Modify: `backend/app/crud/process_route.py`
|
||
- Modify: `backend/app/api/production.py`(`update_process_route`)
|
||
|
||
- [ ] **Step 1: UpdateBody 增加字段**
|
||
|
||
在 `ProcessRouteUpdateBody` 增加:
|
||
|
||
```python
|
||
equipment_ids: Optional[List[str]] = Field(None, alias="equipmentIds")
|
||
```
|
||
|
||
- [ ] **Step 2: `_route_row_to_dict` 返回设备**
|
||
|
||
在 `process_route.py` 顶部增加导入:
|
||
|
||
```python
|
||
from app.crud.equipment_snapshot import parse_equipment_ids, resolve_equipment_list_snapshot
|
||
```
|
||
|
||
`_route_row_to_dict` 在 `"isCurrent"` 之后增加:
|
||
|
||
```python
|
||
"equipmentIds": parse_equipment_ids(getattr(r, "equipment_ids", None)),
|
||
"equipmentName": getattr(r, "equipment_name", None) or "",
|
||
```
|
||
|
||
- [ ] **Step 3: `update_route` 支持 equipment_ids**
|
||
|
||
将 `update_route` 改为(保留原有字段循环,并单独处理设备):
|
||
|
||
```python
|
||
def update_route(db: Session, route_id: str, *, user_id: Optional[str] = None, **kwargs) -> BizProcessRoute:
|
||
obj = get_route(db, route_id)
|
||
if not obj:
|
||
raise ValueError("路线不存在")
|
||
for field in ["route_code", "route_name", "product_type_id", "version", "remark"]:
|
||
val = kwargs.get(field)
|
||
if val is not None:
|
||
setattr(obj, field, (val or "").strip() or None if field != "route_code" else (val or "").strip())
|
||
if "equipment_ids" in kwargs:
|
||
eids, enames = resolve_equipment_list_snapshot(
|
||
db, kwargs.get("equipment_ids"), obj.tenant_no or "zkxw"
|
||
)
|
||
obj.equipment_ids = eids
|
||
obj.equipment_name = enames
|
||
obj.update_by = user_id
|
||
obj.update_time = datetime.now()
|
||
db.add(obj)
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
```
|
||
|
||
注意:清空设备时前端应传 `equipmentIds: []`,且 API 必须把该键放入 kwargs(见下一步 `exclude_unset` 行为——空列表仍会 set,可进入 `"equipment_ids" in kwargs`)。
|
||
|
||
- [ ] **Step 4: API 传入 equipment_ids**
|
||
|
||
`update_process_route` 中 `update_route(...)` 增加:
|
||
|
||
```python
|
||
equipment_ids=payload["equipment_ids"] if "equipment_ids" in payload else None,
|
||
```
|
||
|
||
且仅当 `"equipment_ids" in payload` 时才传入;推荐写法:
|
||
|
||
```python
|
||
payload = body.model_dump(exclude_unset=True, by_alias=False)
|
||
update_kwargs = {
|
||
"user_id": user.get("user_id"),
|
||
"route_code": payload.get("route_code"),
|
||
"route_name": payload.get("route_name"),
|
||
"product_type_id": payload.get("product_type_id"),
|
||
"version": payload.get("version"),
|
||
"remark": payload.get("remark"),
|
||
}
|
||
if "equipment_ids" in payload:
|
||
update_kwargs["equipment_ids"] = payload.get("equipment_ids")
|
||
obj = route_svc.update_route(db, route_id, **update_kwargs)
|
||
```
|
||
|
||
- [ ] **Step 5: 冒烟**
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && python -c "
|
||
from app.schemas.process_route import ProcessRouteUpdateBody
|
||
from app.crud.process_route import _route_row_to_dict
|
||
print(ProcessRouteUpdateBody.model_fields.get('equipment_ids') is not None)
|
||
print('ok')
|
||
"
|
||
```
|
||
|
||
Expected: `True` 然后 `ok`
|
||
|
||
- [ ] **Step 6: Commit(若用户要求提交)**
|
||
|
||
```bash
|
||
git add backend/app/schemas/process_route.py backend/app/crud/process_route.py backend/app/api/production.py
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(process-route): serialize and update route default equipment
|
||
|
||
Expose equipmentIds on route APIs and persist name snapshot via resolve_equipment_list_snapshot.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: 创建任务时带入路线默认设备
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/crud/production_task.py`(`create_production_task`,约 1017–1019 行)
|
||
|
||
- [ ] **Step 1: 在 clone 步骤后写入任务设备**
|
||
|
||
在 `if route_id:` 块内,`_clone_route_to_task(...)` **之后**增加:
|
||
|
||
```python
|
||
route_id = data.get("route_id")
|
||
if route_id:
|
||
_clone_route_to_task(db, task.id, route_id, tenant_no, user_id, create_dept_id)
|
||
route = (
|
||
db.query(BizProcessRoute)
|
||
.filter(
|
||
BizProcessRoute.id == route_id,
|
||
BizProcessRoute.tenant_no == tenant_no,
|
||
BizProcessRoute.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if route and (getattr(route, "equipment_ids", None) or getattr(route, "equipment_name", None)):
|
||
task.equipment_ids = route.equipment_ids
|
||
task.equipment_name = route.equipment_name
|
||
db.add(task)
|
||
```
|
||
|
||
确认文件顶部 / 模型导入已包含 `BizProcessRoute`(若无则从 `app.models.models` 导入)。
|
||
|
||
拷贝策略:直接复制路线已存快照,不二次 `resolve`(与「创建时快照」一致;设备已删时任务仍保留历史名称,与任务后续再保存时再校验的行为可接受)。
|
||
|
||
- [ ] **Step 2: 语法校验**
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && python -c "from app.crud.production_task import create_production_task; print('ok')"
|
||
```
|
||
|
||
Expected: `ok`
|
||
|
||
- [ ] **Step 3: Commit(若用户要求提交)**
|
||
|
||
```bash
|
||
git add backend/app/crud/production_task.py
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(production-task): copy route default equipment on task create
|
||
|
||
When dispatching with a process route, autofill task equipment from route defaults.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: 路线图配置 UI — 默认设备
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/views/production/process/ProcessRouteTab.vue`
|
||
|
||
- [ ] **Step 1: 增加 import**
|
||
|
||
在 script 中增加:
|
||
|
||
```ts
|
||
import TaskEquipmentPanel from '@/views/production/production-work-order/TaskEquipmentPanel.vue'
|
||
import { fetchAllEquipments } from '@/api/production'
|
||
import { parseEquipmentIds } from '@/utils/equipment-select'
|
||
```
|
||
|
||
(`fetchAllEquipments` 可并入现有 `@/api/production` 解构导入列表。)
|
||
|
||
- [ ] **Step 2: 状态与计算属性**
|
||
|
||
在路线图配置相关 state(`selectedStepId` 附近)增加:
|
||
|
||
```ts
|
||
const selectedVirtualStep = ref<'equipment' | null>(null)
|
||
const routeEquipmentIds = ref<string[]>([])
|
||
const equipmentOptions = ref<Record<string, any>[]>([])
|
||
const equipmentSaving = ref(false)
|
||
|
||
const virtualEquipmentCount = computed(() => routeEquipmentIds.value.length)
|
||
const virtualEquipmentStatus = computed(() =>
|
||
routeEquipmentIds.value.length > 0 ? 'filled' : 'empty'
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: 加载参考数据时拉设备;详情加载时回填**
|
||
|
||
`loadConfigReferenceData` 的 `Promise.all` 增加 `fetchAllEquipments()`,结果赋给 `equipmentOptions`。
|
||
|
||
`loadRouteDetail` 在拿到 `detail` 后:
|
||
|
||
```ts
|
||
routeEquipmentIds.value = parseEquipmentIds(detail.equipmentIds || detail.equipmentName)
|
||
```
|
||
|
||
`handleOpenConfig` 开头重置:
|
||
|
||
```ts
|
||
selectedVirtualStep.value = null
|
||
routeEquipmentIds.value = []
|
||
```
|
||
|
||
- [ ] **Step 4: 选中互斥**
|
||
|
||
```ts
|
||
function selectVirtualEquipment() {
|
||
selectedVirtualStep.value = 'equipment'
|
||
selectedStepId.value = ''
|
||
}
|
||
|
||
function selectStep(step: Record<string, any>) {
|
||
selectedVirtualStep.value = null
|
||
selectedStepId.value = step.id
|
||
}
|
||
```
|
||
|
||
拖入步骤 / 复制步骤后若自动选中步骤,保持 `selectedVirtualStep = null`(现有 `selectedStepId = newStep.id` 即可,并显式 `selectedVirtualStep.value = null`)。
|
||
|
||
- [ ] **Step 5: 保存默认设备**
|
||
|
||
```ts
|
||
async function saveRouteEquipment() {
|
||
if (!configRouteId.value) return
|
||
equipmentSaving.value = true
|
||
try {
|
||
await fetchUpdateProcessRoute(configRouteId.value, {
|
||
equipmentIds: routeEquipmentIds.value
|
||
})
|
||
ElMessage.success('默认设备已保存')
|
||
} catch {
|
||
/* 全局处理 */
|
||
} finally {
|
||
equipmentSaving.value = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: 模板 — 中间栏虚拟卡**
|
||
|
||
在 `.route-config-center-dropzone` 内、`<draggable>` **之前**插入(对齐派发任务):
|
||
|
||
```vue
|
||
<div
|
||
class="virtual-step-card"
|
||
:class="{ 'is-active': selectedVirtualStep === 'equipment' }"
|
||
@click="selectVirtualEquipment"
|
||
>
|
||
<div class="virtual-step-icon virtual-step-icon--equipment">🔧</div>
|
||
<div class="virtual-step-info">
|
||
<div class="virtual-step-name">默认设备</div>
|
||
</div>
|
||
<el-tag v-if="virtualEquipmentStatus === 'filled'" type="success" size="small"
|
||
>已选 {{ virtualEquipmentCount }} 台</el-tag
|
||
>
|
||
<el-tag v-else type="info" size="small">暂无</el-tag>
|
||
</div>
|
||
<div class="step-divider"><span>工艺步骤</span></div>
|
||
```
|
||
|
||
- [ ] **Step 7: 模板 — 右侧面板**
|
||
|
||
将右侧 `route-config-right` 改为三态:
|
||
|
||
1. `selectedVirtualStep === 'equipment'` → 标题「默认设备」+ `TaskEquipmentPanel`
|
||
2. `selectedStep` → 现有步骤详情
|
||
3. else → 现有 empty「请选择…」
|
||
|
||
示例结构:
|
||
|
||
```vue
|
||
<div class="route-config-right">
|
||
<template v-if="selectedVirtualStep === 'equipment'">
|
||
<div class="route-config-right-header">
|
||
<h3>默认设备</h3>
|
||
</div>
|
||
<div class="route-config-right-body">
|
||
<TaskEquipmentPanel
|
||
:equipment-ids="routeEquipmentIds"
|
||
:equipment-options="equipmentOptions"
|
||
:saving="equipmentSaving"
|
||
@update:equipment-ids="(ids) => (routeEquipmentIds = ids)"
|
||
@save="saveRouteEquipment"
|
||
/>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="selectedStep">
|
||
<!-- 现有步骤详情不变 -->
|
||
</template>
|
||
<template v-else>
|
||
<div class="route-config-right-empty">
|
||
<el-icon :size="48" color="#d1d5db"><Rank /></el-icon>
|
||
<p>请选择默认设备或左侧步骤查看详情</p>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
```
|
||
|
||
注意:`routeEquipmentIds` 是 `ref`,模板里不用 `.value`;`@update` 回调里应写 `routeEquipmentIds = ids`(模板自动解包)或脚本内函数赋值 `routeEquipmentIds.value = ids`。
|
||
|
||
- [ ] **Step 8: 样式**
|
||
|
||
在本文件 `scoped` style 中复制 `DispatchTaskDialog.vue` 的 `.virtual-step-card` / `.virtual-step-icon` / `.virtual-step-info` / `.virtual-step-name` / `.step-divider`(Fresh Mint,勿改 Token)。可省略 emoji 专用色。
|
||
|
||
- [ ] **Step 9: 手动验收清单**
|
||
|
||
1. 本地执行 `docs/sql/alter_biz_process_route_equipment.sql`
|
||
2. 打开 `/#/production-group/production-base/process` → 工艺路线 → 路线图配置 → 点「默认设备」勾选保存 → 刷新仍在
|
||
3. 生产工单派发任务选该路线 → 编辑阶段「设备」已预填
|
||
4. 任务改设备不影响路线;路线改设备不影响已派发任务
|
||
5. 不选路线派发 → 设备为空
|
||
|
||
- [ ] **Step 10: 更新图谱**
|
||
|
||
```bash
|
||
./scripts/update-graph.sh backend
|
||
./scripts/update-graph.sh frontend
|
||
```
|
||
|
||
- [ ] **Step 11: Commit(若用户要求提交)**
|
||
|
||
```bash
|
||
git add web-admin/src/views/production/process/ProcessRouteTab.vue
|
||
git commit -m "$(cat <<'EOF'
|
||
feat(web-admin): configure process route default equipment in route map
|
||
|
||
Add virtual default-equipment panel in route config and persist via update route API.
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Spec coverage(自检)
|
||
|
||
| Spec 要求 | Task |
|
||
|-----------|------|
|
||
| `biz_process_route` 加字段 | Task 1 |
|
||
| 列表/详情返回 equipmentIds | Task 2 |
|
||
| PUT 更新 + 快照 | Task 2 |
|
||
| 创建任务拷贝到任务 | Task 3 |
|
||
| 路线图配置虚拟卡 + TaskEquipmentPanel | Task 4 |
|
||
| 派发后可改、互不影响 | Task 3 拷贝一次 + 现有任务保存 |
|
||
| 不做步骤级 UI / 不做编辑弹窗字段 | 未列入任务 |
|
||
|
||
## Placeholder scan
|
||
|
||
无 TBD / 「类似 Task N」占位。
|
||
|
||
---
|
||
|
||
## Execution
|
||
|
||
Plan complete and saved to `docs/superpowers/plans/2026-07-28-process-route-default-equipment.md`.
|
||
|
||
**两种执行方式:**
|
||
|
||
1. **Subagent-Driven(推荐)** — 每任务开新子代理,任务间复审
|
||
2. **Inline Execution** — 本会话按 executing-plans 连续做并设检查点
|
||
|
||
选哪种?
|