From a08903199470fdc19f8bbaf77e64f481253c2924 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Tue, 18 Aug 2026 10:48:56 +0800 Subject: [PATCH] docs: add production waste-output list implementation plan. Break the approved design into TDD-backed backend list helpers, menu SQL, and a P01 read-only web-admin page. Co-authored-by: Cursor --- ...2026-08-18-production-waste-output-list.md | 592 ++++++++++++++++++ 1 file changed, 592 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-production-waste-output-list.md diff --git a/docs/superpowers/plans/2026-08-18-production-waste-output-list.md b/docs/superpowers/plans/2026-08-18-production-waste-output-list.md new file mode 100644 index 00000000..3bef8c08 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-production-waste-output-list.md @@ -0,0 +1,592 @@ +# 生产废弃物管理(只读列表)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:** 在【生产管理】下新增【废弃物管理】只读列表页,分页汇总所有生产任务工艺步骤中类型为废弃物(FQW)的步骤产出。 + +**Architecture:** 不新建业务表。后端按租户解析 `biz_output_type`(优先 `type_code=FQW`,兜底 `type_name=废弃物`),JOIN `biz_production_task_step_output` → step → task → work_order,应用与生产任务列表相同的数据权限后分页;前端 P01 只读列表 +「查看」打开只读 `TaskExecutionDialog`。 + +**Tech Stack:** FastAPI / SQLAlchemy / Vue 3 / Element Plus;设计依据 `docs/superpowers/specs/2026-08-18-production-waste-output-list-design.md`。 + +**Spec:** `docs/superpowers/specs/2026-08-18-production-waste-output-list-design.md` + +--- + +## File map + +| File | Responsibility | +|------|----------------| +| Create: `backend/app/crud/production_waste_output.py` | 废弃物类型 id 解析 + 列表查询 | +| Create: `backend/tests/test_production_waste_output.py` | 类型解析纯函数 / 关键字匹配语义单测 | +| Modify: `backend/app/api/production.py` | 注册 `GET /production-waste-outputs` | +| Create: `docs/sql/alter_sys_menu_add_production_waste_output.sql` | 幂等菜单 + 角色授权 | +| Modify: `待同步到生产的sql脚本` | 追加待上线 SQL 片段 | +| Create: `web-admin/src/api/production-waste-output.ts` | 列表 API | +| Create: `web-admin/src/views/production/waste-output/index.vue` | P01 只读列表页 | +| Modify: `web-admin/src/router/modules/sqxp.ts` | 路由(洁净服后) | +| Modify: `backend/app/api/menu.py` | 静态菜单镜像(洁净服后) | + +--- + +### Task 1: 后端 — 废弃物类型 id 解析(TDD) + +**Files:** +- Create: `backend/app/crud/production_waste_output.py` +- Create: `backend/tests/test_production_waste_output.py` + +- [ ] **Step 1: Write the failing test** + +```python +# backend/tests/test_production_waste_output.py +"""生产废弃物列表:产出类型解析与关键字匹配语义。""" +import unittest + +from app.crud.production_waste_output import ( + WASTE_OUTPUT_TYPE_CODE, + WASTE_OUTPUT_TYPE_NAME, + match_waste_output_keyword, + pick_waste_output_type_ids, +) + + +class TestPickWasteOutputTypeIds(unittest.TestCase): + def test_prefers_type_code_fqw(self): + rows = [ + {"id": "a", "type_code": "YXW", "type_name": "有效物"}, + {"id": "b", "type_code": "FQW", "type_name": "废弃物"}, + {"id": "c", "type_code": "", "type_name": "废弃物"}, + ] + self.assertEqual(pick_waste_output_type_ids(rows), {"b"}) + + def test_fallback_type_name_when_no_fqw(self): + rows = [ + {"id": "a", "type_code": "YXW", "type_name": "有效物"}, + {"id": "c", "type_code": "", "type_name": "废弃物"}, + {"id": "d", "type_code": None, "type_name": "废弃物"}, + ] + self.assertEqual(pick_waste_output_type_ids(rows), {"c", "d"}) + + def test_empty_when_no_match(self): + self.assertEqual(pick_waste_output_type_ids([]), set()) + self.assertEqual( + pick_waste_output_type_ids([{"id": "a", "type_code": "YXW", "type_name": "有效物"}]), + set(), + ) + + +class TestMatchWasteOutputKeyword(unittest.TestCase): + def test_matches_name_or_barcode(self): + self.assertTrue(match_waste_output_keyword("废液", "废液A", "X1")) + self.assertTrue(match_waste_output_keyword("X1", "废液", "BX1Y")) + self.assertFalse(match_waste_output_keyword("zzz", "废液", "X1")) + + def test_blank_keyword_always_true(self): + self.assertTrue(match_waste_output_keyword("", "废液", "")) + self.assertTrue(match_waste_output_keyword(None, "", "")) + + +class TestConstants(unittest.TestCase): + def test_constants(self): + self.assertEqual(WASTE_OUTPUT_TYPE_CODE, "FQW") + self.assertEqual(WASTE_OUTPUT_TYPE_NAME, "废弃物") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +cd backend && source venv/bin/activate && python -m pytest tests/test_production_waste_output.py -v +``` + +Expected: FAIL(模块不存在) + +- [ ] **Step 3: Minimal implementation** + +```python +# backend/app/crud/production_waste_output.py +"""生产任务步骤废弃物产出 — 只读汇总列表。""" +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple + +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session + +from app.models.models import ( + BizOutputType, + BizProductionTask, + BizProductionTaskStep, + BizProductionTaskStepOutput, + BizProductionWorkOrder, +) + +WASTE_OUTPUT_TYPE_CODE = "FQW" +WASTE_OUTPUT_TYPE_NAME = "废弃物" + + +def pick_waste_output_type_ids(rows: Iterable[Dict[str, Any]]) -> Set[str]: + """从产出类型行中选出废弃物类型 id:优先 type_code=FQW,否则兜底 type_name=废弃物。""" + rows = list(rows or []) + by_code = { + str(r.get("id") or "").strip() + for r in rows + if str(r.get("type_code") or "").strip() == WASTE_OUTPUT_TYPE_CODE + and str(r.get("id") or "").strip() + } + if by_code: + return by_code + return { + str(r.get("id") or "").strip() + for r in rows + if str(r.get("type_name") or "").strip() == WASTE_OUTPUT_TYPE_NAME + and str(r.get("id") or "").strip() + } + + +def match_waste_output_keyword( + keyword: Optional[str], output_name: Optional[str], output_barcode: Optional[str] +) -> bool: + kw = (keyword or "").strip() + if not kw: + return True + name = output_name or "" + barcode = output_barcode or "" + return kw in name or kw in barcode + + +def resolve_waste_output_type_ids(db: Session, tenant_no: str) -> Set[str]: + rows = ( + db.query(BizOutputType.id, BizOutputType.type_code, BizOutputType.type_name) + .filter(BizOutputType.tenant_no == tenant_no, BizOutputType.del_flag == 0) + .all() + ) + return pick_waste_output_type_ids( + [{"id": r.id, "type_code": r.type_code, "type_name": r.type_name} for r in rows] + ) + + +def _fmt_dt(t) -> str: + if t is None: + return "" + if hasattr(t, "strftime"): + return t.strftime("%Y-%m-%d %H:%M:%S") + return str(t) + + +def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> str: + v = (value or "").strip() + if not v: + return v + if len(v) <= 10: + return f"{v} 23:59:59" if end_of_day else f"{v} 00:00:00" + return v + + +def list_production_waste_outputs( + db: Session, + *, + tenant_no: str, + page: int = 1, + page_size: int = 10, + keyword: Optional[str] = None, + create_time_start: Optional[str] = None, + create_time_end: Optional[str] = None, + user_info=None, +) -> Tuple[List[Dict[str, Any]], int]: + type_ids = resolve_waste_output_type_ids(db, tenant_no) + if not type_ids: + return [], 0 + + q = ( + db.query( + BizProductionTaskStepOutput, + BizProductionTaskStep, + BizProductionTask, + BizProductionWorkOrder, + ) + .join( + BizProductionTaskStep, + and_( + BizProductionTaskStep.id == BizProductionTaskStepOutput.step_id, + BizProductionTaskStep.del_flag == 0, + ), + ) + .join( + BizProductionTask, + and_( + BizProductionTask.id == BizProductionTaskStep.task_id, + BizProductionTask.del_flag == 0, + BizProductionTask.tenant_no == tenant_no, + ), + ) + .outerjoin( + BizProductionWorkOrder, + and_( + BizProductionWorkOrder.id == BizProductionTask.work_order_id, + BizProductionWorkOrder.del_flag == 0, + ), + ) + .filter( + BizProductionTaskStepOutput.tenant_no == tenant_no, + BizProductionTaskStepOutput.del_flag == 0, + BizProductionTaskStepOutput.output_type_id.in_(list(type_ids)), + ) + ) + + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter( + or_( + BizProductionTaskStepOutput.output_name.like(like), + BizProductionTaskStepOutput.output_barcode.like(like), + ) + ) + if create_time_start and create_time_start.strip(): + q = q.filter( + BizProductionTaskStepOutput.create_time + >= _normalize_date_bound(create_time_start.strip()) + ) + if create_time_end and create_time_end.strip(): + q = q.filter( + BizProductionTaskStepOutput.create_time + <= _normalize_date_bound(create_time_end.strip(), end_of_day=True) + ) + + if user_info is not None: + from app.core.data_scope import apply_data_scope + + q = apply_data_scope(q, BizProductionTask, user_info, db, tenant_no) + + q = q.order_by(BizProductionTaskStepOutput.create_time.desc()) + total = q.count() + page = max(1, int(page or 1)) + page_size = max(1, min(int(page_size or 10), 100)) + rows = q.offset((page - 1) * page_size).limit(page_size).all() + + items: List[Dict[str, Any]] = [] + for out, step, task, wo in rows: + items.append( + { + "id": out.id, + "outputName": out.output_name or "", + "outputBarcode": out.output_barcode or "", + "remark": out.remark or "", + "createTime": _fmt_dt(out.create_time), + "stepId": out.step_id or "", + "processName": (step.process_name if step else None) or "", + "taskId": (task.id if task else None) or "", + "taskNo": (task.task_no if task else None) or "", + "workOrderId": (wo.id if wo else None) or (task.work_order_id if task else "") or "", + "workOrderNo": (wo.work_order_no if wo else None) or "", + "sampleCode": (wo.sample_code if wo else None) or "", + } + ) + return items, total + + +def total_pages(total: int, page_size: int) -> int: + if page_size <= 0: + return 0 + return (total + page_size - 1) // page_size +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd backend && source venv/bin/activate && python -m pytest tests/test_production_waste_output.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/crud/production_waste_output.py backend/tests/test_production_waste_output.py +git commit -m "feat(backend): add production waste-output list query helpers." +``` + +--- + +### Task 2: 后端 — 注册列表 API + +**Files:** +- Modify: `backend/app/api/production.py`(在 `list_production_tasks` 附近新增路由) + +- [ ] **Step 1: 增加 import 与路由** + +在 `production.py` 顶部 CRUD import 区增加: + +```python +from app.crud import production_waste_output as production_waste_output_svc +``` + +在合适位置(建议 `/production-tasks` 路由组附近)增加: + +```python +@router.get("/production-waste-outputs") +async def list_production_waste_outputs( + page: int = Query(1, ge=1), + pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), + keyword: Optional[str] = None, + createTimeStart: Optional[str] = Query(None, alias="createTimeStart"), + createTimeEnd: Optional[str] = Query(None, alias="createTimeEnd"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items, total = production_waste_output_svc.list_production_waste_outputs( + db, + tenant_no=tenant_no, + page=page, + page_size=pageSize, + keyword=keyword, + create_time_start=createTimeStart, + create_time_end=createTimeEnd, + user_info=user, + ) + tp = production_waste_output_svc.total_pages(total, pageSize) + return success_response( + {"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp} + ) +``` + +- [ ] **Step 2: 冒烟导入** + +```bash +cd backend && source venv/bin/activate && python -c "from app.api.production import list_production_waste_outputs; print('ok')" +``` + +Expected: `ok` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/api/production.py +git commit -m "feat(backend): expose GET /production-waste-outputs list API." +``` + +--- + +### Task 3: 菜单 SQL + 待同步脚本 + +**Files:** +- Create: `docs/sql/alter_sys_menu_add_production_waste_output.sql` +- Modify: `待同步到生产的sql脚本`(追加同一可执行片段) + +**约定:** + +| 项 | 值 | +|----|-----| +| C id | `95300000000000000000000000000001` | +| F id | `95300000000000000000000000000002` | +| parent | `PRM00000000000000000000000000001` | +| path | `waste-output` | +| component | `/production/waste-output/index` | +| perms | `production:waste-output:list` | +| order_num | `8`(洁净服当前为 `7`,紧挨其后) | +| F menu_name | 废弃物查询 | + +- [ ] **Step 1: 编写幂等 SQL** + +脚本须包含: + +1. 若不存在则 INSERT C / F(或按 id 更新 path/component/perms/order_num)。 +2. 对「已拥有 PRM 下任一有效子菜单」的角色,幂等授予 C+F,并补祖先 `PRM000…`(避免 MySQL 1093:用临时表/派生表,勿同句边读边写 `sys_role_menu`)。 +3. `sys_role_menu.id = UPPER(MD5(CONCAT(role_id, '|', menu_id)))`;软删先恢复。 + +参考写法:`docs/superpowers/specs/2026-08-17-production-pre-transit-sample-design.md` §6.2(同 PRM 父菜单授权模式)。若仓库中仍有 `alter_sys_menu_add_production_pre_transit_sample.sql` 历史副本可对照;否则按该设计段落手写。 + +C 菜单 INSERT 示例值: + +```sql +-- menu_name='废弃物管理', parent_id='PRM00000000000000000000000000001', menu_type='C', +-- path='waste-output', component='/production/waste-output/index', +-- perms='production:waste-output:list', order_num=8, tenant_no='zkxw', del_flag=0 +``` + +F 按钮:`menu_type='F'`, `parent_id='95300000000000000000000000000001'`, `menu_name='废弃物查询'`, 同 perms, `order_num=1`。 + +- [ ] **Step 2: 将可执行片段追加到 `待同步到生产的sql脚本`** + +- [ ] **Step 3: Commit** + +```bash +git add docs/sql/alter_sys_menu_add_production_waste_output.sql "待同步到生产的sql脚本" +git commit -m "docs(sql): add production waste-output menu migration." +``` + +--- + +### Task 4: 前端 API + 路由 + 菜单镜像 + +**Files:** +- Create: `web-admin/src/api/production-waste-output.ts` +- Modify: `web-admin/src/router/modules/sqxp.ts`(在 `ProductionCleanroomGarment` 路由对象**之后**插入) +- Modify: `backend/app/api/menu.py`(生产管理 children 中洁净服节点之后插入,字段与 sqxp 一致) + +- [ ] **Step 1: API 模块** + +```typescript +// web-admin/src/api/production-waste-output.ts +import request from '@/utils/http' + +export interface ProductionWasteOutputItem { + id: string + outputName?: string + outputBarcode?: string + remark?: string + createTime?: string + stepId?: string + processName?: string + taskId?: string + taskNo?: string + workOrderId?: string + workOrderNo?: string + sampleCode?: string +} + +export function fetchProductionWasteOutputList(params?: { + page?: number + pageSize?: number + keyword?: string + createTimeStart?: string + createTimeEnd?: string +}) { + return request.get<{ + list: ProductionWasteOutputItem[] + total: number + page: number + pageSize: number + totalPages: number + }>({ url: '/web/admin/production-waste-outputs', params }) +} +``` + +- [ ] **Step 2: sqxp.ts 路由** + +在 `path: 'cleanroom-garment'` 那个 children 项后面插入: + +```typescript +{ + id: '95300000000000000000000000000001', + name: 'ProductionWasteOutput', + path: 'waste-output', + component: '/production/waste-output/index', + meta: { + title: '废弃物管理', + keepAlive: true, + menuCode: 'production:waste-output:list' + } +} +``` + +- [ ] **Step 3: menu.py 镜像** + +同样在 `ProductionCleanroomGarment` 字典后插入等价 JSON(Python dict,`True`/`False`)。 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/api/production-waste-output.ts web-admin/src/router/modules/sqxp.ts backend/app/api/menu.py +git commit -m "feat: wire production waste-output API route and menu mirror." +``` + +--- + +### Task 5: 前端只读列表页 + +**Files:** +- Create: `web-admin/src/views/production/waste-output/index.vue` + +**参考:** `web-admin/src/views/system/unit/index.vue`(P01 骨架);「查看」打开任务执行对齐 `web-admin/src/views/production/cell-product/SourceChainGraph.vue`(`TaskExecutionDialog` + `readonly`)。 + +- [ ] **Step 1: 创建页面** + +要点(实现时写完整 Vue,勿省略): + +1. `defineOptions({ name: 'ProductionWasteOutput' })` +2. `panel-top`:`

废弃物列表

`(显式竖条类,无需改 App.vue) +3. `filter-block`:关键字(placeholder「产出名称/条码」)+ 登记时间 `el-date-picker` `type="daterange"` `value-format="YYYY-MM-DD"` → 请求 `createTimeStart` / `createTimeEnd` +4. **无** list-toolbar 新增/批量删除;可省略「已选 N 项」 +5. `list-table` + `stripe`,列:产出名称、条码、工序/步骤、任务号、工单号、样本编码、备注、登记时间、操作(查看) +6. 空单元格显示 `—`;`el-empty` description=`暂无废弃物登记` +7. 分页:`list-body.list-wrap` 内 + `.pagination.list-pager-bar` +8. 操作列 `fixed="right"` `min-width` ≥ 120:`查看` 按钮带 ``;无 `taskId` 时 `disabled` +9. 页面底部挂载: + +```vue + +``` + +`handleView(row)`:若无 `taskId` → `ElMessage.warning('关联任务不可用')`;否则打开对话框。 + +10. `onMounted` / 搜索 / 重置 → `fetchProductionWasteOutputList` + +- [ ] **Step 2: 本地打开页面冒烟** + +- Hash:`/#/production-group/production/waste-output` +- 需先执行菜单 SQL;或临时用 frontend access 模式验证路由组件可加载 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/views/production/waste-output/index.vue +git commit -m "feat(web-admin): add production waste-output read-only list page." +``` + +--- + +### Task 6: 图谱更新 + 验收 + +- [ ] **Step 1: 更新知识图谱** + +```bash +./scripts/update-graph.sh backend +./scripts/update-graph.sh frontend +``` + +- [ ] **Step 2: 手工验收(对照 spec §9)** + +1. 任务执行登记废弃物(可空名称/空条码)→ 本页刷新可见;有效物不出现。 +2. 关键字、登记时间、分页正确。 +3. 「查看」打开只读任务执行;无 taskId 时禁用/提示。 +4. 侧栏【生产管理】在洁净服后出现【废弃物管理】。 + +- [ ] **Step 3: Commit graphify(若有变更)** + +```bash +git add backend/graphify-out web-admin/graphify-out +git commit -m "chore: update graphify after waste-output feature." +``` + +--- + +## Spec coverage check + +| Spec 项 | Task | +|---------|------| +| 只读列表 / 无写 API | Task 2、5 | +| FQW 优先 + 名称兜底 | Task 1 | +| 空名称/条码也展示 | Task 1 查询无非空过滤 | +| 关键字 + 登记时间 | Task 1、2、5 | +| JOIN step/task/wo + 数据权限 | Task 1 | +| 菜单同级紧挨洁净服 order 8 | Task 3、4 | +| 查看 → 只读任务执行 | Task 5 | +| SQL 待同步 | Task 3 | +| graphify | Task 6 | + +## Placeholder scan + +无 TBD;类型解析与列表函数签名前后一致(`list_production_waste_outputs` / `fetchProductionWasteOutputList`)。