From 0707a4c95e951f757e1d67ffb55a38f62c69ca4d Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 18 Aug 2026 16:16:57 +0800 Subject: [PATCH] docs: add production task set design spec and implementation plan Co-authored-by: Cursor --- .../plans/2026-08-18-production-task-set.md | 1698 +++++++++++++++++ .../2026-08-18-production-task-set-design.md | 260 +++ 2 files changed, 1958 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-production-task-set.md create mode 100644 docs/superpowers/specs/2026-08-18-production-task-set-design.md diff --git a/docs/superpowers/plans/2026-08-18-production-task-set.md b/docs/superpowers/plans/2026-08-18-production-task-set.md new file mode 100644 index 00000000..fa4570b6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-production-task-set.md @@ -0,0 +1,1698 @@ +# 生产任务集合 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:** 两表(集合头 + 条目:任务名称/可选工艺路线/描述/备注)。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` + +--- + +## File map + +| 文件 | 职责 | +|------|------| +| 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`。 + +--- + +### Task 1: SQL 建表 + 菜单授权 + +**Files:** +- Create: `docs/sql/alter_biz_production_task_set.sql` +- Modify: `待同步到生产的sql脚本` + +- [ ] **Step 1: 写幂等迁移脚本** + +创建 `docs/sql/alter_biz_production_task_set.sql`: + +```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。 + +- [ ] **Step 2: 待同步清单** + +在 `待同步到生产的sql脚本` 追加一行:`docs/sql/alter_biz_production_task_set.sql` + +- [ ] **Step 3: Commit** + +```bash +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 +)" +``` + +--- + +### Task 2: SQLAlchemy 模型 + +**Files:** +- Modify: `backend/app/models/models.py`(在 `BizProcessRouteStepDataCollection` 之后、`# 生产执行` 注释之前插入) + +- [ ] **Step 1: 插入两个模型类** + +```python +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)` 状态、公共字段顺序)。 + +- [ ] **Step 2: Commit** + +```bash +git add backend/app/models/models.py +git commit -m "$(cat <<'EOF' +feat: add production task set SQLAlchemy models + +EOF +)" +``` + +--- + +### Task 3: 纯函数单测(TDD) + +**Files:** +- Create: `backend/app/crud/production_task_set.py`(本任务只放纯函数) +- Create: `backend/tests/test_production_task_set_helpers.py` + +- [ ] **Step 1: Write the failing test** + +```python +# 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"], + ) +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd backend && source venv/bin/activate && python -m pytest tests/test_production_task_set_helpers.py -v +``` + +Expected: FAIL(模块或函数不存在) + +- [ ] **Step 3: Write minimal implementation** + +在 `backend/app/crud/production_task_set.py`: + +```python +"""生产任务集合 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 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && source venv/bin/activate && python -m pytest tests/test_production_task_set_helpers.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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 +)" +``` + +--- + +### Task 4: CRUD 实现 + +**Files:** +- Modify: `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 整单回滚)。 + +- [ ] **Step 1: 实现序列化、唯一名、条目写入、CRUD** + +在同一文件继续追加(保留 Task 3 纯函数): + +```python +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`: + +```python +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} +``` + +增加辅助: + +```python +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) +``` + +- [ ] **Step 2: 再跑 Task 3 单测,确认纯函数未破坏** + +```bash +cd backend && source venv/bin/activate && python -m pytest tests/test_production_task_set_helpers.py -v +``` + +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/crud/production_task_set.py +git commit -m "$(cat <<'EOF' +feat: implement production task set CRUD and batch dispatch + +EOF +)" +``` + +--- + +### Task 5: Schema + API 路由 + +**Files:** +- Create: `backend/app/schemas/production_task_set.py` +- Modify: `backend/app/api/production.py` + +- [ ] **Step 1: Pydantic schemas** + +```python +# 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") +``` + +- [ ] **Step 2: 注册路由** + +在 `production.py` 增加: + +```python +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}` 之前。** + +```python +@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}` 之前): + +```python +@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`。 + +- [ ] **Step 3: Commit** + +```bash +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 +)" +``` + +--- + +### Task 6: 前端 API 与映射工具(TDD) + +**Files:** +- Create: `web-admin/src/api/production-task-set.ts` +- Modify: `web-admin/src/api/production-task.ts` +- Create: `web-admin/src/utils/production-task-set.ts` +- Create: `web-admin/src/utils/production-task-set.test.ts` + +- [ ] **Step 1: Write the failing frontend test** + +```ts +// 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: '' + } + ]) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd web-admin && pnpm exec vitest run src/utils/production-task-set.test.ts +``` + +Expected: FAIL(模块不存在) + +- [ ] **Step 3: Implement util + API modules** + +```ts +// 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[] | 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 } +} +``` + +```ts +// web-admin/src/api/production-task-set.ts +import request from '@/utils/http' + +type PageList = { + 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>({ + 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({ + url: `/web/admin/production-task-sets/${id}` + }) +} + +export function fetchCreateProductionTaskSet(data: ProductionTaskSetPayload) { + return request.post({ + url: '/web/admin/production-task-sets', + params: data + }) +} + +export function fetchUpdateProductionTaskSet(id: string, data: ProductionTaskSetPayload) { + return request.put({ + 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({ + url: '/web/admin/production-task-sets/from-work-order', + params: data + }) +} +``` + +在 `web-admin/src/api/production-task.ts` 增加: + +```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 + }) +} +``` + +- [ ] **Step 4: Run frontend test** + +```bash +cd web-admin && pnpm exec vitest run src/utils/production-task-set.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +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 +)" +``` + +--- + +### Task 7: 维护页 + 静态路由 + +**Files:** +- Create: `web-admin/src/views/production/production-task-set/index.vue` +- Modify: `web-admin/src/router/modules/sqxp.ts` + +- [ ] **Step 1: 静态路由** + +在 `sqxp.ts` 的 `production-base` children 中,`ProductionProcessRoute` 之后插入: + +```ts + { + id: '95400000000000000000000000000001', + name: 'ProductionTaskSet', + path: 'production-task-set', + component: '/production/production-task-set/index', + meta: { + title: '生产任务集合', + keepAlive: true, + menuCode: 'production:task-set:list' + } + } +``` + +- [ ] **Step 2: 列表页** + +对照 `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。 + +- [ ] **Step 3: Commit** + +```bash +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 +)" +``` + +--- + +### Task 8: 派发弹窗支持任务集合 + +**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 时不渲染单选,行为与现状完全一致。 + +模板(插在现有表单网格之前): + +```vue + + + 单条任务 + 任务集合 + + +``` + +`dispatchMode === 'single'`:现有名称/路线/描述/备注不变;主按钮仍走 `handleCreateTask` → 进入 edit 阶段。 + +`dispatchMode === 'set'`:隐藏单条四字段;展示集合 `el-select`(`fetchProductionTaskSetsActive`);`@change` 调 `fetchProductionTaskSetDetail` 填 `setPreviewItems`。只读表格列:任务名称、工艺路线(`routeName` 或「—」)、描述、备注。 + +主按钮文案仍为「生成任务」。`handleCreateTask` 开头: + +```ts + if (!props.editTaskId && dispatchMode.value === 'set') { + await handleCreateFromSet() + return + } +``` + +```ts + 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` 与预览。 + +- [ ] **Step 2: Commit** + +```bash +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 +)" +``` + +--- + +### Task 9: 工单「存为任务集合」 + +**Files:** +- Create: `web-admin/src/views/production/production-work-order/SaveProductionTaskSetDialog.vue` +- Modify: `web-admin/src/views/production/production-work-order/ProductionWorkOrderCardList.vue` +- Modify: `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 条空名称任务」。 + +- [ ] **Step 2: 卡片菜单** + +`ProductionWorkOrderCardList.vue` 的 `…` 下拉里,在编辑前增加: + +```vue + + 存为任务集合 + +``` + +```ts + 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`): + +```ts + if (command === 'save-set') { + saveSetRow.value = row + saveSetVisible.value = true + return + } +``` + +`tasks` 用 `presetTasksByWorkOrderId[row.id] || []`。成功后不必整表重载工单,仅 `ElMessage.success`。 + +【我的生产任务】`pageMode='mine'`:`canSaveTaskSet` 为 false,无菜单项。 + +- [ ] **Step 3: Commit** + +```bash +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 +)" +``` + +--- + +### Task 10: 图谱、自检与手工验收 + +**Files:** 按改动范围跑图谱 + +- [ ] **Step 1: 更新代码知识图谱** + +```bash +./scripts/update-graph.sh all +``` + +- [ ] **Step 2: 后端单测再跑一遍** + +```bash +cd backend && source venv/bin/activate && python -m pytest tests/test_production_task_set_helpers.py -v +``` + +Expected: PASS + +- [ ] **Step 3: 前端单测** + +```bash +cd web-admin && pnpm exec vitest run src/utils/production-task-set.test.ts +``` + +Expected: PASS + +- [ ] **Step 4: 手工对照 spec §9** + +1. 执行 SQL 后登录:生产基础信息出现「生产任务集合」;可增改删启停;条目四字段;停用后派发下拉不可见。 +2. 生产工单「派发任务」单条:仍生成 1 条并进入编辑工艺。 +3. 选含 4 条的集合:一次生成 4 条,弹窗关闭,卡片任务区可见 4 条,**不**进三栏编辑。 +4. 集合内路线改为已删 ID(或先删路线):生成失败,一条都不建。 +5. 「存为任务集合」保存名称/路线/描述/备注;空名称跳过。 +6. 【我的生产任务】无派发任务、无存为集合。 + +- [ ] **Step 5: Commit 图谱(若有 diff)** + +```bash +git add backend/graphify-out web-admin/graphify-out +git commit -m "$(cat <<'EOF' +chore: update graphify output after production task set + +EOF +)" +``` + +--- + +## Spec coverage + +| 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 | + +## Type names(全局一致) + +- 表/模型:`BizProductionTaskSet` / `BizProductionTaskSetItem` +- 函数:`normalize_item_payload`、`skip_empty_named_tasks`、`collect_missing_route_ids`、`create_set_from_work_order`、`create_tasks_from_set` +- 前端:`fetchCreateProductionTasksFromSet`、`mapWorkOrderTasksToSetItems`、`dispatchMode: 'single' | 'set'` diff --git a/docs/superpowers/specs/2026-08-18-production-task-set-design.md b/docs/superpowers/specs/2026-08-18-production-task-set-design.md new file mode 100644 index 00000000..e7430d00 --- /dev/null +++ b/docs/superpowers/specs/2026-08-18-production-task-set-design.md @@ -0,0 +1,260 @@ +# 生产任务集合 — 设计文档 + +> 日期:2026-08-18 +> 状态:已确认,待实现计划 +> 范围:【生产基础信息】任务集合主数据;【生产工单】派发任务弹窗、存为集合 +> 不改:【我的生产任务】派发入口(本来就没有);单条派发后的三栏编辑工艺 / 步骤派发 / 完成工单 + +--- + +## 1. 背景与目标 + +### 1.1 问题 + +同类型生产工单每次派发的任务内容往往相同(任务名称、工艺路线、描述、备注)。当前「派发任务」每次只生成 **1** 条生产任务,用户需反复打开弹窗填写,效率低。 + +### 1.2 目标 + +1. 引入可维护、可复用的 **生产任务集合**:一条集合 = 多条「派发信息」(与现有派发表单字段一致)。 +2. 「派发任务」弹窗支持 **按集合一次生成多条** 生产任务(集合 4 条 → 生成 4 条任务)。 +3. 保留现有 **单条** 派发(选工艺路线等)流程不变。 +4. 支持把当前生产工单已派发的任务 **存为** 一套新集合。 + +### 1.3 已确认产品决策 + +| 项 | 决策 | +|----|------| +| 形态 | 独立主数据「生产任务集合」,非绑死某一工单 Tab | +| 条目内容 | 与派发表单一致:任务名称、工艺路线(可选)、描述、备注 | +| 不存 | 工艺步骤快照、负责人、步骤派发信息 | +| 派发后 | 集合派发:生成后关闭弹窗并刷新任务列表;不进入三栏编辑 | +| 单条派发 | 仍:生成 1 条 → 进入编辑工艺(现状) | +| 存为集合 | 仅【生产工单】管理页;有任务即可(含已完成工单) | +| 路线语义 | 派发时按当时路线克隆步骤;工单上事后改过的步骤不进集合 | + +--- + +## 2. 概念关系 + +``` +工艺路线(步骤集合) 生产任务集合(派发信息集合) + │ │ + │ 可选引用 │ 有序多条 + ▼ ▼ +生产任务 ←── 单条派发 / 按集合批量派发 ── 生产工单 +``` + +- **工艺路线**:一条任务内部的步骤模板。 +- **任务集合**:多条「待生成生产任务」的派发参数模板。 +- 派发时:对集合每一条目调用与现有单条创建相同的建任务逻辑(含可选路线克隆),同一事务。 + +--- + +## 3. 数据模型 + +### 3.1 `biz_production_task_set`(集合) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | VARCHAR(64) | 主键 UUID | +| `set_name` | VARCHAR(200) NOT NULL | 集合名称;租户内未删除记录唯一 | +| `status` | CHAR(1) NOT NULL DEFAULT '1' | `1` 启用 / `0` 停用 | +| 公共字段 | | `tenant_no`、`order_num`、`remark`、`create_by`、`create_dept_id`、`create_time`、`update_by`、`update_time`、`del_flag` | + +字段顺序:`id` → 业务字段 → 公共字段。软删。列表默认不展示 `order_num`。 + +### 3.2 `biz_production_task_set_item`(条目) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | VARCHAR(64) | 主键 UUID | +| `set_id` | VARCHAR(64) NOT NULL | 所属集合 | +| `task_name` | VARCHAR(200) NOT NULL | 任务名称 | +| `route_id` | VARCHAR(64) NULL | 工艺路线 ID,可选 | +| `route_name` | VARCHAR(100) NULL | 路线名称快照(展示用) | +| `description` | VARCHAR(500) NULL | 描述 | +| `remark` | VARCHAR(512) NULL | 备注 | +| 公共字段 | | 同上;条目顺序用 `order_num` | + +约束: + +- 保存集合时至少一条有效条目(`task_name` 非空)。 +- `route_id` 若有值,须指向本租户未删除的工艺路线;写入时同步 `route_name` 快照。 +- 更新集合时:**整表替换**条目(先软删旧条目再插入新条目,或等价事务内重建)。 + +### 3.3 SQL + +- 新建:`docs/sql/` 下建表脚本(及菜单授权脚本,见 §7)。 +- 不改现有生产任务 / 工艺路线表结构。 + +--- + +## 4. 后端 API + +前缀与鉴权对齐现有 production 管理端:`/web/admin/...`,`require_admin_or_super_admin`,租户隔离。 + +### 4.1 集合 CRUD + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/production-task-sets` | 分页列表;keyword、status;返回含 `itemCount` | +| GET | `/production-task-sets/active` | 启用集合简表(派发下拉:id、setName、itemCount) | +| GET | `/production-task-sets/{id}` | 详情 + 按 `order_num` 排序的条目(含路线展示名) | +| POST | `/production-task-sets` | 新增(body:setName、status、remark、items[]) | +| PUT | `/production-task-sets/{id}` | 更新(整表替换 items) | +| DELETE | `/production-task-sets/{id}` | 软删集合及条目 | + +`items[]` 元素:`taskName`、`routeId?`、`description?`、`remark?`(顺序即数组序,服务端写 `order_num`)。 + +### 4.2 从工单生成集合 + +`POST /production-task-sets/from-work-order` + +Body:`workOrderId`、`setName`、`remark?` + +规则: + +1. 工单须存在且属当前租户。 +2. 读取该工单下未删除生产任务,按 `create_time ASC`、`id ASC` 稳定排序。 +3. 每条任务映射为一条目:`taskName`、`routeId`、`routeName`(若有路线则解析名称)、`description`、`remark`。 +4. **`task_name` 为空的任务跳过**;若跳过后无任何条目 → `400`,提示无可保存任务。 +5. 集合 `status` 默认启用。 +6. `set_name` 冲突 → `400`。 + +成功响应带 `skippedEmptyNameCount`(无跳过则为 0),便于前端提示。 + +### 4.3 按集合批量创建任务 + +`POST /production-tasks/from-set` + +Body:`workOrderId`、`setId` + +规则: + +1. 工单状态须为 `dpz` 或 `jxz`(与现有可派发条件一致)。 +2. 集合须存在、未删、`status=1`,且至少一条条目。 +3. 对每条条目:若 `route_id` 非空但路线不存在/已删 → **整单回滚**,错误信息列出缺失路线。 +4. 按 `order_num` 顺序,对每条调用与现有 `create_production_task` 相同逻辑(`task_name`、`route_id`、`description`、`remark`,以及由工单推导的 `task_type` / `order_source`);有路线则克隆步骤与路线默认设备。 +5. 同一事务;成功后同步工单状态(复用现有 `sync_production_work_order_status_from_tasks`)。 +6. 响应:新建任务列表摘要(id、taskName、taskNo)或至少 `createdCount`。 + +**不改** `POST /production-tasks` 单条创建行为。 + +--- + +## 5. 前端 + +### 5.1 维护页(P01) + +- 路径建议:`/#/production-group/production-base/production-task-set` +- 源码:`web-admin/src/views/production/production-task-set/index.vue` +- 参考:`quality-inspection/inspection-item-template/index.vue` +- 面板标题:`生产任务集合列表`(须写入 `App.vue` `panelTitleBarTextRules`) + +列表:集合名称、任务条数、状态 Tag、备注、创建时间;操作查看 / 编辑 / 删除。 + +筛选:关键词、状态。工具栏:新增集合(唯一 primary)。 + +新增/编辑弹窗: + +- `label-position="top"`,`dialog-form-layout`,约 720~900px(条目子表需宽) +- 集合名称、状态、备注 +- 全宽条目表:任务名称、工艺路线(远程检索,同 `DispatchTaskDialog`)、描述、备注;增行 / 删行 / 上移下移;至少一行 +- 底栏:取消 Close / 确定 Check + +查看:只读展示同上字段。 + +### 5.2 派发弹窗 `DispatchTaskDialog.vue` + +阶段一表单顶部增加 **派发方式**: + +| 方式 | UI | 主按钮 | 成功后 | +|------|-----|--------|--------| +| 单条任务(默认) | 现有:任务名称、工艺路线、描述、备注 | 生成任务 | 进入三栏编辑工艺(现状) | +| 任务集合 | 下拉选启用集合(`/active`);选中后 `GET /{id}` 拉条目做只读预览(名称/路线/描述/备注) | 生成任务 | 调用 `from-set` → 关闭弹窗 → 刷新任务列表;**不**进编辑阶段 | + +校验: + +- 集合方式:必须选中集合;预览至少一条。 +- 后端返回路线缺失等错误:弹窗内提示,不关闭。 + +编辑已有任务(`editTaskId`)路径不变,不出现派发方式切换。卡片头与任务区空态的「派发任务」共用本弹窗,一并生效。 + +### 5.3 存为集合 + +仅 `pageMode === 'manage'`: + +- 卡片 `…` 菜单增加「存为任务集合」。 +- 显示条件:该工单已加载到至少一条生产任务(可用卡片内 `presetTasks` / 任务区数据判断)。 +- 弹窗:集合名称、备注、只读预览映射条目;确定调 `from-work-order`。 +- 【我的生产任务】不出现该菜单项。 + +### 5.4 API 模块 + +新建 `web-admin/src/api/production-task-set.ts`;`production-task.ts` 增加 `fetchCreateProductionTasksFromSet`。 + +--- + +## 6. 错误处理与边界 + +| 场景 | 行为 | +|------|------| +| 集合名为空 / 重名 | 400,前端表单校验 + 接口提示 | +| 无条目或任务名全空 | 保存失败 | +| 停用集合 | 不出现在 `/active`;已打开派发若仍提交由后端拒绝 | +| 集合中路线已删 | `from-set` 整单失败,提示缺失路线 | +| 工单已完成/已作废 | 不允许 `from-set`;仍允许「存为集合」 | +| 集合派发后 | 用户对每条任务单独「编辑工艺」「步骤派发」 | +| 同一工单多次派发同一集合 | 允许(与多次单条派发一致,会再生成一批任务) | + +--- + +## 7. 菜单与权限 + +- 父级:生产基础信息(`production-base` / PRB) +- C 菜单:生产任务集合;`path=production-task-set`;`component=/production/production-task-set/index` +- SQL:`docs/sql/alter_sys_menu_add_production_task_set.sql`,幂等;对已有 PRB 子菜单权限的角色授予(对照废弃物 / 检测项目模版菜单脚本) + +| 能力 | perms | +|------|-------| +| 列表/查看 | `production:task-set:list` | +| 新增(含从工单存为集合) | `production:task-set:add` | +| 编辑(含启停) | `production:task-set:edit` | +| 删除 | `production:task-set:remove` | + +派发弹窗「任务集合」方式:有 list 权限才展示集合下拉;无 list 时仍可用「单条任务」。无 add 权限则隐藏「存为任务集合」。 + +--- + +## 8. 非目标 + +- 集合与工单 Tab / 产品类型的强制绑定或自动默认选中 +- 步骤级快照、批量步骤派发、批量进入执行 +- 派发集合后自动打开第一条的三栏编辑 +- 小程序 / mobile-h5 侧派发 +- 修改「我的生产任务」卡片操作(仍无派发任务) + +--- + +## 9. 验收 + +1. 维护页可增改删启停集合;条目含名称/可选路线/描述/备注;停用后派发下拉不可见。 +2. 派发弹窗「单条」行为与改前一致(生成 → 编辑工艺)。 +3. 派发弹窗「任务集合」选含 4 条的集合 → 一次生成 4 条任务 → 弹窗关闭 → 卡片任务区可见 4 条;不进入三栏编辑。 +4. 集合内某路线已删 → 一条任务都不生成,有明确错误。 +5. 工单「存为任务集合」可保存名称/路线/描述/备注;空名称任务跳过;无有效任务时失败。 +6. 【我的生产任务】无「派发任务」「存为任务集合」。 + +--- + +## 10. 实现落点(摘要) + +| 层 | 落点 | +|----|------| +| SQL | 建表 + 菜单授权 | +| Model / CRUD / Schema | `BizProductionTaskSet`、`BizProductionTaskSetItem`;CRUD;复用 `create_production_task` | +| API | `production.py` 注册集合 CRUD、`from-work-order`、`from-set` | +| 前端页 | `production-task-set/index.vue` + 路由/菜单 | +| 派发 | `DispatchTaskDialog.vue` | +| 存为 | `ProductionWorkOrderCardList.vue` + TabPanel/index 命令处理 | +| 测试 | 后端:`from-set` 事务与路线校验;`from-work-order` 跳过空名;集合名唯一 |