1699 lines
56 KiB
Markdown
1699 lines
56 KiB
Markdown
# 生产任务集合 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 可维护的生产任务集合主数据;派发弹窗可按集合一次生成多条生产任务;工单可将已派发任务存为集合。
|
||
|
||
**Architecture:** 两表(集合头 + 条目:任务名称/可选工艺路线/描述/备注)。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<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 }
|
||
}
|
||
```
|
||
|
||
```ts
|
||
// 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` 增加:
|
||
|
||
```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
|
||
<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` 开头:
|
||
|
||
```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
|
||
<el-dropdown-item
|
||
v-if="canSaveTaskSet(row)"
|
||
command="save-set"
|
||
>
|
||
<el-icon><FolderAdd /></el-icon>存为任务集合
|
||
</el-dropdown-item>
|
||
```
|
||
|
||
```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'`
|