# 出库放行审核 · Tab + 就绪过滤 + 作废隐藏 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:** 出库放行审核对齐入库:待审核/已审核双 Tab、待审核默认仅展示双工单侧完成、两 Tab 常驻隐藏「出库质检/生产全作废」行。 **Architecture:** 在 `list_reviews_paginated`(输注计划主表)增加 `review_phase`、`ready_for_review_only`,以及常驻 `NOT (qc_all_void OR production_all_void)`。工单经 `infusion_schedule_id` 关联出库质检/出库生产。前端同页组件加 Tab +「展示全部待审核」复选框,中试/基础实验/临床入口一并生效。 **Tech Stack:** FastAPI + SQLAlchemy、Vue 3 + Element Plus、pytest **Spec:** `docs/superpowers/specs/2026-07-29-outbound-release-tabs-ready-void-design.md` --- ## File Map | 文件 | 职责 | |------|------| | `backend/tests/test_outbound_release_review_filters.py` | **新建** normalize / 谓词单测 | | `backend/app/crud/outbound_release_review.py` | phase / ready / void EXISTS + `list_reviews_paginated` | | `backend/app/api/outbound_release_review.py` | Query 透传 `reviewPhase`、`readyForReviewOnly` | | `web-admin/src/api/outbound-release-review.ts` | 列表 params 类型 | | `web-admin/src/views/pilot-service/outbound-release-review/index.vue` | Tab、复选框、请求、样式 | | `docs/superpowers/specs/2026-07-29-outbound-release-tabs-ready-void-design.md` | 实现后标「已实现」 | 不改:详情/审核/物料 API、卡片列表结构、表结构。 --- ### Task 1: Backend — normalize、EXISTS、列表过滤、API **Files:** - Create: `backend/tests/test_outbound_release_review_filters.py` - Modify: `backend/app/crud/outbound_release_review.py` - Modify: `backend/app/api/outbound_release_review.py` - [ ] **Step 1: 写失败单测** 创建 `backend/tests/test_outbound_release_review_filters.py`: ```python from app.crud.outbound_release_review import ( normalize_outbound_ready_for_review_only, normalize_outbound_review_phase, outbound_work_orders_all_void, ) def test_normalize_outbound_review_phase(): assert normalize_outbound_review_phase("pending") == "pending" assert normalize_outbound_review_phase("REVIEWED") == "reviewed" assert normalize_outbound_review_phase(" reviewed ") == "reviewed" assert normalize_outbound_review_phase(None) is None assert normalize_outbound_review_phase("") is None assert normalize_outbound_review_phase("all") is None def test_normalize_outbound_ready_for_review_only(): assert normalize_outbound_ready_for_review_only(True) is True assert normalize_outbound_ready_for_review_only("true") is True assert normalize_outbound_ready_for_review_only("1") is True assert normalize_outbound_ready_for_review_only("yes") is True assert normalize_outbound_ready_for_review_only(False) is None assert normalize_outbound_ready_for_review_only("false") is None assert normalize_outbound_ready_for_review_only(None) is None def test_outbound_all_void_predicate(): assert outbound_work_orders_all_void([]) is False assert outbound_work_orders_all_void([""]) is False assert outbound_work_orders_all_void(["yzf"]) is True assert outbound_work_orders_all_void(["yzf", "yzf"]) is True assert outbound_work_orders_all_void([" YZF ", "yzf"]) is True assert outbound_work_orders_all_void(["yzf", "ywc"]) is False assert outbound_work_orders_all_void(["yzf", ""]) is False ``` - [ ] **Step 2: 跑单测确认失败** ```bash cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_outbound_release_review_filters.py -v ``` Expected: FAIL(导入失败) - [ ] **Step 3: 实现 normalize、谓词、EXISTS,并接入 `list_reviews_paginated`** 在 `backend/app/crud/outbound_release_review.py`: 1) 顶部将 sqlalchemy 导入改为: ```python from sqlalchemy import and_, exists, func, or_ ``` (已有 `and_`/`func`/`or_` 则只补 `exists`。) 2) 在 `_total_pages` 之后(或 Helpers 区)增加: ```python def normalize_outbound_review_phase(review_phase: Optional[str]) -> Optional[str]: p = (review_phase or "").strip().lower() if p in ("pending", "reviewed"): return p return None def normalize_outbound_ready_for_review_only(raw) -> Optional[bool]: """Return True when ready-only filter should apply; None means no filter.""" if raw is None: return None if isinstance(raw, bool): return True if raw else None s = str(raw).strip().lower() if s in ("true", "1", "yes"): return True return None def outbound_work_orders_all_void(statuses) -> bool: """True when ≥1 non-empty status and every one equals yzf.""" cleaned = [(s or "").strip() for s in (statuses or [])] if not cleaned or not any(s for s in cleaned): return False return all(s.lower() == "yzf" for s in cleaned) def _outbound_qc_unfinished_exists_for_schedule(tenant_no: str): """Any unfinished outbound schedule QC (dpz/ypz) for this infusion schedule.""" return ( exists() .where( and_( BizQcWorkOrder.tenant_no == tenant_no, BizQcWorkOrder.del_flag == 0, BizQcWorkOrder.qc_biz_type == "outbound", BizQcWorkOrder.trigger_source == "schedule", BizQcWorkOrder.infusion_schedule_id == BizPilotInfusionSchedule.id, BizQcWorkOrder.work_order_status.in_(("dpz", "ypz")), ) ) .correlate(BizPilotInfusionSchedule) ) def _outbound_production_unfinished_exists_for_schedule(tenant_no: str): return ( exists() .where( and_( BizProductionWorkOrder.tenant_no == tenant_no, BizProductionWorkOrder.del_flag == 0, BizProductionWorkOrder.work_order_type == "outbound", BizProductionWorkOrder.infusion_schedule_id == BizPilotInfusionSchedule.id, BizProductionWorkOrder.work_order_status.in_(("dpz", "jxz")), ) ) .correlate(BizPilotInfusionSchedule) ) def _outbound_qc_all_void_for_schedule(tenant_no: str): base = [ BizQcWorkOrder.tenant_no == tenant_no, BizQcWorkOrder.del_flag == 0, BizQcWorkOrder.qc_biz_type == "outbound", BizQcWorkOrder.trigger_source == "schedule", BizQcWorkOrder.infusion_schedule_id == BizPilotInfusionSchedule.id, ] any_wo = exists().where(and_(*base)).correlate(BizPilotInfusionSchedule) any_non_void = ( exists() .where( and_( *base, or_( BizQcWorkOrder.work_order_status.is_(None), BizQcWorkOrder.work_order_status != "yzf", ), ) ) .correlate(BizPilotInfusionSchedule) ) return and_(any_wo, ~any_non_void) def _outbound_production_all_void_for_schedule(tenant_no: str): base = [ BizProductionWorkOrder.tenant_no == tenant_no, BizProductionWorkOrder.del_flag == 0, BizProductionWorkOrder.work_order_type == "outbound", BizProductionWorkOrder.infusion_schedule_id == BizPilotInfusionSchedule.id, ] any_wo = exists().where(and_(*base)).correlate(BizPilotInfusionSchedule) any_non_void = ( exists() .where( and_( *base, or_( BizProductionWorkOrder.work_order_status.is_(None), BizProductionWorkOrder.work_order_status != "yzf", ), ) ) .correlate(BizPilotInfusionSchedule) ) return and_(any_wo, ~any_non_void) ``` 3) 修改 `list_reviews_paginated` 签名,在 `infusion_time_end` 之后增加: ```python review_phase: Optional[str] = None, ready_for_review_only=None, ``` 4) **替换**现有「`# ── review_conclusion 过滤 ──`」整块为(保留旧 `review_conclusion=pending` 兼容,但 `review_phase` 优先): ```python # ── reviewPhase / review_conclusion 过滤 ── phase = normalize_outbound_review_phase(review_phase) rc = (review_conclusion or "").strip() if phase == "pending" or (phase is None and rc == "pending"): reviewed_schedule_ids = ( db.query(BizOutboundReleaseReview.infusion_schedule_id) .filter( BizOutboundReleaseReview.tenant_no == tenant_no, BizOutboundReleaseReview.del_flag == 0, BizOutboundReleaseReview.review_conclusion.isnot(None), BizOutboundReleaseReview.review_conclusion != "", ) .subquery() ) q0 = q0.filter(~BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)) elif phase == "reviewed": reviewed_schedule_ids = ( db.query(BizOutboundReleaseReview.infusion_schedule_id) .filter( BizOutboundReleaseReview.tenant_no == tenant_no, BizOutboundReleaseReview.del_flag == 0, BizOutboundReleaseReview.review_conclusion.isnot(None), BizOutboundReleaseReview.review_conclusion != "", ) .subquery() ) q0 = q0.filter(BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)) if rc in ("tg", "btg"): conclusion_ids = ( db.query(BizOutboundReleaseReview.infusion_schedule_id) .filter( BizOutboundReleaseReview.tenant_no == tenant_no, BizOutboundReleaseReview.del_flag == 0, BizOutboundReleaseReview.review_conclusion == rc, ) .subquery() ) q0 = q0.filter(BizPilotInfusionSchedule.id.in_(conclusion_ids)) elif rc in ("tg", "btg"): reviewed_schedule_ids = ( db.query(BizOutboundReleaseReview.infusion_schedule_id) .filter( BizOutboundReleaseReview.tenant_no == tenant_no, BizOutboundReleaseReview.del_flag == 0, BizOutboundReleaseReview.review_conclusion == rc, ) .subquery() ) q0 = q0.filter(BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids)) if normalize_outbound_ready_for_review_only(ready_for_review_only) is True: q0 = q0.filter( ~_outbound_qc_unfinished_exists_for_schedule(tenant_no), ~_outbound_production_unfinished_exists_for_schedule(tenant_no), ) # 出库质检/生产「有工单且全部 yzf」→ 列表隐藏(两 Tab 常驻) q0 = q0.filter( ~or_( _outbound_qc_all_void_for_schedule(tenant_no), _outbound_production_all_void_for_schedule(tenant_no), ) ) ``` 将上述块插在**原 review_conclusion 过滤位置**,且必须在 `if user_info:` / `total = ...` **之前**。删除旧的仅 `rc == "pending"` / `elif rc:` 重复逻辑,避免双重过滤。 - [ ] **Step 4: API 透传** 在 `backend/app/api/outbound_release_review.py` 的 `list_pilot_release_reviews`: 在 `infusionTimeEnd: Optional[str] = None,` 后增加: ```python reviewPhase: Optional[str] = Query(None), readyForReviewOnly: Optional[bool] = Query(None), ``` 在 `list_reviews_paginated(...)` 调用中增加: ```python review_phase=reviewPhase, ready_for_review_only=readyForReviewOnly, ``` - [ ] **Step 5: 跑单测** ```bash cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_outbound_release_review_filters.py -v ``` Expected: PASS - [ ] **Step 6: Commit** ```bash git add backend/tests/test_outbound_release_review_filters.py \ backend/app/crud/outbound_release_review.py \ backend/app/api/outbound_release_review.py git commit -m "$(cat <<'EOF' feat(outbound-release): add reviewPhase, ready filter, and void hide List supports pending/reviewed phase, readyForReviewOnly, and always hides schedules whose outbound QC or production WOs are all voided. EOF )" ``` --- ### Task 2: Frontend — API params + Tab + 复选框 **Files:** - Modify: `web-admin/src/api/outbound-release-review.ts` - Modify: `web-admin/src/views/pilot-service/outbound-release-review/index.vue` - [ ] **Step 1: API params** 修改 `fetchOutboundReleaseReviewList` 的 params 类型与调用,增加: ```typescript reviewPhase?: 'pending' | 'reviewed' readyForReviewOnly?: boolean ``` - [ ] **Step 2: 页面结构 — Tab + 复选框 + 去掉空「待审核」** 在 `index.vue` 模板中,于 `panel-top` 之后、`filter-block` 之前插入(对齐入库): ```vue