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: 两表(集合头 + 条目:任务名称/可选工艺路线/描述/备注)。CRUD 挂 production.py。派发 POST /production-tasks/from-set 在同一事务内循环调用现有 create_production_task。前端 P01 维护页 + DispatchTaskDialog 增加「任务集合」方式 + 工单「存为任务集合」。
Tech Stack: FastAPI + SQLAlchemy + MySQL 5.7;Vue 3 + Element Plus;权限 useMenuPerm / require_menu_code
Spec: docs/superpowers/specs/2026-08-18-production-task-set-design.md
| 文件 | 职责 |
|---|---|
Create: docs/sql/alter_biz_production_task_set.sql |
两表 DDL + 菜单 + 角色授权(幂等) |
Modify: 待同步到生产的sql脚本 |
追加上线文件名 |
Modify: backend/app/models/models.py |
BizProductionTaskSet、BizProductionTaskSetItem |
Create: backend/app/crud/production_task_set.py |
纯函数 + CRUD + from-work-order + from-set |
Create: backend/tests/test_production_task_set_helpers.py |
条目规范化 / 空名跳过 / 缺失路线 |
Create: backend/app/schemas/production_task_set.py |
Pydantic body |
Modify: backend/app/api/production.py |
路由注册(from-set 必须写在 /{task_id} 之前) |
Create: web-admin/src/api/production-task-set.ts |
集合 API |
Modify: web-admin/src/api/production-task.ts |
fetchCreateProductionTasksFromSet |
Create: web-admin/src/utils/production-task-set.ts |
工单任务 → 条目预览映射 |
Create: web-admin/src/utils/production-task-set.test.ts |
映射单测 |
Create: web-admin/src/views/production/production-task-set/index.vue |
P01 维护页 |
Modify: web-admin/src/router/modules/sqxp.ts |
静态路由 |
Modify: web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue |
派发方式 |
Create: web-admin/src/views/production/production-work-order/SaveProductionTaskSetDialog.vue |
存为集合弹窗 |
Modify: ProductionWorkOrderCardList.vue、index.vue、tabs/ProductionWorkOrderTabPanel.vue |
菜单项与命令 |
./scripts/update-graph.sh |
实现后增量更新图谱 |
常量(全任务统一)
| 常量 | 值 |
|---|---|
| 头表 | biz_production_task_set |
| 条目表 | biz_production_task_set_item |
| C 菜单 id | 95400000000000000000000000000001 |
| F list | 95400000000000000000000000000002 |
| F add | 95400000000000000000000000000003 |
| F edit | 95400000000000000000000000000004 |
| F remove | 95400000000000000000000000000005 |
| parent_id | PRB00000000000000000000000000001 |
| 工单菜单 id | 90000000000000000000000000000011 |
| path | production-task-set |
| component | /production/production-task-set/index |
| C order_num | 12 |
| perms list | production:task-set:list |
| perms add | production:task-set:add |
| perms edit | production:task-set:edit |
| perms remove | production:task-set:remove |
| Hash | /#/production-group/production-base/production-task-set |
| API 集合 | /web/admin/production-task-sets |
| API from-set | POST /web/admin/production-tasks/from-set |
可用性约定(相对 spec 的落地): SQL 将 C+F 授予「已有任意 PRB 子菜单」或「已有生产工单菜单」的角色,这样只开工单的人也能选集合 / 存为集合。GET .../active 使用 require_any_menu_code(list, production:work-order:list)。POST .../from-set 使用 production:work-order:list(与单条创建任务同权)。POST .../from-work-order 使用 production:task-set:add。
Files:
docs/sql/alter_biz_production_task_set.sqlModify: 待同步到生产的sql脚本
[ ] Step 1: 写幂等迁移脚本
创建 docs/sql/alter_biz_production_task_set.sql:
-- 生产任务集合:头表 + 条目 + 菜单授权(幂等)
SET @tenant_no = CONVERT('zkxw' USING utf8mb4) COLLATE utf8mb4_unicode_ci;
SET @prb_id = CONVERT('PRB00000000000000000000000000001' USING utf8mb4) COLLATE utf8mb4_unicode_ci;
SET @wo_menu_id = CONVERT('90000000000000000000000000000011' USING utf8mb4) COLLATE utf8mb4_unicode_ci;
SET @menu_c = CONVERT('95400000000000000000000000000001' USING utf8mb4) COLLATE utf8mb4_unicode_ci;
SET @menu_list = CONVERT('95400000000000000000000000000002' USING utf8mb4) COLLATE utf8mb4_unicode_ci;
SET @menu_add = CONVERT('95400000000000000000000000000003' USING utf8mb4) COLLATE utf8mb4_unicode_ci;
SET @menu_edit = CONVERT('95400000000000000000000000000004' USING utf8mb4) COLLATE utf8mb4_unicode_ci;
SET @menu_remove = CONVERT('95400000000000000000000000000005' USING utf8mb4) COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `biz_production_task_set` (
`id` varchar(64) NOT NULL COMMENT '主键ID',
`set_name` varchar(200) NOT NULL COMMENT '集合名称',
`status` char(1) NOT NULL DEFAULT '1' COMMENT '1启用 0停用',
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int(11) DEFAULT NULL 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(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_pts_tenant_status` (`tenant_no`,`del_flag`,`status`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生产任务集合';
CREATE TABLE IF NOT EXISTS `biz_production_task_set_item` (
`id` varchar(64) NOT NULL COMMENT '主键ID',
`set_id` varchar(64) NOT NULL COMMENT '集合ID',
`task_name` varchar(200) NOT NULL COMMENT '任务名称',
`route_id` varchar(64) DEFAULT NULL COMMENT '工艺路线ID',
`route_name` varchar(100) DEFAULT NULL COMMENT '路线名称快照',
`description` varchar(500) DEFAULT NULL COMMENT '描述',
`remark` varchar(512) DEFAULT NULL COMMENT '备注',
`tenant_no` varchar(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号',
`order_num` int(11) 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(4) NOT NULL DEFAULT '0' COMMENT '删除标记',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_ptsi_set` (`set_id`,`del_flag`,`order_num`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='生产任务集合条目';
菜单:对照 docs/sql/alter_sys_menu_add_production_waste_output.sql 的 INSERT/UPDATE 幂等写法。C 菜单:
menu_name='生产任务集合',parent_id=@prb_id,menu_type='C'path='production-task-set',component='/production/production-task-set/index'perms='production:task-set:list',visible='1',order_num=12四个 F:父级 @menu_c,visible='0',perms 分别为 list/add/edit/remove,名称「查询/新增/编辑/删除」。
角色授权:已有 PRB 下任一有效子菜单 或 工单菜单 @wo_menu_id 的角色,幂等授予 @prb_id + C + 四个 F。MySQL 5.7 禁止同句读改 sys_role_menu,用派生表,对照废弃物脚本 3a/3b。
在 待同步到生产的sql脚本 追加一行:docs/sql/alter_biz_production_task_set.sql
git add docs/sql/alter_biz_production_task_set.sql 待同步到生产的sql脚本
git commit -m "$(cat <<'EOF'
feat: add production task set schema and menu SQL
EOF
)"
Files:
Modify: backend/app/models/models.py(在 BizProcessRouteStepDataCollection 之后、# 生产执行 注释之前插入)
[ ] Step 1: 插入两个模型类
class BizProductionTaskSet(Base):
"""生产任务集合 biz_production_task_set"""
__tablename__ = "biz_production_task_set"
id = Column(String(64), primary_key=True, comment="主键ID")
set_name = Column(String(200), nullable=False, comment="集合名称")
status = Column(CHAR(1), nullable=False, default="1", comment="1启用 0停用")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizProductionTaskSetItem(Base):
"""生产任务集合条目 biz_production_task_set_item"""
__tablename__ = "biz_production_task_set_item"
id = Column(String(64), primary_key=True, comment="主键ID")
set_id = Column(String(64), nullable=False, comment="集合ID")
task_name = Column(String(200), nullable=False, comment="任务名称")
route_id = Column(String(64), nullable=True, comment="工艺路线ID")
route_name = Column(String(100), nullable=True, comment="路线名称快照")
description = Column(String(500), nullable=True, comment="描述")
remark = Column(String(512), nullable=True, comment="备注")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
字段对齐 BizQcInspectionItemTemplate 写法(CHAR(1) 状态、公共字段顺序)。
git add backend/app/models/models.py
git commit -m "$(cat <<'EOF'
feat: add production task set SQLAlchemy models
EOF
)"
Files:
backend/app/crud/production_task_set.py(本任务只放纯函数)Create: backend/tests/test_production_task_set_helpers.py
[ ] Step 1: Write the failing test
# backend/tests/test_production_task_set_helpers.py
import unittest
from app.crud.production_task_set import (
collect_missing_route_ids,
normalize_item_payload,
skip_empty_named_tasks,
)
class TestNormalizeItemPayload(unittest.TestCase):
def test_requires_task_name(self):
self.assertIsNone(normalize_item_payload({"taskName": " ", "routeId": "r1"}))
def test_keeps_optional_fields(self):
item = normalize_item_payload(
{
"taskName": "分离",
"routeId": " r1 ",
"description": " d ",
"remark": " m ",
}
)
self.assertEqual(
item,
{
"task_name": "分离",
"route_id": "r1",
"description": "d",
"remark": "m",
},
)
def test_empty_route_becomes_none(self):
item = normalize_item_payload({"taskName": "培养", "routeId": ""})
self.assertEqual(item["route_id"], None)
self.assertEqual(item["description"], None)
self.assertEqual(item["remark"], None)
class TestSkipEmptyNamedTasks(unittest.TestCase):
def test_skips_blank_names_and_counts(self):
kept, skipped = skip_empty_named_tasks(
[
{"task_name": "A", "route_id": "r1", "description": None, "remark": "x"},
{"task_name": " ", "route_id": "r2"},
{"task_name": None, "route_id": None},
]
)
self.assertEqual(skipped, 2)
self.assertEqual(len(kept), 1)
self.assertEqual(kept[0]["task_name"], "A")
self.assertEqual(kept[0]["remark"], "x")
class TestCollectMissingRouteIds(unittest.TestCase):
def test_empty_route_not_missing(self):
self.assertEqual(
collect_missing_route_ids(
[{"route_id": None}, {"route_id": ""}],
{"alive"},
),
[],
)
def test_lists_missing_preserving_order_unique(self):
self.assertEqual(
collect_missing_route_ids(
[{"route_id": "gone"}, {"route_id": "alive"}, {"route_id": "gone"}],
{"alive"},
),
["gone"],
)
cd backend && source venv/bin/activate && python -m pytest tests/test_production_task_set_helpers.py -v
Expected: FAIL(模块或函数不存在)
在 backend/app/crud/production_task_set.py:
"""生产任务集合 CRUD"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set, Tuple
def _strip_or_none(raw: Any) -> Optional[str]:
text = str(raw or "").strip()
return text or None
def normalize_item_payload(raw: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
data = raw or {}
task_name = str(data.get("taskName") or data.get("task_name") or "").strip()
if not task_name:
return None
return {
"task_name": task_name,
"route_id": _strip_or_none(data.get("routeId") if "routeId" in data else data.get("route_id")),
"description": _strip_or_none(data.get("description")),
"remark": _strip_or_none(data.get("remark")),
}
def skip_empty_named_tasks(tasks: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]:
kept: List[Dict[str, Any]] = []
skipped = 0
for row in tasks or []:
name = str(row.get("task_name") or row.get("taskName") or "").strip()
if not name:
skipped += 1
continue
kept.append(
{
"task_name": name,
"route_id": _strip_or_none(row.get("route_id") if "route_id" in row else row.get("routeId")),
"route_name": _strip_or_none(row.get("route_name") if "route_name" in row else row.get("routeName")),
"description": _strip_or_none(row.get("description")),
"remark": _strip_or_none(row.get("remark")),
}
)
return kept, skipped
def collect_missing_route_ids(items: List[Dict[str, Any]], existing_ids: Set[str]) -> List[str]:
missing: List[str] = []
seen: Set[str] = set()
alive = existing_ids or set()
for item in items or []:
rid = _strip_or_none(item.get("route_id"))
if not rid or rid in alive or rid in seen:
continue
seen.add(rid)
missing.append(rid)
return missing
cd backend && source venv/bin/activate && python -m pytest tests/test_production_task_set_helpers.py -v
Expected: PASS
git add backend/app/crud/production_task_set.py backend/tests/test_production_task_set_helpers.py
git commit -m "$(cat <<'EOF'
test: add production task set item helper coverage
EOF
)"
Files:
backend/app/crud/production_task_set.py对照 backend/app/crud/qc_inspection_item_template.py 的租户过滤、名称唯一、_row_to_dict。CRUD 函数只 flush,不 commit(与 create_production_task 一致,便于 from-set 整单回滚)。
在同一文件继续追加(保留 Task 3 纯函数):
from datetime import datetime
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import (
BizProcessRoute,
BizProductionTask,
BizProductionTaskSet,
BizProductionTaskSetItem,
BizProductionWorkOrder,
)
def _item_to_dict(r: BizProductionTaskSetItem) -> Dict[str, Any]:
return {
"id": r.id,
"setId": r.set_id,
"taskName": r.task_name or "",
"routeId": r.route_id or "",
"routeName": r.route_name or "",
"description": r.description or "",
"remark": r.remark or "",
"orderNum": r.order_num if r.order_num is not None else 0,
}
def _set_to_dict(r: BizProductionTaskSet, item_count: int = 0, items: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
data: Dict[str, Any] = {
"id": r.id,
"setName": r.set_name or "",
"status": r.status or "1",
"itemCount": item_count,
"orderNum": r.order_num if r.order_num is not None else 0,
"remark": r.remark or "",
"createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "",
"updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "",
}
if items is not None:
data["items"] = items
return data
def set_name_exists(db: Session, name: str, tenant_no: str, exclude_id: Optional[str] = None) -> bool:
n = (name or "").strip()
if not n:
return False
q = db.query(BizProductionTaskSet).filter(
BizProductionTaskSet.set_name == n,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
if exclude_id:
q = q.filter(BizProductionTaskSet.id != exclude_id)
return q.first() is not None
def _require_status(status: Optional[str]) -> str:
st = (status or "1").strip()
if st not in ("0", "1"):
raise ValueError("状态无效")
return st
def _load_route_name_map(db: Session, tenant_no: str, route_ids: List[str]) -> Dict[str, str]:
ids = [rid for rid in route_ids if rid]
if not ids:
return {}
rows = (
db.query(BizProcessRoute)
.filter(
BizProcessRoute.tenant_no == tenant_no,
BizProcessRoute.del_flag == 0,
BizProcessRoute.id.in_(ids),
)
.all()
)
return {r.id: (r.route_name or "") for r in rows}
def _normalize_items_or_raise(raw_items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for raw in raw_items or []:
item = normalize_item_payload(raw)
if item:
out.append(item)
if not out:
raise ValueError("请至少添加一条任务名称非空的条目")
return out
def _assert_routes_exist(db: Session, tenant_no: str, items: List[Dict[str, Any]]) -> Dict[str, str]:
wanted = [i["route_id"] for i in items if i.get("route_id")]
name_map = _load_route_name_map(db, tenant_no, wanted)
missing = collect_missing_route_ids(items, set(name_map.keys()))
if missing:
raise ValueError("存在无效或已删除的工艺路线")
return name_map
def _replace_items(
db: Session,
*,
set_id: str,
tenant_no: str,
items: List[Dict[str, Any]],
route_names: Dict[str, str],
user_id: Optional[str],
create_dept_id: str,
) -> None:
now = datetime.now()
existing = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == set_id,
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
)
.all()
)
for row in existing:
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.add(row)
for idx, item in enumerate(items, start=1):
rid = item.get("route_id")
db.add(
BizProductionTaskSetItem(
id=generate_uuid(),
set_id=set_id,
task_name=item["task_name"],
route_id=rid,
route_name=route_names.get(rid or "") if rid else None,
description=item.get("description"),
remark=item.get("remark"),
tenant_no=tenant_no,
order_num=idx,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
)
def _item_count_map(db: Session, tenant_no: str, set_ids: List[str]) -> Dict[str, int]:
if not set_ids:
return {}
rows = (
db.query(BizProductionTaskSetItem.set_id, func.count(BizProductionTaskSetItem.id))
.filter(
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
BizProductionTaskSetItem.set_id.in_(set_ids),
)
.group_by(BizProductionTaskSetItem.set_id)
.all()
)
return {sid: int(cnt) for sid, cnt in rows}
def list_sets(
db: Session,
*,
tenant_no: str,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
status: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
q = db.query(BizProductionTaskSet).filter(
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
kw = (keyword or "").strip()
if kw:
q = q.filter(BizProductionTaskSet.set_name.like(f"%{kw}%"))
st = (status or "").strip()
if st in ("0", "1"):
q = q.filter(BizProductionTaskSet.status == st)
total = q.count()
rows = (
q.order_by(BizProductionTaskSet.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
counts = _item_count_map(db, tenant_no, [r.id for r in rows])
return [_set_to_dict(r, item_count=counts.get(r.id, 0)) for r in rows], int(total)
def list_active_sets(db: Session, *, tenant_no: str) -> List[Dict[str, Any]]:
rows = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
BizProductionTaskSet.status == "1",
)
.order_by(BizProductionTaskSet.create_time.desc())
.all()
)
counts = _item_count_map(db, tenant_no, [r.id for r in rows])
return [_set_to_dict(r, item_count=counts.get(r.id, 0)) for r in rows]
def get_set(db: Session, set_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
row = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not row:
return None
items = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == set_id,
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
)
.order_by(BizProductionTaskSetItem.order_num.asc(), BizProductionTaskSetItem.create_time.asc())
.all()
)
return _set_to_dict(row, item_count=len(items), items=[_item_to_dict(i) for i in items])
def create_set(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
set_name: str,
status: str = "1",
remark: Optional[str] = None,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
name = (set_name or "").strip()
if not name:
raise ValueError("集合名称不能为空")
if set_name_exists(db, name, tenant_no):
raise ValueError("集合名称已存在")
normalized = _normalize_items_or_raise(items or [])
route_names = _assert_routes_exist(db, tenant_no, normalized)
now = datetime.now()
obj = BizProductionTaskSet(
id=generate_uuid(),
set_name=name,
status=_require_status(status),
tenant_no=tenant_no,
order_num=0,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id=create_dept_id or "",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
db.flush()
_replace_items(
db,
set_id=obj.id,
tenant_no=tenant_no,
items=normalized,
route_names=route_names,
user_id=user_id,
create_dept_id=create_dept_id,
)
db.flush()
detail = get_set(db, obj.id, tenant_no)
if not detail:
raise ValueError("集合创建失败")
return detail
def update_set(
db: Session,
set_id: str,
*,
tenant_no: str,
user_id: Optional[str] = None,
set_name: Optional[str] = None,
status: Optional[str] = None,
remark: Optional[str] = None,
items: Optional[List[Dict[str, Any]]] = None,
) -> Dict[str, Any]:
obj = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not obj:
raise ValueError("集合不存在")
if set_name is not None:
name = set_name.strip()
if not name:
raise ValueError("集合名称不能为空")
if set_name_exists(db, name, tenant_no, exclude_id=set_id):
raise ValueError("集合名称已存在")
obj.set_name = name
if status is not None:
obj.status = _require_status(status)
if remark is not None:
obj.remark = remark.strip() or None
if items is not None:
normalized = _normalize_items_or_raise(items)
route_names = _assert_routes_exist(db, tenant_no, normalized)
_replace_items(
db,
set_id=obj.id,
tenant_no=tenant_no,
items=normalized,
route_names=route_names,
user_id=user_id,
create_dept_id=obj.create_dept_id or "",
)
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
db.flush()
detail = get_set(db, obj.id, tenant_no)
if not detail:
raise ValueError("集合不存在")
return detail
def delete_set(db: Session, set_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None:
obj = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == set_id,
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not obj:
raise ValueError("集合不存在")
now = datetime.now()
obj.del_flag = 1
obj.update_by = user_id
obj.update_time = now
db.add(obj)
items = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == set_id,
BizProductionTaskSetItem.del_flag == 0,
)
.all()
)
for row in items:
row.del_flag = 1
row.update_by = user_id
row.update_time = now
db.add(row)
db.flush()
def create_set_from_work_order(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
work_order_id: str,
set_name: str,
remark: Optional[str] = None,
) -> Dict[str, Any]:
wo_id = (work_order_id or "").strip()
wo = (
db.query(BizProductionWorkOrder)
.filter(
BizProductionWorkOrder.id == wo_id,
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.del_flag == 0,
)
.first()
)
if not wo:
raise ValueError("工单不存在")
tasks = (
db.query(BizProductionTask)
.filter(
BizProductionTask.work_order_id == wo_id,
BizProductionTask.tenant_no == tenant_no,
BizProductionTask.del_flag == 0,
)
.order_by(BizProductionTask.create_time.asc(), BizProductionTask.id.asc())
.all()
)
raw = [
{
"task_name": t.task_name,
"route_id": t.route_id,
"description": t.description,
"remark": t.remark,
}
for t in tasks
]
kept, skipped = skip_empty_named_tasks(raw)
if not kept:
raise ValueError("无可保存的生产任务(任务名称均为空)")
route_ids = [i["route_id"] for i in kept if i.get("route_id")]
name_map = _load_route_name_map(db, tenant_no, route_ids)
payload_items = []
for i in kept:
rid = i.get("route_id")
if rid and rid not in name_map:
rid = None
payload_items.append(
{
"taskName": i["task_name"],
"routeId": rid,
"description": i.get("description"),
"remark": i.get("remark"),
}
)
detail = create_set(
db,
tenant_no=tenant_no,
user_id=user_id,
create_dept_id=create_dept_id,
set_name=set_name,
status="1",
remark=remark,
items=payload_items,
)
return {**detail, "skippedEmptyNameCount": skipped}
create_tasks_from_set:
def create_tasks_from_set(
db: Session,
*,
tenant_no: str,
user_id: Optional[str] = None,
create_dept_id: str = "",
work_order_id: str,
set_id: str,
) -> Dict[str, Any]:
from app.crud import production_task as production_task_svc
wo_id = (work_order_id or "").strip()
wo = (
db.query(BizProductionWorkOrder)
.filter(
BizProductionWorkOrder.id == wo_id,
BizProductionWorkOrder.tenant_no == tenant_no,
BizProductionWorkOrder.del_flag == 0,
)
.first()
)
if not wo:
raise ValueError("工单不存在")
status = getattr(wo, "work_order_status", None) or ""
if status not in ("dpz", "jxz"):
raise ValueError("仅待配置或进行中的工单可派发任务")
header = (
db.query(BizProductionTaskSet)
.filter(
BizProductionTaskSet.id == (set_id or "").strip(),
BizProductionTaskSet.tenant_no == tenant_no,
BizProductionTaskSet.del_flag == 0,
)
.first()
)
if not header:
raise ValueError("集合不存在")
if (header.status or "1") != "1":
raise ValueError("集合已停用")
items = (
db.query(BizProductionTaskSetItem)
.filter(
BizProductionTaskSetItem.set_id == header.id,
BizProductionTaskSetItem.tenant_no == tenant_no,
BizProductionTaskSetItem.del_flag == 0,
)
.order_by(BizProductionTaskSetItem.order_num.asc(), BizProductionTaskSetItem.create_time.asc())
.all()
)
if not items:
raise ValueError("集合没有可派发的任务")
payloads = [
{
"route_id": row.route_id,
"task_name": row.task_name,
"description": row.description,
"remark": row.remark,
}
for row in items
]
name_map = _load_route_name_map(db, tenant_no, [p["route_id"] for p in payloads if p.get("route_id")])
missing = collect_missing_route_ids(payloads, set(name_map.keys()))
if missing:
raise ValueError("工艺路线已删除,无法派发:" + "、".join(missing))
created = []
for row in items:
task = production_task_svc.create_production_task(
db,
tenant_no=tenant_no,
user_id=user_id,
create_dept_id=create_dept_id,
data={
"work_order_id": wo_id,
"task_name": row.task_name,
"route_id": row.route_id,
"description": row.description,
"remark": row.remark,
},
)
created.append(production_task_svc._task_to_dict(task))
return {"createdCount": len(created), "list": created}
增加辅助:
def total_pages(total: int, page_size: int) -> int:
if page_size <= 0:
return 0
return (int(total) + int(page_size) - 1) // int(page_size)
cd backend && source venv/bin/activate && python -m pytest tests/test_production_task_set_helpers.py -v
Expected: PASS
git add backend/app/crud/production_task_set.py
git commit -m "$(cat <<'EOF'
feat: implement production task set CRUD and batch dispatch
EOF
)"
Files:
backend/app/schemas/production_task_set.pyModify: backend/app/api/production.py
[ ] Step 1: Pydantic schemas
# backend/app/schemas/production_task_set.py
from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field
class ProductionTaskSetItemBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
task_name: str = Field(..., alias="taskName")
route_id: Optional[str] = Field(None, alias="routeId")
description: Optional[str] = None
remark: Optional[str] = None
class ProductionTaskSetCreateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
set_name: str = Field(..., alias="setName")
status: Optional[str] = "1"
remark: Optional[str] = None
items: List[ProductionTaskSetItemBody] = []
class ProductionTaskSetUpdateBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
set_name: Optional[str] = Field(None, alias="setName")
status: Optional[str] = None
remark: Optional[str] = None
items: Optional[List[ProductionTaskSetItemBody]] = None
class ProductionTaskSetFromWorkOrderBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
work_order_id: str = Field(..., alias="workOrderId")
set_name: str = Field(..., alias="setName")
remark: Optional[str] = None
class ProductionTasksFromSetBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
work_order_id: str = Field(..., alias="workOrderId")
set_id: str = Field(..., alias="setId")
在 production.py 增加:
from app.crud import production_task_set as production_task_set_svc
from app.schemas.production_task_set import (
ProductionTaskSetCreateBody,
ProductionTaskSetFromWorkOrderBody,
ProductionTaskSetUpdateBody,
ProductionTasksFromSetBody,
)
POST /production-tasks/from-set 必须插在现有 @router.post("/production-tasks") 之后、任何 /production-tasks/{task_id} 之前。
@router.post("/production-tasks/from-set")
async def create_production_tasks_from_set(
body: ProductionTasksFromSetBody,
user: dict = Depends(require_menu_code("production:work-order:list")),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
result = production_task_set_svc.create_tasks_from_set(
db,
tenant_no=tenant_no,
user_id=user.get("user_id"),
create_dept_id="",
work_order_id=body.work_order_id,
set_id=body.set_id,
)
db.commit()
return success_response(result, message="创建成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
集合路由(/active 与 /from-work-order 在 /{set_id} 之前):
@router.get("/production-task-sets")
async def list_production_task_sets(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
status: Optional[str] = None,
user: dict = Depends(require_menu_code("production:task-set:list")),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items, total = production_task_set_svc.list_sets(
db, tenant_no=tenant_no, page=page, page_size=pageSize, keyword=keyword, status=status,
)
tp = production_task_set_svc.total_pages(total, pageSize)
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
@router.get("/production-task-sets/active")
async def list_active_production_task_sets(
user: dict = Depends(require_any_menu_code("production:task-set:list", "production:work-order:list")),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
items = production_task_set_svc.list_active_sets(db, tenant_no=tenant_no)
return success_response({"list": items})
@router.post("/production-task-sets/from-work-order")
async def create_production_task_set_from_work_order(
body: ProductionTaskSetFromWorkOrderBody,
user: dict = Depends(require_menu_code("production:task-set:add")),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
detail = production_task_set_svc.create_set_from_work_order(
db,
tenant_no=tenant_no,
user_id=user.get("user_id"),
work_order_id=body.work_order_id,
set_name=body.set_name,
remark=body.remark,
)
db.commit()
return success_response(detail, message="保存成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@router.get("/production-task-sets/{set_id}")
async def get_production_task_set(
set_id: str,
user: dict = Depends(require_any_menu_code("production:task-set:list", "production:work-order:list")),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
detail = production_task_set_svc.get_set(db, set_id, tenant_no)
if not detail:
return error_response(404, "集合不存在")
return success_response(detail)
@router.post("/production-task-sets")
async def create_production_task_set(
body: ProductionTaskSetCreateBody,
user: dict = Depends(require_menu_code("production:task-set:add")),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
detail = production_task_set_svc.create_set(
db,
tenant_no=tenant_no,
user_id=user.get("user_id"),
set_name=body.set_name,
status=body.status or "1",
remark=body.remark,
items=[x.model_dump(by_alias=False) for x in body.items],
)
db.commit()
return success_response(detail, message="创建成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@router.put("/production-task-sets/{set_id}")
async def update_production_task_set(
set_id: str,
body: ProductionTaskSetUpdateBody,
user: dict = Depends(require_menu_code("production:task-set:edit")),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
data = body.model_dump(by_alias=False, exclude_unset=True)
items = None
if "items" in data:
items = [x.model_dump(by_alias=False) for x in (body.items or [])]
detail = production_task_set_svc.update_set(
db,
set_id,
tenant_no=tenant_no,
user_id=user.get("user_id"),
set_name=data.get("set_name"),
status=data.get("status"),
remark=data.get("remark"),
items=items,
)
db.commit()
return success_response(detail, message="更新成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@router.delete("/production-task-sets/{set_id}")
async def delete_production_task_set(
set_id: str,
user: dict = Depends(require_menu_code("production:task-set:remove")),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
production_task_set_svc.delete_set(db, set_id, tenant_no=tenant_no, user_id=user.get("user_id"))
db.commit()
return success_response(None, message="删除成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
normalize_item_payload 同时识别 task_name / taskName。
git add backend/app/schemas/production_task_set.py backend/app/api/production.py
git commit -m "$(cat <<'EOF'
feat: expose production task set REST APIs
EOF
)"
Files:
web-admin/src/api/production-task-set.tsweb-admin/src/api/production-task.tsweb-admin/src/utils/production-task-set.tsCreate: web-admin/src/utils/production-task-set.test.ts
[ ] Step 1: Write the failing frontend test
// web-admin/src/utils/production-task-set.test.ts
import { describe, expect, it } from 'vitest'
import { mapWorkOrderTasksToSetItems } from './production-task-set'
describe('mapWorkOrderTasksToSetItems', () => {
it('skips empty names and keeps route/description/remark', () => {
const { items, skippedEmptyNameCount } = mapWorkOrderTasksToSetItems([
{ taskName: '分离', routeId: 'r1', routeName: '路线A', description: 'd', remark: 'm' },
{ taskName: ' ', routeId: 'r2' },
{ taskName: '培养' }
])
expect(skippedEmptyNameCount).toBe(1)
expect(items).toEqual([
{
taskName: '分离',
routeId: 'r1',
routeName: '路线A',
description: 'd',
remark: 'm'
},
{
taskName: '培养',
routeId: '',
routeName: '',
description: '',
remark: ''
}
])
})
})
cd web-admin && pnpm exec vitest run src/utils/production-task-set.test.ts
Expected: FAIL(模块不存在)
// web-admin/src/utils/production-task-set.ts
export type ProductionTaskSetItemPreview = {
taskName: string
routeId: string
routeName: string
description: string
remark: string
}
export function mapWorkOrderTasksToSetItems(tasks: Record<string, any>[] | undefined): {
items: ProductionTaskSetItemPreview[]
skippedEmptyNameCount: number
} {
const items: ProductionTaskSetItemPreview[] = []
let skippedEmptyNameCount = 0
for (const row of tasks || []) {
const taskName = String(row.taskName || '').trim()
if (!taskName) {
skippedEmptyNameCount += 1
continue
}
items.push({
taskName,
routeId: String(row.routeId || '').trim(),
routeName: String(row.routeName || '').trim(),
description: String(row.description || '').trim(),
remark: String(row.remark || '').trim()
})
}
return { items, skippedEmptyNameCount }
}
// web-admin/src/api/production-task-set.ts
import request from '@/utils/http'
type PageList<T> = {
list: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export type ProductionTaskSetItem = {
id?: string
taskName: string
routeId?: string
routeName?: string
description?: string
remark?: string
orderNum?: number
}
export type ProductionTaskSet = {
id: string
setName: string
status: string
itemCount?: number
items?: ProductionTaskSetItem[]
remark?: string
createTime?: string
skippedEmptyNameCount?: number
}
export type ProductionTaskSetPayload = {
setName: string
status?: string
remark?: string
items: Array<{
taskName: string
routeId?: string
description?: string
remark?: string
}>
}
export function fetchProductionTaskSetList(params?: {
page?: number
pageSize?: number
keyword?: string
status?: string
}) {
return request.get<PageList<ProductionTaskSet>>({
url: '/web/admin/production-task-sets',
params
})
}
export function fetchProductionTaskSetsActive() {
return request.get<{ list: ProductionTaskSet[] }>({
url: '/web/admin/production-task-sets/active'
})
}
export function fetchProductionTaskSetDetail(id: string) {
return request.get<ProductionTaskSet>({
url: `/web/admin/production-task-sets/${id}`
})
}
export function fetchCreateProductionTaskSet(data: ProductionTaskSetPayload) {
return request.post<ProductionTaskSet>({
url: '/web/admin/production-task-sets',
params: data
})
}
export function fetchUpdateProductionTaskSet(id: string, data: ProductionTaskSetPayload) {
return request.put<ProductionTaskSet>({
url: `/web/admin/production-task-sets/${id}`,
params: data
})
}
export function fetchDeleteProductionTaskSet(id: string) {
return request.del({ url: `/web/admin/production-task-sets/${id}` })
}
export function fetchCreateProductionTaskSetFromWorkOrder(data: {
workOrderId: string
setName: string
remark?: string
}) {
return request.post<ProductionTaskSet>({
url: '/web/admin/production-task-sets/from-work-order',
params: data
})
}
在 web-admin/src/api/production-task.ts 增加:
export function fetchCreateProductionTasksFromSet(data: { workOrderId: string; setId: string }) {
return request.post<{ createdCount: number; list: any[] }>({
url: '/web/admin/production-tasks/from-set',
params: data
})
}
cd web-admin && pnpm exec vitest run src/utils/production-task-set.test.ts
Expected: PASS
git add web-admin/src/api/production-task-set.ts web-admin/src/api/production-task.ts \
web-admin/src/utils/production-task-set.ts web-admin/src/utils/production-task-set.test.ts
git commit -m "$(cat <<'EOF'
feat: add production task set API client and item mapping
EOF
)"
Files:
web-admin/src/views/production/production-task-set/index.vueModify: web-admin/src/router/modules/sqxp.ts
[ ] Step 1: 静态路由
在 sqxp.ts 的 production-base children 中,ProductionProcessRoute 之后插入:
{
id: '95400000000000000000000000000001',
name: 'ProductionTaskSet',
path: 'production-task-set',
component: '/production/production-task-set/index',
meta: {
title: '生产任务集合',
keepAlive: true,
menuCode: 'production:task-set:list'
}
}
对照 web-admin/src/views/quality-inspection/inspection-item-template/index.vue 的 P01 骨架(panel-top + title-bar-heading「生产任务集合列表」、filter、toolbar 仅「新增集合」、list-table + 分页)。
列:集合名称、任务条数 itemCount、状态 Tag(启用=success / 停用=info)、备注、创建时间 formatDisplayOrDash、操作 fixed="right" min-width ≥ 280:查看、编辑、启停、删除。权限:useMenuPerm 的 production:task-set:add|edit|remove。
新增/编辑弹窗:class="dialog-form-layout",width="860px",label-position="top",align-center,modal-class="protocol-dialog-mask",append-to-body。
表单字段:
el-select 启用/停用el-table 行内编辑。列:任务名称、工艺路线(el-select filterable remote,复用 fetchProcessRouteList,与 DispatchTaskDialog 相同 remote-method)、描述、备注、操作(上移/下移/删除)。表头工具「添加条目」。至少一行且任务名称非空才能提交。查看弹窗:只读同样字段,无提交。
启停:fetchUpdateProductionTaskSet(id, { setName, status: next, remark, items }) 需带当前 items(先 fetchProductionTaskSetDetail)或 update 接口允许只改 status(Task 4 的 update_set 在 items is None 时不替换条目——启停只传 status)。
列表默认不展示 orderNum。
底栏按钮:Close 取消 / Check 确定,图标文字间距 6px。
git add web-admin/src/views/production/production-task-set/index.vue web-admin/src/router/modules/sqxp.ts
git commit -m "$(cat <<'EOF'
feat: add production task set maintenance page
EOF
)"
Files:
Modify: web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue
[ ] Step 1: 阶段一增加派发方式
仅当 !props.editTaskId(新建)时展示。dispatchMode: 'single' | 'set',默认 'single'。
有 production:task-set:list 或(因 SQL 已授权)直接展示两种方式;无 list 且无工单权限时只保留单条。实现:const canUseSet = computed(() => hasMenuPerm('production:task-set:list') || hasMenuPerm('production:work-order:list'))。canUseSet 为 false 时不渲染单选,行为与现状完全一致。
模板(插在现有表单网格之前):
<el-form-item v-if="!editTaskId && canUseSet" label="派发方式" class="form-col-span-2">
<el-radio-group v-model="dispatchMode" @change="onDispatchModeChange">
<el-radio-button value="single">单条任务</el-radio-button>
<el-radio-button value="set">任务集合</el-radio-button>
</el-radio-group>
</el-form-item>
dispatchMode === 'single':现有名称/路线/描述/备注不变;主按钮仍走 handleCreateTask → 进入 edit 阶段。
dispatchMode === 'set':隐藏单条四字段;展示集合 el-select(fetchProductionTaskSetsActive);@change 调 fetchProductionTaskSetDetail 填 setPreviewItems。只读表格列:任务名称、工艺路线(routeName 或「—」)、描述、备注。
主按钮文案仍为「生成任务」。handleCreateTask 开头:
if (!props.editTaskId && dispatchMode.value === 'set') {
await handleCreateFromSet()
return
}
async function handleCreateFromSet() {
const setId = String(selectedSetId.value || '').trim()
const workOrderId = String(props.workOrderRow?.id || '').trim()
if (!setId) {
ElMessage.warning('请选择任务集合')
return
}
if (!setPreviewItems.value.length) {
ElMessage.warning('集合没有可派发的任务')
return
}
submitting.value = true
try {
const result = await fetchCreateProductionTasksFromSet({ workOrderId, setId })
ElMessage.success(`已生成 ${result?.createdCount || setPreviewItems.value.length} 条生产任务`)
pendingListRefresh.value = true
emit('success')
handleClose()
} catch {
/* global error handling */
} finally {
submitting.value = false
}
}
禁止在集合成功后调用 enterEditPhase / needStepDispatch。
打开弹窗时(watch visible,非 editTaskId):dispatchMode = 'single',清空 selectedSetId 与预览。
git add web-admin/src/views/production/production-work-order/DispatchTaskDialog.vue
git commit -m "$(cat <<'EOF'
feat: dispatch production tasks from a reusable set
EOF
)"
Files:
web-admin/src/views/production/production-work-order/SaveProductionTaskSetDialog.vueweb-admin/src/views/production/production-work-order/ProductionWorkOrderCardList.vueModify: web-admin/src/views/production/production-work-order/tabs/ProductionWorkOrderTabPanel.vue
[ ] Step 1: 存为弹窗组件
SaveProductionTaskSetDialog.vue:dialog-form-layout,宽约 720px。props:visible、workOrderId、tasks。字段:集合名称必填、备注可选、只读预览表(mapWorkOrderTasksToSetItems(tasks))。无有效条目时确定按钮 disabled,提示「没有可保存的任务名称」。提交 fetchCreateProductionTaskSetFromWorkOrder。若 skippedEmptyNameCount > 0,成功消息带「已跳过 N 条空名称任务」。
ProductionWorkOrderCardList.vue 的 … 下拉里,在编辑前增加:
<el-dropdown-item
v-if="canSaveTaskSet(row)"
command="save-set"
>
<el-icon><FolderAdd /></el-icon>存为任务集合
</el-dropdown-item>
const { hasMenuPerm } = useMenuPerm()
function canSaveTaskSet(row: ProductionWorkOrderItem) {
if (isMineMode.value) return false
if (!hasMenuPerm('production:task-set:add')) return false
const tasks = resolvePresetTasks(row.id)
return Array.isArray(tasks) && tasks.some((t) => String(t?.taskName || '').trim())
}
在 ProductionWorkOrderTabPanel.vue 的 handleCommand 拦截(此处已有 presetTasksByWorkOrderId,不要把 save-set 再 emit 到 index.vue):
if (command === 'save-set') {
saveSetRow.value = row
saveSetVisible.value = true
return
}
tasks 用 presetTasksByWorkOrderId[row.id] || []。成功后不必整表重载工单,仅 ElMessage.success。
【我的生产任务】pageMode='mine':canSaveTaskSet 为 false,无菜单项。
git add web-admin/src/views/production/production-work-order/SaveProductionTaskSetDialog.vue \
web-admin/src/views/production/production-work-order/ProductionWorkOrderCardList.vue \
web-admin/src/views/production/production-work-order/tabs/ProductionWorkOrderTabPanel.vue
git commit -m "$(cat <<'EOF'
feat: save work-order tasks as a production task set
EOF
)"
Files: 按改动范围跑图谱
./scripts/update-graph.sh all
cd backend && source venv/bin/activate && python -m pytest tests/test_production_task_set_helpers.py -v
Expected: PASS
cd web-admin && pnpm exec vitest run src/utils/production-task-set.test.ts
Expected: PASS
git add backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: update graphify output after production task set
EOF
)"
| Spec 节 | 任务 |
|---|---|
| §3 两表 | Task 1–2 |
| §4.1 CRUD / active | Task 4–5 |
| §4.2 from-work-order | Task 4–5、9 |
| §4.3 from-set 事务与路线失败 | Task 4–5、8 |
| §5.1 维护页 P01 | Task 7 |
| §5.2 派发弹窗二选一、生成后关闭 | Task 8 |
| §5.3 存为集合、mine 无入口 | Task 9 |
| §5.4 API 模块 | Task 6 |
| §6 边界 | Task 3 单测 + Task 4 校验 + Task 10 手工 |
| §7 菜单权限 | Task 1、5、7–9 |
| §8 非目标 | 不实现 Tab 绑定、步骤快照、mine 派发 |
| §9 验收 | Task 10 |
BizProductionTaskSet / BizProductionTaskSetItemnormalize_item_payload、skip_empty_named_tasks、collect_missing_route_ids、create_set_from_work_order、create_tasks_from_setfetchCreateProductionTasksFromSet、mapWorkOrderTasksToSetItems、dispatchMode: 'single' | 'set'