2026-07-29-outbound-release-tabs-ready-void.md 19 KB

出库放行审核 · 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:

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: 跑单测确认失败
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 导入改为:

from sqlalchemy import and_, exists, func, or_

(已有 and_/func/or_ 则只补 exists。)

2) 在 _total_pages 之后(或 Helpers 区)增加:

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 之后增加:

    review_phase: Optional[str] = None,
    ready_for_review_only=None,

4) 替换现有「# ── review_conclusion 过滤 ──」整块为(保留旧 review_conclusion=pending 兼容,但 review_phase 优先):

    # ── 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, 后增加:

    reviewPhase: Optional[str] = Query(None),
    readyForReviewOnly: Optional[bool] = Query(None),

在 list_reviews_paginated(...) 调用中增加:

            review_phase=reviewPhase,
            ready_for_review_only=readyForReviewOnly,
  • Step 5: 跑单测
cd backend && source venv/bin/activate && PYTHONPATH=. pytest tests/test_outbound_release_review_filters.py -v

Expected: PASS

  • Step 6: Commit
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 类型与调用,增加:

  reviewPhase?: 'pending' | 'reviewed'
  readyForReviewOnly?: boolean
  • Step 2: 页面结构 — Tab + 复选框 + 去掉空「待审核」

在 index.vue 模板中,于 panel-top 之后、filter-block 之前插入(对齐入库):

      <div class="panel-pill-tabs panel-pill-tabs--lg">
        <div class="panel-pill-tabs__track" role="tablist">
          <button
            v-for="tab in STATUS_TABS"
            :key="tab.name"
            type="button"
            role="tab"
            class="panel-pill-tabs__btn"
            :class="{ 'is-active': activeTab === tab.name }"
            :aria-selected="activeTab === tab.name"
            @click="handleTabChange(tab.name)"
          >
            {{ tab.label }}
          </button>
        </div>
      </div>

审核结论 el-select:删除 <el-option label="待审核" value="" />,仅保留 reviewConclusionOptions 循环。

在 filter-actions-row 内、filter-actions-end 之前插入:

            <label
              v-if="activeTab === 'pending'"
              class="storage-release-ready-check"
            >
              <el-checkbox v-model="searchForm.showAllPending" />
              <span class="storage-release-ready-check__copy">
                <span class="storage-release-ready-check__title">展示全部待审核</span>
                <span class="storage-release-ready-check__hint">
                  默认不勾选:仅展示质检与生产工单均为完成的记录;勾选后展示全部。
                </span>
              </span>
            </label>
  • Step 3: script 逻辑

在 defineOptions 附近增加:

  type ReviewTab = 'pending' | 'reviewed'

  const STATUS_TABS = [
    { name: 'pending' as const, label: '待审核' },
    { name: 'reviewed' as const, label: '已审核' }
  ]

  const activeTab = ref<ReviewTab>('pending')

searchForm 增加 showAllPending: false。

改写 loadData:

  async function loadData() {
    loading.value = true
    try {
      const readyOnly =
        activeTab.value === 'pending' && !searchForm.showAllPending ? true : undefined
      const res = await fetchOutboundReleaseReviewList({
        page: pagination.page,
        pageSize: pagination.pageSize,
        reviewConclusion: searchForm.reviewConclusion || undefined,
        keyword: searchForm.keyword || undefined,
        bizType: effectiveBizType.value,
        infusionTimeStart: searchForm.infusionTimeRange?.[0] || undefined,
        infusionTimeEnd: searchForm.infusionTimeRange?.[1] || undefined,
        reviewPhase: activeTab.value,
        readyForReviewOnly: readyOnly
      })
      tableData.value = res.list || []
      pagination.total = res.total ?? 0
    } finally {
      loading.value = false
    }
  }

增加:

  function handleTabChange(name: ReviewTab) {
    if (activeTab.value === name) return
    activeTab.value = name
    pagination.page = 1
    selectedRows.value = []
    if (name === 'pending') {
      searchForm.showAllPending = false
    }
    loadData()
  }

handleReset 增加:searchForm.showAllPending = false(若当前在待审核;或始终重置该字段)。

注意:现有 watchDebounced(searchForm, ...) 会在勾选复选框时自动搜索——保留即可。Tab 切换走 handleTabChange,不要让 activeTab 进 searchForm 以免重复请求逻辑混乱。

  • Step 4: 样式

若文件无 <style scoped>,在 script 后增加;若已有则合并。复用入库样式类名:

<style scoped lang="scss">
  .filter-form :deep(.el-form-item) {
    margin-bottom: 0;
    min-width: 0;
  }

  .storage-release-ready-check {
    display: inline-flex;
    align-items: flex-start;
    gap: 10px;
    margin-right: auto;
    max-width: 520px;
    padding: 10px 12px;
    border: 1px dashed #a7f3d0;
    border-radius: 8px;
    background: var(--primary-bg, #ecfdf5);
    cursor: pointer;
    user-select: none;
  }

  .storage-release-ready-check__copy {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }

  .storage-release-ready-check__title {
    font-size: 13px;
    font-weight: 600;
    color: var(--text, #1e293b);
    line-height: 1.4;
  }

  .storage-release-ready-check__hint {
    font-size: 12px;
    color: var(--text-muted, #64748b);
    line-height: 1.45;
  }
</style>
  • Step 5: Commit
git add web-admin/src/api/outbound-release-review.ts \
  web-admin/src/views/pilot-service/outbound-release-review/index.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): outbound release pending/reviewed tabs and ready checkbox

Match storage-release review UX for pilot/basic-experiment outbound release lists.
EOF
)"

Task 3: 规格状态 + 手工验收

Files:

  • Modify: docs/superpowers/specs/2026-07-29-outbound-release-tabs-ready-void-design.md

  • [ ] Step 1: 将 > 状态:已确认 改为 > 状态:已实现

  • [ ] Step 2: 手工验收清单(实现者报告中粘贴给用户)

  1. http://localhost:3006/#/market/pilot-service/outbound-release-review 与基础实验同路径入口:双 Tab 默认待审核。
  2. 待审核默认:有未完成出库质检/生产 → 不出现;双完成或双无工单 → 可出现。
  3. 勾选「展示全部待审核」→ 可见未完成行;全作废行仍不可见。
  4. 出库质检或出库生产全部 yzf → 两 Tab 均不出现。
  5. 已审核无复选框;作废隐藏仍生效。
  6. 详情直链仍可打开。
  • Step 3: Commit
git add docs/superpowers/specs/2026-07-29-outbound-release-tabs-ready-void-design.md
git commit -m "$(cat <<'EOF'
docs: mark outbound release tabs/ready/void design as implemented
EOF
)"

Spec coverage (self-review)

Spec Task
双 Tab pending/reviewed Task 2 + Task 1 phase
去掉空「待审核」选项 Task 2
ready 复选框仅待审核、默认不勾选 Task 2
ready SQL 双 NOT EXISTS Task 1
出库 WO 范围 schedule + outbound Task 1
作废全 yzf 常驻隐藏 Task 1
详情 API 不改 无 Task 改详情
中试/基础实验同页 Task 2 单页
单测 Task 1

无 TBD。函数名统一为 outbound_work_orders_all_void。