927 lines
30 KiB
Markdown
927 lines
30 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_production_task_step_assignee`(对齐 `biz_qc_task_assignee`);步骤表增加 `planned_start_time`、`completed_by`;`PUT /production-tasks/{id}/steps/dispatch` 批量 sync 负责人;mine 鉴权从 `assert_task_assignee` 改为「任务级任一步 assignee OR 步骤级 assignee」+ 步骤写操作 `assert_step_assignee`;前端新增 `DispatchStepDialog.vue`,移除任务级负责人表单,隐藏任务转发/改派入口。
|
||
|
||
**Tech Stack:** MySQL 5.7 · FastAPI · SQLAlchemy · Pydantic v2 · Vue 3 · Element Plus · pnpm
|
||
|
||
**Design Spec:** `docs/superpowers/specs/2026-06-25-production-step-dispatch-design.md`
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| 文件 | 职责 |
|
||
|------|------|
|
||
| `docs/sql/migrations/2026-06-25-production-step-dispatch.sql` | 步骤字段 + assignee 表 |
|
||
| `docs/sql/migrations/2026-06-25-production-step-dispatch-backfill.sql` | 可选:旧任务 task assignee → 全步骤 assignee |
|
||
| `docs/sql/zkxw.sql` | 同步 DDL |
|
||
| `backend/app/models/models.py` | `BizProductionTaskStep` 新列 + `BizProductionTaskStepAssignee` |
|
||
| `backend/app/models/__init__.py` | export 新模型 |
|
||
| `backend/app/crud/production_task_step_assignee.py` | **新建** sync / enrich / validate |
|
||
| `backend/app/crud/production_task.py` | mine 过滤、dispatch、assert、completed_by、create 去 assignee 必填 |
|
||
| `backend/app/schemas/production_task.py` | `ProductionTaskStepDispatchBody`;Create 去必填 assignee |
|
||
| `backend/app/api/production.py` | `PUT steps/dispatch`;`_assert_*` 改造 |
|
||
| `web-admin/src/api/production-task.ts` | `fetchDispatchProductionTaskSteps`;create 去 assigneeUserId |
|
||
| `web-admin/src/views/production/production-work-order/DispatchStepDialog.vue` | **新建** 步骤派发表格弹窗 |
|
||
| `web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue` | 移除任务负责人;B 入口打开步骤派发 |
|
||
| `web-admin/src/views/production/production-work-order/WorkOrderTaskSubList.vue` | 步骤派发按钮;隐藏转发/改派;展示步骤负责人 |
|
||
| `web-admin/src/views/production/production-work-order/TaskExecutionDialog.vue` | mine 步骤过滤 + 只读样式 |
|
||
| `web-admin/src/views/production/production-work-order/StepDetailPanel.vue` | `canEditStep` 控制按钮 |
|
||
| `web-admin/src/views/production/production-work-order/useTaskStepEditor.ts` | `canEditStep` / 完成写 completed_by 透传 |
|
||
|
||
---
|
||
|
||
## Task 1: 数据库迁移
|
||
|
||
**Files:**
|
||
- Create: `docs/sql/migrations/2026-06-25-production-step-dispatch.sql`
|
||
- Create: `docs/sql/migrations/2026-06-25-production-step-dispatch-backfill.sql`
|
||
- Modify: `docs/sql/zkxw.sql`(`biz_production_task_step` 与新建表片段)
|
||
|
||
- [ ] **Step 1: 编写主 migration**
|
||
|
||
```sql
|
||
-- docs/sql/migrations/2026-06-25-production-step-dispatch.sql
|
||
|
||
ALTER TABLE `biz_production_task_step`
|
||
ADD COLUMN `planned_start_time` datetime DEFAULT NULL COMMENT '计划开始时间' AFTER `step_status`;
|
||
|
||
ALTER TABLE `biz_production_task_step`
|
||
ADD COLUMN `completed_by` varchar(64) DEFAULT NULL COMMENT '完成步骤的用户ID' AFTER `completed_time`;
|
||
|
||
CREATE TABLE IF NOT EXISTS `biz_production_task_step_assignee` (
|
||
`id` varchar(64) NOT NULL COMMENT '主键',
|
||
`step_id` varchar(64) NOT NULL COMMENT '生产任务步骤ID',
|
||
`user_id` varchar(64) NOT NULL COMMENT '负责人用户ID',
|
||
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
|
||
`order_num` int DEFAULT 0 COMMENT '展示顺序',
|
||
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
|
||
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人id',
|
||
`create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||
`update_by` varchar(64) DEFAULT NULL COMMENT '更新人id',
|
||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||
`del_flag` tinyint NOT NULL DEFAULT 0 COMMENT '删除标记',
|
||
PRIMARY KEY (`id`),
|
||
KEY `idx_step_assignee_step` (`step_id`),
|
||
KEY `idx_step_assignee_user` (`user_id`, `tenant_no`)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生产任务步骤负责人';
|
||
```
|
||
|
||
- [ ] **Step 2: 编写可选 backfill(旧数据兼容)**
|
||
|
||
```sql
|
||
-- docs/sql/migrations/2026-06-25-production-step-dispatch-backfill.sql
|
||
-- 将仍有 task.current_assignee_user_id 且无步骤负责人的任务,回填到全部步骤
|
||
|
||
INSERT INTO biz_production_task_step_assignee (
|
||
id, step_id, user_id, tenant_no, order_num,
|
||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||
)
|
||
SELECT
|
||
REPLACE(UUID(), '-', ''),
|
||
s.id,
|
||
t.current_assignee_user_id,
|
||
s.tenant_no,
|
||
0,
|
||
t.create_by,
|
||
IFNULL(t.create_dept_id, ''),
|
||
NOW(),
|
||
t.update_by,
|
||
NOW(),
|
||
0
|
||
FROM biz_production_task t
|
||
JOIN biz_production_task_step s ON s.task_id = t.id AND s.del_flag = 0
|
||
WHERE t.del_flag = 0
|
||
AND t.current_assignee_user_id IS NOT NULL
|
||
AND TRIM(t.current_assignee_user_id) <> ''
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM biz_production_task_step_assignee a
|
||
WHERE a.step_id = s.id AND a.del_flag = 0
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 3: 在开发/测试库执行主 migration**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
mysql -u ... -p ... zkxw < docs/sql/migrations/2026-06-25-production-step-dispatch.sql
|
||
```
|
||
|
||
Expected: 两列新增成功;`biz_production_task_step_assignee` 表存在。
|
||
|
||
- [ ] **Step 4: 同步 `docs/sql/zkxw.sql` 对应 DDL**
|
||
|
||
---
|
||
|
||
## Task 2: Backend 模型
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/models/models.py`
|
||
- Modify: `backend/app/models/__init__.py`
|
||
|
||
- [ ] **Step 1: 扩展 `BizProductionTaskStep`**
|
||
|
||
在 `step_status` 后、`started_time` 前增加:
|
||
|
||
```python
|
||
planned_start_time = Column(DateTime, nullable=True, comment="计划开始时间")
|
||
```
|
||
|
||
在 `completed_time` 后增加:
|
||
|
||
```python
|
||
completed_by = Column(String(64), nullable=True, comment="完成步骤的用户ID")
|
||
```
|
||
|
||
- [ ] **Step 2: 新增 `BizProductionTaskStepAssignee`**
|
||
|
||
放在 `BizProductionTaskStep` 附近,字段对齐 migration SQL(参照 `BizQcTaskAssignee`)。
|
||
|
||
- [ ] **Step 3: 注册 export**
|
||
|
||
`backend/app/models/__init__.py`:
|
||
|
||
```python
|
||
from app.models.models import (
|
||
# ...
|
||
BizProductionTaskStepAssignee,
|
||
)
|
||
__all__ = [
|
||
# ...
|
||
"BizProductionTaskStepAssignee",
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Backend Schema
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/schemas/production_task.py`
|
||
|
||
- [ ] **Step 1: 创建任务 body 去掉 assignee 必填**
|
||
|
||
```python
|
||
class ProductionTaskCreate(BaseModel):
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
work_order_id: str
|
||
assignee_user_id: Optional[str] = Field(None, alias="assigneeUserId") # deprecated,可选
|
||
task_type: Optional[str] = 'cell_storage'
|
||
task_name: Optional[str] = None
|
||
route_id: Optional[str] = None
|
||
description: Optional[str] = None
|
||
remark: Optional[str] = None
|
||
```
|
||
|
||
- [ ] **Step 2: 新增步骤派发 body**
|
||
|
||
```python
|
||
class ProductionTaskStepDispatchItem(BaseModel):
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
step_id: str = Field(..., alias="stepId")
|
||
assignee_user_ids: List[str] = Field(..., alias="assigneeUserIds", min_length=1)
|
||
planned_start_time: Optional[datetime] = Field(None, alias="plannedStartTime")
|
||
|
||
|
||
class ProductionTaskStepDispatchBody(BaseModel):
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
steps: List[ProductionTaskStepDispatchItem] = Field(..., min_length=1)
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: 步骤负责人 CRUD 模块
|
||
|
||
**Files:**
|
||
- Create: `backend/app/crud/production_task_step_assignee.py`
|
||
|
||
- [ ] **Step 1: 创建模块(复制 QC 模式并改表名)**
|
||
|
||
```python
|
||
"""生产任务步骤负责人 CRUD"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Dict, List, Optional, Set
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db_collation import eq_unicode
|
||
from app.core.utils import generate_uuid
|
||
from app.models.models import BizProductionTaskStepAssignee, SysUser
|
||
|
||
# validate_assignee_user_ids — 与 qc_task_assignee.validate_assignee_user_ids 相同逻辑
|
||
|
||
|
||
def sync_assignees_for_step(
|
||
db: Session,
|
||
step_id: str,
|
||
user_ids: List[str],
|
||
*,
|
||
tenant_no: str,
|
||
operator_id: Optional[str] = None,
|
||
create_dept_id: str = "",
|
||
) -> None:
|
||
"""全量替换步骤负责人(软删旧 + 插入新)。"""
|
||
# 实现同 qc_task_assignee.sync_assignees_for_task,表换 BizProductionTaskStepAssignee,FK 换 step_id
|
||
|
||
|
||
def enrich_step_assignees(
|
||
db: Session, steps: List[Dict], *, tenant_no: str
|
||
) -> None:
|
||
"""批量填充 assigneeUserIds / assigneeNames / assignees。"""
|
||
step_ids = [s["id"] for s in steps if s.get("id")]
|
||
if not step_ids:
|
||
return
|
||
rows = (
|
||
db.query(BizProductionTaskStepAssignee, SysUser)
|
||
.outerjoin(SysUser, eq_unicode(SysUser.id, BizProductionTaskStepAssignee.user_id))
|
||
.filter(
|
||
BizProductionTaskStepAssignee.step_id.in_(step_ids),
|
||
BizProductionTaskStepAssignee.tenant_no == tenant_no,
|
||
BizProductionTaskStepAssignee.del_flag == 0,
|
||
)
|
||
.order_by(BizProductionTaskStepAssignee.order_num.asc())
|
||
.all()
|
||
)
|
||
by_step: Dict[str, List] = {}
|
||
for assignee, user in rows:
|
||
by_step.setdefault(assignee.step_id, []).append((assignee, user))
|
||
for step in steps:
|
||
pairs = by_step.get(step["id"], [])
|
||
step["assigneeUserIds"] = [a.user_id for a, _ in pairs]
|
||
step["assigneeNames"] = [
|
||
f"{(u.nick_name or u.user_name or '')}({u.user_no or ''})".strip()
|
||
for _, u in pairs if u
|
||
]
|
||
step["assignees"] = [
|
||
{
|
||
"userId": a.user_id,
|
||
"nickName": (u.nick_name or u.user_name or "") if u else "",
|
||
"userNo": (u.user_no or "") if u else "",
|
||
}
|
||
for a, u in pairs
|
||
]
|
||
|
||
|
||
def user_is_step_assignee(
|
||
db: Session, step_id: str, user_id: str, tenant_no: str
|
||
) -> bool:
|
||
uid = (user_id or "").strip()
|
||
if not uid:
|
||
return False
|
||
return (
|
||
db.query(BizProductionTaskStepAssignee.id)
|
||
.filter(
|
||
BizProductionTaskStepAssignee.step_id == step_id,
|
||
BizProductionTaskStepAssignee.user_id == uid,
|
||
BizProductionTaskStepAssignee.tenant_no == tenant_no,
|
||
BizProductionTaskStepAssignee.del_flag == 0,
|
||
)
|
||
.first()
|
||
is not None
|
||
)
|
||
|
||
|
||
def user_is_any_step_assignee_of_task(
|
||
db: Session, task_id: str, user_id: str, tenant_no: str
|
||
) -> bool:
|
||
from app.models.models import BizProductionTaskStep
|
||
|
||
uid = (user_id or "").strip()
|
||
if not uid:
|
||
return False
|
||
return (
|
||
db.query(BizProductionTaskStepAssignee.id)
|
||
.join(
|
||
BizProductionTaskStep,
|
||
BizProductionTaskStep.id == BizProductionTaskStepAssignee.step_id,
|
||
)
|
||
.filter(
|
||
BizProductionTaskStep.task_id == task_id,
|
||
BizProductionTaskStep.del_flag == 0,
|
||
BizProductionTaskStepAssignee.user_id == uid,
|
||
BizProductionTaskStepAssignee.tenant_no == tenant_no,
|
||
BizProductionTaskStepAssignee.del_flag == 0,
|
||
)
|
||
.first()
|
||
is not None
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: 本地 import smoke test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate
|
||
python -c "from app.crud import production_task_step_assignee; print('ok')"
|
||
```
|
||
|
||
Expected: `ok`
|
||
|
||
---
|
||
|
||
## Task 5: production_task.py — 步骤 dict、派发、mine 过滤
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/crud/production_task.py`
|
||
|
||
- [ ] **Step 1: 扩展 `_step_to_dict`**
|
||
|
||
```python
|
||
def _step_to_dict(s: BizProductionTaskStep) -> Dict[str, Any]:
|
||
return {
|
||
# ... existing fields ...
|
||
"stepStatus": s.step_status or "dzx",
|
||
"plannedStartTime": _fmt_dt(getattr(s, "planned_start_time", None)),
|
||
"startedTime": _fmt_dt(s.started_time),
|
||
"completedTime": _fmt_dt(s.completed_time),
|
||
"completedBy": getattr(s, "completed_by", None) or "",
|
||
"completedByName": "",
|
||
"assigneeUserIds": [],
|
||
"assigneeNames": [],
|
||
"assignees": [],
|
||
"remark": s.remark or "",
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: `create_production_task` 去掉 assignee 必填**
|
||
|
||
删除或注释:
|
||
|
||
```python
|
||
assignee_raw = (data.get("assignee_user_id") or data.get("assigneeUserId") or "").strip()
|
||
assignee_user_id = _validate_assignee_user_id(db, assignee_raw, tenant_no)
|
||
```
|
||
|
||
改为:
|
||
|
||
```python
|
||
assignee_raw = (data.get("assignee_user_id") or data.get("assigneeUserId") or "").strip()
|
||
assignee_user_id = assignee_raw.strip() or None
|
||
if assignee_raw.strip():
|
||
assignee_user_id = _validate_assignee_user_id(db, assignee_raw, tenant_no)
|
||
```
|
||
|
||
创建任务时:
|
||
|
||
```python
|
||
current_assignee_user_id=assignee_user_id, # 可为 NULL
|
||
```
|
||
|
||
- [ ] **Step 3: 新增 `dispatch_task_steps`**
|
||
|
||
```python
|
||
def dispatch_task_steps(
|
||
db: Session,
|
||
task_id: str,
|
||
*,
|
||
tenant_no: str,
|
||
steps_payload: List[Dict[str, Any]],
|
||
operator_id: Optional[str] = None,
|
||
create_dept_id: str = "",
|
||
) -> Dict[str, Any]:
|
||
from app.crud import production_task_step_assignee as step_assignee_svc
|
||
|
||
task = get_production_task_row(db, task_id, tenant_no)
|
||
if not task:
|
||
raise ValueError("任务不存在")
|
||
if not steps_payload:
|
||
raise ValueError("请至少指定一个步骤的负责人")
|
||
|
||
step_rows = {
|
||
s.id: s
|
||
for s in db.query(BizProductionTaskStep).filter(
|
||
BizProductionTaskStep.task_id == task_id,
|
||
BizProductionTaskStep.tenant_no == tenant_no,
|
||
BizProductionTaskStep.del_flag == 0,
|
||
).all()
|
||
}
|
||
|
||
for item in steps_payload:
|
||
sid = (item.get("step_id") or item.get("stepId") or "").strip()
|
||
if sid not in step_rows:
|
||
raise ValueError(f"步骤不存在或不属于该任务: {sid}")
|
||
user_ids = item.get("assignee_user_ids") or item.get("assigneeUserIds") or []
|
||
step_assignee_svc.sync_assignees_for_step(
|
||
db, sid, user_ids,
|
||
tenant_no=tenant_no,
|
||
operator_id=operator_id,
|
||
create_dept_id=create_dept_id,
|
||
)
|
||
step = step_rows[sid]
|
||
pst = item.get("planned_start_time") or item.get("plannedStartTime")
|
||
if pst is None and "plannedStartTime" in item and item.get("plannedStartTime") is None:
|
||
step.planned_start_time = None
|
||
elif pst:
|
||
from dateutil.parser import parse as _parse_dt
|
||
step.planned_start_time = _parse_dt(str(pst)) if not isinstance(pst, datetime) else pst
|
||
step.update_by = operator_id
|
||
step.update_time = datetime.now()
|
||
db.add(step)
|
||
|
||
db.flush()
|
||
detail = get_production_task_detail(db, task_id, tenant_no)
|
||
if not detail:
|
||
raise ValueError("任务不存在")
|
||
return detail
|
||
```
|
||
|
||
- [ ] **Step 4: 改造 `list_production_tasks` mine 过滤**
|
||
|
||
新增参数 `step_assignee_user_id: Optional[str] = None`。
|
||
|
||
在 `current_assignee_user_id` 过滤块 **之后** 增加:
|
||
|
||
```python
|
||
sa_uid = (step_assignee_user_id or "").strip()
|
||
if sa_uid:
|
||
step_task_ids = (
|
||
db.query(BizProductionTaskStep.task_id)
|
||
.join(
|
||
BizProductionTaskStepAssignee,
|
||
BizProductionTaskStepAssignee.step_id == BizProductionTaskStep.id,
|
||
)
|
||
.filter(
|
||
BizProductionTaskStepAssignee.user_id == sa_uid,
|
||
BizProductionTaskStepAssignee.tenant_no == tenant_no,
|
||
BizProductionTaskStepAssignee.del_flag == 0,
|
||
BizProductionTaskStep.del_flag == 0,
|
||
BizProductionTask.del_flag == 0,
|
||
)
|
||
.distinct()
|
||
.subquery()
|
||
)
|
||
q = q.filter(
|
||
or_(
|
||
BizProductionTask.id.in_(step_task_ids),
|
||
BizProductionTask.current_assignee_user_id == sa_uid, # 旧数据 fallback
|
||
)
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: 改造 `list_mine_production_tasks`**
|
||
|
||
`scope=current` 时调用:
|
||
|
||
```python
|
||
return list_production_tasks(
|
||
# ...
|
||
step_assignee_user_id=uid,
|
||
current_assignee_user_id=None, # 不再仅用 task 级
|
||
# ...
|
||
)
|
||
```
|
||
|
||
保留 `current_assignee_user_id=uid` 作为 OR 条件已在 Step 4 合并;或直接只传 `step_assignee_user_id=uid` 并在 SQL 内 OR task assignee fallback。
|
||
|
||
- [ ] **Step 6: 新增 assert 函数**
|
||
|
||
```python
|
||
def assert_task_step_assignee_or_legacy(
|
||
db: Session, task_id: str, user_id: str, tenant_no: str
|
||
) -> BizProductionTask:
|
||
from app.crud import production_task_step_assignee as step_assignee_svc
|
||
|
||
task = get_production_task_row(db, task_id, tenant_no)
|
||
if not task:
|
||
raise ValueError("任务不存在")
|
||
if step_assignee_svc.user_is_any_step_assignee_of_task(db, task_id, user_id, tenant_no):
|
||
return task
|
||
if (task.current_assignee_user_id or "") == (user_id or ""):
|
||
return task
|
||
raise PermissionError("非任务步骤负责人,无权操作")
|
||
|
||
|
||
def assert_step_assignee_for_write(
|
||
db: Session,
|
||
step_id: str,
|
||
user_id: str,
|
||
tenant_no: str,
|
||
*,
|
||
manage: bool = False,
|
||
) -> BizProductionTaskStep:
|
||
if manage:
|
||
step = db.query(BizProductionTaskStep).filter(
|
||
BizProductionTaskStep.id == step_id,
|
||
BizProductionTaskStep.tenant_no == tenant_no,
|
||
BizProductionTaskStep.del_flag == 0,
|
||
).first()
|
||
if not step:
|
||
raise ValueError("步骤不存在")
|
||
return step
|
||
from app.crud import production_task_step_assignee as step_assignee_svc
|
||
step = db.query(BizProductionTaskStep).filter(
|
||
BizProductionTaskStep.id == step_id,
|
||
BizProductionTaskStep.tenant_no == tenant_no,
|
||
BizProductionTaskStep.del_flag == 0,
|
||
).first()
|
||
if not step:
|
||
raise ValueError("步骤不存在")
|
||
if not step_assignee_svc.user_is_step_assignee(db, step_id, user_id, tenant_no):
|
||
raise PermissionError("非当前步骤负责人,无权操作")
|
||
return step
|
||
```
|
||
|
||
- [ ] **Step 7: `update_task_step` 写 `completed_by`**
|
||
|
||
当 `data.get("step_status") == "ywc"` 且 `user_id` 存在:
|
||
|
||
```python
|
||
step.completed_by = user_id
|
||
```
|
||
|
||
- [ ] **Step 8: `get_production_task_detail` enrich 步骤 assignee**
|
||
|
||
加载 steps 后调用:
|
||
|
||
```python
|
||
from app.crud.production_task_step_assignee import enrich_step_assignees
|
||
enrich_step_assignees(db, steps, tenant_no=tenant_no)
|
||
# 可选:_enrich_completed_by_names
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: API 路由与鉴权
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/api/production.py`
|
||
|
||
- [ ] **Step 1: 改造 `_assert_production_task_write_access`**
|
||
|
||
```python
|
||
def _assert_production_task_write_access(
|
||
db: Session,
|
||
user: dict,
|
||
task_id: str,
|
||
tenant_no: str,
|
||
operate_scope: Optional[str],
|
||
) -> None:
|
||
scope = (operate_scope or "manage").strip().lower()
|
||
user_id = _get_current_user_id(user)
|
||
role = user.get("role")
|
||
codes = get_user_menu_codes(db, user_id) if role != SUPER_ADMIN_ROLE else set()
|
||
if scope == "mine":
|
||
if role != SUPER_ADMIN_ROLE and "production:my-task:list" not in codes:
|
||
raise PermissionError("无菜单访问权限")
|
||
production_task_svc.assert_task_step_assignee_or_legacy(
|
||
db, task_id, user_id, tenant_no
|
||
)
|
||
elif role != SUPER_ADMIN_ROLE and "production:work-order:list" not in codes:
|
||
raise PermissionError("无菜单访问权限")
|
||
```
|
||
|
||
- [ ] **Step 2: 新增步骤写鉴权 helper**
|
||
|
||
```python
|
||
def _assert_production_task_step_write_access(
|
||
db: Session,
|
||
user: dict,
|
||
step_id: str,
|
||
tenant_no: str,
|
||
operate_scope: Optional[str],
|
||
) -> None:
|
||
scope = (operate_scope or "manage").strip().lower()
|
||
user_id = _get_current_user_id(user)
|
||
role = user.get("role")
|
||
codes = get_user_menu_codes(db, user_id) if role != SUPER_ADMIN_ROLE else set()
|
||
manage = scope != "mine"
|
||
if not manage:
|
||
if role != SUPER_ADMIN_ROLE and "production:my-task:list" not in codes:
|
||
raise PermissionError("无菜单访问权限")
|
||
elif role != SUPER_ADMIN_ROLE and "production:work-order:list" not in codes:
|
||
raise PermissionError("无菜单访问权限")
|
||
production_task_svc.assert_step_assignee_for_write(
|
||
db, step_id, user_id, tenant_no, manage=manage
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 3: 注册 `PUT /production-tasks/{task_id}/steps/dispatch`**
|
||
|
||
```python
|
||
@router.put("/production-tasks/{task_id}/steps/dispatch")
|
||
async def dispatch_production_task_steps_api(
|
||
task_id: str,
|
||
body: ProductionTaskStepDispatchBody,
|
||
user: dict = Depends(require_menu_code("production:work-order:list")),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
try:
|
||
tenant_no = _get_tenant_no(db, user)
|
||
payload = [
|
||
{
|
||
"step_id": it.step_id,
|
||
"assignee_user_ids": it.assignee_user_ids,
|
||
"planned_start_time": it.planned_start_time,
|
||
}
|
||
for it in body.steps
|
||
]
|
||
detail = production_task_svc.dispatch_task_steps(
|
||
db,
|
||
task_id,
|
||
tenant_no=tenant_no,
|
||
steps_payload=payload,
|
||
operator_id=user.get("user_id"),
|
||
create_dept_id=user.get("dept_id", ""),
|
||
)
|
||
db.commit()
|
||
return success_response(detail, message="步骤派发成功")
|
||
except ValueError as e:
|
||
db.rollback()
|
||
return error_response(400, str(e))
|
||
```
|
||
|
||
- [ ] **Step 4: 步骤写接口增加 step 级鉴权**
|
||
|
||
在以下 handler **内** `_assert_production_task_write_access` 之后追加 `_assert_production_task_step_write_access`:
|
||
|
||
- `update_production_task_step`
|
||
- `replace_task_step_reagents`
|
||
- `replace_task_step_consumables`
|
||
- `replace_task_step_configured_reagents`
|
||
- `replace_task_step_data_collections`
|
||
- `replace_task_step_outputs`
|
||
- `delete_production_task_step`
|
||
- `copy_production_task_step`
|
||
|
||
示例(`update_production_task_step`):
|
||
|
||
```python
|
||
_assert_production_task_write_access(db, user, task_id, tenant_no, operateScope)
|
||
_assert_production_task_step_write_access(db, user, step_id, tenant_no, operateScope)
|
||
```
|
||
|
||
任务级 inputs/outputs/attachments/status 仍只用 task 级 assert(D10:任一步 assignee 即可)。
|
||
|
||
- [ ] **Step 5: Swagger 手动验证**
|
||
|
||
Run backend,在 `/docs` 测试:
|
||
|
||
1. `PUT .../steps/dispatch` 返回 steps 含 `assigneeUserIds`
|
||
2. `GET .../production-tasks/mine?scope=current` 仅 assignee 可见
|
||
|
||
---
|
||
|
||
## Task 7: Frontend API
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/api/production-task.ts`
|
||
|
||
- [ ] **Step 1: 新增 dispatch API**
|
||
|
||
```typescript
|
||
export function fetchDispatchProductionTaskSteps(
|
||
taskId: string,
|
||
data: {
|
||
steps: Array<{
|
||
stepId: string
|
||
assigneeUserIds: string[]
|
||
plannedStartTime?: string | null
|
||
}>
|
||
}
|
||
) {
|
||
return request.put<any>({
|
||
url: `/web/admin/production-tasks/${taskId}/steps/dispatch`,
|
||
data
|
||
})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: 确认 create 不再传 assigneeUserId**
|
||
|
||
`DispatchTaskDialog.vue` 将在 Task 9 移除该字段。
|
||
|
||
---
|
||
|
||
## Task 8: DispatchStepDialog.vue(新建)
|
||
|
||
**Files:**
|
||
- Create: `web-admin/src/views/production/production-work-order/DispatchStepDialog.vue`
|
||
|
||
- [ ] **Step 1: 实现弹窗骨架**
|
||
|
||
要点:
|
||
|
||
- Props: `visible`, `taskId`, `taskName?`
|
||
- 打开时 `fetchProductionTaskDetail(taskId)` 加载 `steps`
|
||
- 表格列:序号、`processName`、负责人多选(remote `fetchUserList`)、计划开始时间
|
||
- 本地 state:`rows: { stepId, processName, assigneeUserIds, plannedStartTime, userOptions }[]`
|
||
- 提交:过滤 `assigneeUserIds.length >= 1` 的行,调用 `fetchDispatchProductionTaskSteps`
|
||
- 校验:至少 1 行有负责人
|
||
- UI:`dialog-form-layout`、`label-position="top"`、footer Close/Check 图标
|
||
|
||
- [ ] **Step 2: 回显已有 assignee**
|
||
|
||
detail.steps 中若已有 `assigneeUserIds` / `plannedStartTime`,填入表单。
|
||
|
||
---
|
||
|
||
## Task 9: DispatchTaskDialog.vue 改造
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue`
|
||
|
||
- [ ] **Step 1: 移除任务级负责人**
|
||
|
||
删除表单项 `assigneeUserId`、`rules.assigneeUserId`、`form.assigneeUserId` 及 create payload 中的 `assigneeUserId`。
|
||
|
||
- [ ] **Step 2: B 入口 — 创建后打开步骤派发**
|
||
|
||
在 `handleCreateTask` 成功、`enterEditPhase` 之后:
|
||
|
||
```typescript
|
||
emit('open-step-dispatch', { taskId, taskName: form.taskName })
|
||
```
|
||
|
||
或由父组件监听 `@success` 并根据是否选了 `routeId` 打开 `DispatchStepDialog`。
|
||
|
||
推荐:Dialog 内 `emit('needStepDispatch', taskId)`,父组件 `WorkOrderTaskSubList` / `index.vue` 统一挂载 `DispatchStepDialog`。
|
||
|
||
- [ ] **Step 3: 若选了 route 且步骤已克隆**
|
||
|
||
创建成功 + `expectSteps` 为 true 时,关闭编辑阶段或保留编辑的同时弹出步骤派发(产品:B 入口)。
|
||
|
||
---
|
||
|
||
## Task 10: WorkOrderTaskSubList.vue 改造
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/views/production/production-work-order/WorkOrderTaskSubList.vue`
|
||
|
||
- [ ] **Step 1: 挂载 DispatchStepDialog**
|
||
|
||
```vue
|
||
<DispatchStepDialog
|
||
v-model:visible="stepDispatchVisible"
|
||
:task-id="stepDispatchTaskId"
|
||
:task-name="stepDispatchTaskName"
|
||
@success="handleTaskMutationSuccess"
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 2: 管理视角增加「步骤派发」按钮**
|
||
|
||
在 `!isMineMode` 且任务非 `yzf` 时显示;文案:无 assignee 时「步骤派发」,否则「调整派发」。
|
||
|
||
判断:任一步 `assigneeUserIds?.length` 或后端返回 `hasStepDispatch`。
|
||
|
||
- [ ] **Step 3: 隐藏 deprecated 转发/改派**
|
||
|
||
删除或 `v-if="false"`:
|
||
|
||
- 下拉「转发任务」「改派负责人」
|
||
- `ForwardTaskDialog` 引用可保留文件但不挂载
|
||
|
||
- [ ] **Step 4: 任务卡片 meta 展示步骤负责人摘要**
|
||
|
||
在任务名下方增加 muted 文本,例如:`步骤负责人:张三、李四(细胞培养 09:00)` — 取前 2 步或折叠展示。
|
||
|
||
---
|
||
|
||
## Task 11: 执行弹窗 — 步骤权限与只读
|
||
|
||
**Files:**
|
||
- Modify: `web-admin/src/views/production/production-work-order/useTaskStepEditor.ts`
|
||
- Modify: `web-admin/src/views/production/production-work-order/TaskExecutionDialog.vue`
|
||
- Modify: `web-admin/src/views/production/production-work-order/StepDetailPanel.vue`
|
||
|
||
- [ ] **Step 1: useTaskStepEditor 增加 canEditStep**
|
||
|
||
```typescript
|
||
import { useUserStore } from '@/store/modules/user'
|
||
|
||
const userStore = useUserStore()
|
||
|
||
function canEditStep(step: Record<string, any> | null | undefined): boolean {
|
||
if (!step) return false
|
||
if (operateScope.value !== 'mine') return true
|
||
const uid = userStore.info?.id || userStore.userId || ''
|
||
const ids: string[] = step.assigneeUserIds || []
|
||
return ids.includes(uid)
|
||
}
|
||
|
||
function visibleStepsForMine(steps: Record<string, any>[]) {
|
||
if (operateScope.value !== 'mine') return steps
|
||
return steps.filter((s) => (s.assigneeUserIds || []).length > 0)
|
||
}
|
||
```
|
||
|
||
导出 `canEditStep`、`visibleStepsForMine`(或 computed `displayStepList`)。
|
||
|
||
- [ ] **Step 2: TaskExecutionDialog 步骤列表**
|
||
|
||
- mine:`v-for="element in editor.displayStepList"`(仅已派步骤)
|
||
- 对 `!canEditStep(element)` 的步骤加 class `is-readonly`(降低 opacity,禁用 drag)
|
||
- 任务级「转发任务」按钮移除
|
||
|
||
- [ ] **Step 3: StepDetailPanel 按钮**
|
||
|
||
```vue
|
||
<el-button
|
||
v-if="editor.mode.value === 'execution' && editor.canEditStep(editor.selectedStep.value)"
|
||
...
|
||
/>
|
||
```
|
||
|
||
保存 / 开始 / 完成均加 `canEditStep` 条件;只读时仅展示内容。
|
||
|
||
- [ ] **Step 4: 步骤写操作前端 guard(可选双保险)**
|
||
|
||
`handleStartStep` / `handleCompleteStep` 开头:
|
||
|
||
```typescript
|
||
if (!canEditStep(step)) {
|
||
ElMessage.warning('非当前步骤负责人,无法操作')
|
||
return
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: 字段一致性与图谱
|
||
|
||
**Files:**
|
||
- Run: `backend/scripts/check_field_consistency.py`(若涉及 CRUD 字段变更)
|
||
- Run: `./scripts/update-graph.sh backend frontend`
|
||
|
||
- [ ] **Step 1: 字段一致性检查**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate
|
||
python scripts/check_field_consistency.py
|
||
```
|
||
|
||
Expected: 无 production_task step 相关新增不一致;若有 `_row_to_dict` 变更则修复 schema/前端。
|
||
|
||
- [ ] **Step 2: 更新知识图谱**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
./scripts/update-graph.sh backend
|
||
./scripts/update-graph.sh frontend
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: 手工测试清单
|
||
|
||
- [ ] **B 入口**:生产工单 → 派发任务 → 选工艺路线 → 创建 → 步骤派发 → 用户 A/B 在「我的生产任务」见工单,执行时仅各自步骤可操作,其他只读。
|
||
|
||
- [ ] **A 入口**:无路线创建 → 手工加 3 步 → 步骤派发 → 同上。
|
||
|
||
- [ ] **一步多人**:A、B 同为步骤 1 负责人;A 完成步骤 1 后 B 见只读已完成。
|
||
|
||
- [ ] **同人多步**:步骤 1、2 均派给 A;A 两步均可操作。
|
||
|
||
- [ ] **计划开始时间**:卡片/派发弹窗展示;未到时间仍可执行。
|
||
|
||
- [ ] **未派步骤**:mine 执行弹窗不显示该步;manage 可见可操作。
|
||
|
||
- [ ] **改派**:manage 调整派发后,旧 assignee mine 列表消失(若无其他步骤),新 assignee 出现。
|
||
|
||
- [ ] **旧数据 backfill**(若执行 backfill SQL):原 task assignee 仍能在 mine 看到任务。
|
||
|
||
- [ ] **回归**:任务输入/产出/完成整单、派发质检工单链路不受影响。
|
||
|
||
---
|
||
|
||
## Spec Coverage Self-Review
|
||
|
||
| Spec 要求 | 对应 Task |
|
||
|-----------|-----------|
|
||
| D1 工单+任务列表 | Task 5 mine 过滤不变外层结构 |
|
||
| D2 其他步骤只读 | Task 11 |
|
||
| D3 一步多人任一完成 | Task 5 Step 7 completed_by |
|
||
| D4 计划开始时间不阻断 | Task 8/11 无前端时间校验 |
|
||
| D5 A+B 双入口 | Task 8/9/10 |
|
||
| D6 未派步骤仅 manage | Task 11 visibleStepsForMine |
|
||
| D7 方案 1 数据模型 | Task 1–4 |
|
||
| D8 任务 assignee 非必填 | Task 3/5 |
|
||
| D9 转发 deprecated | Task 10 |
|
||
| D10 任务 I/O 任一步 assignee | Task 6 task 级 assert |
|
||
| D11 mine 不可改派 | Task 6 dispatch 仅 work-order 权限 |
|
||
|
||
无 TBD 占位;类型命名 `assigneeUserIds` / `plannedStartTime` 前后端一致。
|
||
|
||
---
|
||
|
||
## 建议实施顺序
|
||
|
||
```
|
||
Task 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9 → 10 → 11 → 12 → 13
|
||
└─ backend 可联调 ─┘ └─ frontend ─────────────┘
|
||
```
|
||
|
||
Backend Task 1–6 完成后即可用 Swagger 验证 mine 过滤与 dispatch;Frontend Task 7–11 依赖后端 API。
|