瀏覽代碼

docs: add storage release dual-gate implementation plan.

Break the approved design into TDD tasks covering schema, APIs, three menu entries, and UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 月之前
父節點
當前提交
f880aa7d8a
共有 1 個文件被更改,包括 1164 次插入 和 0 次删除
  1. 1164 0
      docs/superpowers/plans/2026-08-19-storage-release-dual-gate.md

+ 1164 - 0
docs/superpowers/plans/2026-08-19-storage-release-dual-gate.md

@@ -0,0 +1,1164 @@
+# 入库放行审核双闸门 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:** 仍用一张 `biz_storage_release_review`。新增 `prod_review_*` / `qc_review_*` 两套字段;原 `review_conclusion` 改为两侧汇总。提交本侧后重算汇总并调用 `store` / `unstore`。三个菜单共用同一 Vue 页,用路由 `props.reviewRole` 区分能力。纯函数(汇总、Tab 语义、入库动作)先单测再接线。
+
+**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2(backend)、Vue 3 + Element Plus(web-admin)、MySQL 5.7、pytest
+
+**Spec:** `docs/superpowers/specs/2026-08-19-storage-release-dual-gate-design.md`
+
+---
+
+## File Map
+
+| 文件 | 职责 |
+|------|------|
+| `docs/sql/2026-08-19-storage-release-dual-gate.sql` | ALTER 新列 + 历史数据拷到质检侧 + 重算汇总 |
+| `docs/sql/zkxw.sql` | 仅更新 `biz_storage_release_review` 的 `CREATE TABLE` |
+| `backend/app/models/models.py` | ORM 增加两侧字段 |
+| `backend/app/schemas/storage_release_review.py` | `reviewSide`、两侧筛选 |
+| `backend/app/crud/storage_release_review.py` | 纯函数 + 提交/入库/回退 + 列表 Tab |
+| `backend/app/api/storage_release_review.py` | 传 `reviewSide`;写接口校验对应菜单 |
+| `backend/tests/test_storage_release_dual_gate.py` | **新建** 双闸门纯函数单测 |
+| `backend/app/api/menu.py` | 生产、质检菜单增加入口 |
+| `web-admin/src/router/modules/sqxp.ts` | 三入口路由与 `reviewRole` |
+| `web-admin/src/api/storage-release-review.ts` | 类型与请求参数 |
+| `web-admin/src/views/quality-inspection/storage-release-review/index.vue` | 按角色显隐按钮、两列结论 |
+| `StorageReleaseReviewDialog.vue` | 提交本侧,对侧只读 |
+| `StorageReleaseViewDialog.vue` | 两节放行 + 入库 |
+| `web-admin/src/components/business/sample-storage-release-info/index.vue` | 协议卡展示两侧 |
+
+---
+
+### Task 1: 双闸门纯函数与单测
+
+**Files:**
+- Create: `backend/tests/test_storage_release_dual_gate.py`
+- Modify: `backend/app/crud/storage_release_review.py`
+
+- [ ] **Step 1: 写失败单测**
+
+创建 `backend/tests/test_storage_release_dual_gate.py`:
+
+```python
+from app.crud.storage_release_review import (
+    aggregate_review_conclusion,
+    conclusion_blank,
+    matches_dual_review_phase,
+    normalize_review_side,
+    review_side_required_menu,
+    should_copy_legacy_to_qc,
+    storage_transition,
+)
+
+
+def test_conclusion_blank():
+    assert conclusion_blank(None) is True
+    assert conclusion_blank("") is True
+    assert conclusion_blank("  ") is True
+    assert conclusion_blank("tg") is False
+    assert conclusion_blank("btg") is False
+
+
+def test_aggregate_both_tg():
+    assert aggregate_review_conclusion("tg", "tg") == "tg"
+
+
+def test_aggregate_any_btg():
+    assert aggregate_review_conclusion("btg", "tg") == "btg"
+    assert aggregate_review_conclusion("tg", "btg") == "btg"
+    assert aggregate_review_conclusion("btg", None) == "btg"
+    assert aggregate_review_conclusion("", "btg") == "btg"
+
+
+def test_aggregate_pending_when_not_aligned():
+    assert aggregate_review_conclusion("tg", None) is None
+    assert aggregate_review_conclusion(None, "tg") is None
+    assert aggregate_review_conclusion(None, None) is None
+    assert aggregate_review_conclusion("", "") is None
+
+
+def test_storage_transition_store_when_both_tg_and_pending():
+    assert storage_transition("tg", "tg", "drk") == "store"
+
+
+def test_storage_transition_unstore_when_btg_and_stored():
+    assert storage_transition("btg", "tg", "yrk") == "unstore"
+    assert storage_transition("tg", "btg", "yrk") == "unstore"
+
+
+def test_storage_transition_noop_otherwise():
+    assert storage_transition("tg", "tg", "yrk") == "noop"
+    assert storage_transition("tg", None, "drk") == "noop"
+    assert storage_transition("btg", "tg", "drk") == "noop"
+    assert storage_transition(None, None, "yrk") == "noop"
+
+
+def test_matches_dual_review_phase_production():
+    assert matches_dual_review_phase("production", "pending", None, "tg") is True
+    assert matches_dual_review_phase("production", "pending", "tg", "tg") is False
+    assert matches_dual_review_phase("production", "reviewed", "btg", None) is True
+    assert matches_dual_review_phase("production", "reviewed", None, "tg") is False
+
+
+def test_matches_dual_review_phase_qc():
+    assert matches_dual_review_phase("qc", "pending", "tg", None) is True
+    assert matches_dual_review_phase("qc", "reviewed", "tg", "tg") is True
+    assert matches_dual_review_phase("qc", "reviewed", "tg", None) is False
+
+
+def test_matches_dual_review_phase_overview():
+    assert matches_dual_review_phase("overview", "pending", None, "tg") is True
+    assert matches_dual_review_phase("overview", "pending", "tg", "tg") is False
+    assert matches_dual_review_phase("overview", "reviewed", "tg", "btg") is True
+    assert matches_dual_review_phase("overview", "reviewed", "tg", None) is False
+
+
+def test_should_copy_legacy_to_qc():
+    assert should_copy_legacy_to_qc(None, "tg") is True
+    assert should_copy_legacy_to_qc("", "btg") is True
+    assert should_copy_legacy_to_qc("tg", "tg") is False
+    assert should_copy_legacy_to_qc(None, None) is False
+    assert should_copy_legacy_to_qc(None, "") is False
+
+
+def test_normalize_review_side():
+    assert normalize_review_side("production") == "production"
+    assert normalize_review_side(" QC ") == "qc"
+    assert normalize_review_side("overview") == "overview"
+    assert normalize_review_side("all") is None
+    assert normalize_review_side(None) is None
+
+
+def test_review_side_required_menu():
+    assert review_side_required_menu("production") == "production:storageReleaseReview:list"
+    assert review_side_required_menu("qc") == "quality:storageReleaseReview:list"
+    assert review_side_required_menu("overview") is None
+```
+
+- [ ] **Step 2: 跑单测确认失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_storage_release_dual_gate.py -v
+```
+
+Expected: FAIL(无法导入新函数)
+
+- [ ] **Step 3: 实现纯函数**
+
+在 `backend/app/crud/storage_release_review.py` 的 `normalize_review_phase` 之后插入:
+
+```python
+def conclusion_blank(value: Optional[str]) -> bool:
+    return not (value or "").strip()
+
+
+def aggregate_review_conclusion(
+    prod: Optional[str], qc: Optional[str]
+) -> Optional[str]:
+    p = (prod or "").strip()
+    q = (qc or "").strip()
+    if p == "btg" or q == "btg":
+        return "btg"
+    if p == "tg" and q == "tg":
+        return "tg"
+    return None
+
+
+def storage_transition(
+    prod: Optional[str], qc: Optional[str], storage_status: Optional[str]
+) -> str:
+    """Return 'store' | 'unstore' | 'noop'."""
+    agg = aggregate_review_conclusion(prod, qc)
+    st = (storage_status or "drk").strip() or "drk"
+    if agg == "tg" and st == "drk":
+        return "store"
+    if agg == "btg" and st == "yrk":
+        return "unstore"
+    return "noop"
+
+
+def normalize_review_side(raw: Optional[str]) -> Optional[str]:
+    s = (raw or "").strip().lower()
+    if s in ("overview", "production", "qc"):
+        return s
+    return None
+
+
+def review_side_required_menu(review_side: Optional[str]) -> Optional[str]:
+    if review_side == "production":
+        return "production:storageReleaseReview:list"
+    if review_side == "qc":
+        return "quality:storageReleaseReview:list"
+    return None
+
+
+def matches_dual_review_phase(
+    review_side: Optional[str],
+    phase: Optional[str],
+    prod: Optional[str],
+    qc: Optional[str],
+) -> bool:
+    """Pure predicate for Tab semantics; SQL filter must match this."""
+    side = normalize_review_side(review_side) or "overview"
+    ph = normalize_review_phase(phase)
+    if not ph:
+        return True
+    prod_blank = conclusion_blank(prod)
+    qc_blank = conclusion_blank(qc)
+    if side == "production":
+        return prod_blank if ph == "pending" else (not prod_blank)
+    if side == "qc":
+        return qc_blank if ph == "pending" else (not qc_blank)
+    both_filled = (not prod_blank) and (not qc_blank)
+    return (not both_filled) if ph == "pending" else both_filled
+
+
+def should_copy_legacy_to_qc(
+    qc_review_conclusion: Optional[str],
+    legacy_review_conclusion: Optional[str],
+) -> bool:
+    return conclusion_blank(qc_review_conclusion) and not conclusion_blank(
+        legacy_review_conclusion
+    )
+```
+
+- [ ] **Step 4: 跑单测确认通过**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_storage_release_dual_gate.py -v
+```
+
+Expected: PASS(全部 green)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/tests/test_storage_release_dual_gate.py backend/app/crud/storage_release_review.py
+git commit -m "feat: add dual-gate storage release pure functions and tests."
+```
+
+---
+
+### Task 2: SQL 加列与历史迁移脚本
+
+**Files:**
+- Create: `docs/sql/2026-08-19-storage-release-dual-gate.sql`
+- Modify: `docs/sql/zkxw.sql`(仅 `CREATE TABLE \`biz_storage_release_review\`` 字段列表,不要改 INSERT 行)
+
+- [ ] **Step 1: 写变更 SQL**
+
+创建 `docs/sql/2026-08-19-storage-release-dual-gate.sql`:
+
+```sql
+-- 入库放行审核:生产/质检双闸门
+-- MySQL 5.7;历史拷贝条件见下方 UPDATE 的 WHERE(可重复执行)
+
+ALTER TABLE `biz_storage_release_review`
+  ADD COLUMN `qc_review_conclusion` VARCHAR(16) DEFAULT NULL COMMENT '质检侧结论 tg/btg/NULL待审' AFTER `review_time`,
+  ADD COLUMN `qc_review_remark` VARCHAR(512) DEFAULT NULL COMMENT '质检侧审核备注' AFTER `qc_review_conclusion`,
+  ADD COLUMN `qc_review_attachments` VARCHAR(4000) DEFAULT NULL COMMENT '质检侧审核附件(逗号分隔URL)' AFTER `qc_review_remark`,
+  ADD COLUMN `qc_review_by` VARCHAR(64) DEFAULT NULL COMMENT '质检侧审核人ID' AFTER `qc_review_attachments`,
+  ADD COLUMN `qc_review_by_name` VARCHAR(64) DEFAULT NULL COMMENT '质检侧审核人姓名' AFTER `qc_review_by`,
+  ADD COLUMN `qc_review_time` DATETIME DEFAULT NULL COMMENT '质检侧审核时间' AFTER `qc_review_by_name`,
+  ADD COLUMN `prod_review_conclusion` VARCHAR(16) DEFAULT NULL COMMENT '生产侧结论 tg/btg/NULL待审' AFTER `qc_review_time`,
+  ADD COLUMN `prod_review_remark` VARCHAR(512) DEFAULT NULL COMMENT '生产侧审核备注' AFTER `prod_review_conclusion`,
+  ADD COLUMN `prod_review_attachments` VARCHAR(4000) DEFAULT NULL COMMENT '生产侧审核附件(逗号分隔URL)' AFTER `prod_review_remark`,
+  ADD COLUMN `prod_review_by` VARCHAR(64) DEFAULT NULL COMMENT '生产侧审核人ID' AFTER `prod_review_attachments`,
+  ADD COLUMN `prod_review_by_name` VARCHAR(64) DEFAULT NULL COMMENT '生产侧审核人姓名' AFTER `prod_review_by`,
+  ADD COLUMN `prod_review_time` DATETIME DEFAULT NULL COMMENT '生产侧审核时间' AFTER `prod_review_by_name`;
+
+ALTER TABLE `biz_storage_release_review`
+  ADD KEY `idx_srr_dual_conclusion` (`tenant_no`, `qc_review_conclusion`, `prod_review_conclusion`, `del_flag`);
+
+-- 旧单人结论只落到质检侧;已入库状态不改
+UPDATE `biz_storage_release_review`
+SET
+  `qc_review_conclusion` = `review_conclusion`,
+  `qc_review_remark` = `review_remark`,
+  `qc_review_attachments` = `review_attachments`,
+  `qc_review_by` = `review_by`,
+  `qc_review_by_name` = `review_by_name`,
+  `qc_review_time` = `review_time`
+WHERE `del_flag` = 0
+  AND (`qc_review_conclusion` IS NULL OR `qc_review_conclusion` = '')
+  AND `review_conclusion` IS NOT NULL
+  AND `review_conclusion` <> '';
+
+-- 按双闸门重算汇总(旧「仅质检通过」汇总变为 NULL)
+UPDATE `biz_storage_release_review`
+SET `review_conclusion` = CASE
+  WHEN `prod_review_conclusion` = 'tg' AND `qc_review_conclusion` = 'tg' THEN 'tg'
+  WHEN `prod_review_conclusion` = 'btg' OR `qc_review_conclusion` = 'btg' THEN 'btg'
+  ELSE NULL
+END
+WHERE `del_flag` = 0;
+```
+
+若目标库已加过列,跳过 ALTER,只跑 UPDATE。加列失败时用 `SHOW COLUMNS FROM biz_storage_release_review LIKE 'qc_review_conclusion'` 判断。
+
+- [ ] **Step 2: 同步 `docs/sql/zkxw.sql` 的 CREATE TABLE**
+
+定位 `CREATE TABLE \`biz_storage_release_review\``,在 `review_time` 与 `storage_status` 之间插入与 Step 1 相同的 12 个新列,并在 KEY 列表增加 `idx_srr_dual_conclusion`。不要改该表的 INSERT 语句(未列出的新列走 DEFAULT NULL)。
+
+- [ ] **Step 3: 在开发库执行脚本**
+
+对当前开发库执行 `docs/sql/2026-08-19-storage-release-dual-gate.sql`(按项目惯例用已有 MySQL 客户端 / 运维方式,不要把密码写进仓库)。
+
+抽查:
+
+```sql
+SELECT review_conclusion, qc_review_conclusion, prod_review_conclusion, storage_status
+FROM biz_storage_release_review
+WHERE del_flag = 0
+LIMIT 20;
+```
+
+Expected:旧 `tg` 且已入库 → `qc=tg`、`prod` 空、`storage_status=yrk`、汇总 `review_conclusion` 为空;旧未审 → 两侧都空。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/sql/2026-08-19-storage-release-dual-gate.sql docs/sql/zkxw.sql
+git commit -m "docs: add storage release dual-gate schema and data migration SQL."
+```
+
+---
+
+### Task 3: ORM 增加两侧字段
+
+**Files:**
+- Modify: `backend/app/models/models.py`(`BizStorageReleaseReview`,约 1057 行 `review_time` 之后)
+
+- [ ] **Step 1: 在模型中增加字段**
+
+在 `review_time = Column(...)` 之后、`storage_status = Column(...)` 之前插入:
+
+```python
+    qc_review_conclusion = Column(String(16), nullable=True, comment="质检侧结论 tg通过 btg不通过")
+    qc_review_remark = Column(String(512), nullable=True, comment="质检侧审核备注")
+    qc_review_attachments = Column(String(4000), nullable=True, comment="质检侧审核附件(逗号分隔URL)")
+    qc_review_by = Column(String(64), nullable=True, comment="质检侧审核人ID")
+    qc_review_by_name = Column(String(64), nullable=True, comment="质检侧审核人姓名")
+    qc_review_time = Column(DateTime, nullable=True, comment="质检侧审核时间")
+    prod_review_conclusion = Column(String(16), nullable=True, comment="生产侧结论 tg通过 btg不通过")
+    prod_review_remark = Column(String(512), nullable=True, comment="生产侧审核备注")
+    prod_review_attachments = Column(String(4000), nullable=True, comment="生产侧审核附件(逗号分隔URL)")
+    prod_review_by = Column(String(64), nullable=True, comment="生产侧审核人ID")
+    prod_review_by_name = Column(String(64), nullable=True, comment="生产侧审核人姓名")
+    prod_review_time = Column(DateTime, nullable=True, comment="生产侧审核时间")
+```
+
+- [ ] **Step 2: 确认模型可导入**
+
+```bash
+cd backend && source venv/bin/activate && python -c "from app.models.models import BizStorageReleaseReview; print(BizStorageReleaseReview.qc_review_conclusion.name, BizStorageReleaseReview.prod_review_conclusion.name)"
+```
+
+Expected: `qc_review_conclusion prod_review_conclusion`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/app/models/models.py
+git commit -m "feat: add production and QC review columns on storage release model."
+```
+
+---
+
+### Task 4: `_row_to_dict` 与 Pydantic schema
+
+**Files:**
+- Modify: `backend/app/crud/storage_release_review.py`(`_row_to_dict`)
+- Modify: `backend/app/schemas/storage_release_review.py`
+
+- [ ] **Step 1: 扩展 `_row_to_dict`**
+
+在现有 `"reviewTime": _fmt_dt(r.review_time),` 之后插入两侧字段(汇总 `review_*` 保留):
+
+```python
+        "qcReviewConclusion": r.qc_review_conclusion or "",
+        "qcReviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(r.qc_review_conclusion or "", r.qc_review_conclusion or ""),
+        "qcReviewRemark": r.qc_review_remark or "",
+        "qcReviewAttachments": r.qc_review_attachments or "",
+        "qcReviewBy": r.qc_review_by or "",
+        "qcReviewByName": r.qc_review_by_name or "",
+        "qcReviewTime": _fmt_dt(r.qc_review_time),
+        "prodReviewConclusion": r.prod_review_conclusion or "",
+        "prodReviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(r.prod_review_conclusion or "", r.prod_review_conclusion or ""),
+        "prodReviewRemark": r.prod_review_remark or "",
+        "prodReviewAttachments": r.prod_review_attachments or "",
+        "prodReviewBy": r.prod_review_by or "",
+        "prodReviewByName": r.prod_review_by_name or "",
+        "prodReviewTime": _fmt_dt(r.prod_review_time),
+```
+
+- [ ] **Step 2: 更新 schema**
+
+`StorageReleaseReviewListQuery` 增加:
+
+```python
+    reviewSide: Optional[str] = None
+    prodReviewConclusion: Optional[str] = None
+    qcReviewConclusion: Optional[str] = None
+```
+
+`ReviewSubmitBody` 与 `BatchReviewBody` 增加必填:
+
+```python
+    reviewSide: str = Field(..., description="production/qc")
+```
+
+`StorageReleaseReviewListItem` 增加与 `_row_to_dict` 对应的 `qcReview*` / `prodReview*` 可选字段。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/app/crud/storage_release_review.py backend/app/schemas/storage_release_review.py
+git commit -m "feat: expose dual-side storage release fields in row dict and schemas."
+```
+
+---
+
+### Task 5: 列表 Tab 过滤 + 提交后入库/回退
+
+**Files:**
+- Modify: `backend/app/crud/storage_release_review.py`(`list_reviews`、`submit_review`、`batch_submit_review`、`store_sample`、新增 `unstore_sample`、`get_storage_release_map_for_samples`)
+
+- [ ] **Step 1: 改 `list_reviews` 的 phase / 侧筛选**
+
+签名增加(放在 `review_phase` 之后):
+
+```python
+    review_side: Optional[str] = None,
+    prod_review_conclusion: Optional[str] = None,
+    qc_review_conclusion: Optional[str] = None,
+```
+
+**删除**(或替换)现有按汇总 `review_conclusion` 是否为空做 `reviewPhase` 的那段,改为:
+
+```python
+    phase = normalize_review_phase(review_phase)
+    side = normalize_review_side(review_side) or "overview"
+    if phase:
+        prod_blank = or_(
+            BizStorageReleaseReview.prod_review_conclusion.is_(None),
+            BizStorageReleaseReview.prod_review_conclusion == "",
+        )
+        qc_blank = or_(
+            BizStorageReleaseReview.qc_review_conclusion.is_(None),
+            BizStorageReleaseReview.qc_review_conclusion == "",
+        )
+        if side == "production":
+            q0 = q0.filter(prod_blank if phase == "pending" else ~prod_blank)
+        elif side == "qc":
+            q0 = q0.filter(qc_blank if phase == "pending" else ~qc_blank)
+        else:
+            both_filled = and_(~prod_blank, ~qc_blank)
+            q0 = q0.filter(~both_filled if phase == "pending" else both_filled)
+    prc = (prod_review_conclusion or "").strip()
+    if prc:
+        q0 = q0.filter(BizStorageReleaseReview.prod_review_conclusion == prc)
+    qrc = (qc_review_conclusion or "").strip()
+    if qrc:
+        q0 = q0.filter(BizStorageReleaseReview.qc_review_conclusion == qrc)
+```
+
+保留原 `review_conclusion` 汇总过滤、`ready_for_review_only`、作废隐藏,不要改那些函数。
+
+- [ ] **Step 2: 改 `store_sample` 门槛,并实现 `unstore_sample`**
+
+`store_sample` 中把 `if rev.review_conclusion != "tg":` 换成:
+
+```python
+    if aggregate_review_conclusion(rev.prod_review_conclusion, rev.qc_review_conclusion) != "tg":
+        raise ValueError("生产与质检均通过后才能入库")
+```
+
+在 `store_sample` 之后新增:
+
+```python
+def unstore_sample(
+    db: Session,
+    review_id: str,
+    *,
+    tenant_no: str,
+    user_id: str,
+) -> BizStorageReleaseReview:
+    rev = (
+        db.query(BizStorageReleaseReview)
+        .filter(
+            BizStorageReleaseReview.id == review_id,
+            BizStorageReleaseReview.tenant_no == tenant_no,
+            BizStorageReleaseReview.del_flag == 0,
+        )
+        .first()
+    )
+    if not rev:
+        raise ValueError("审核记录不存在")
+    if (rev.storage_status or "") != "yrk":
+        return rev
+    rev.storage_status = "drk"
+    rev.storage_by = None
+    rev.storage_by_name = None
+    rev.storage_time = None
+    rev.update_by = user_id
+    rev.update_time = datetime.now()
+    db.add(rev)
+    if (rev.reception_source or "cell_storage") == "cell_storage":
+        sample_code = (rev.sample_code or "").strip()
+        if sample_code:
+            ps = (
+                db.query(BizProtocolSample)
+                .filter(
+                    BizProtocolSample.sample_code == sample_code,
+                    BizProtocolSample.tenant_no == tenant_no,
+                    BizProtocolSample.del_flag == 0,
+                )
+                .first()
+            )
+            if ps and (ps.sample_status or "") == "yrk":
+                ps.sample_status = "jcz"
+                ps.storage_in_date = None
+                ps.update_time = datetime.now()
+                db.add(ps)
+    db.flush()
+    return rev
+```
+
+不要改科研捐赠样本状态(与现网 `store_sample` 对称)。不要碰细胞产品/库位。
+
+- [ ] **Step 3: 重写 `submit_review`**
+
+签名改为:
+
+```python
+def submit_review(
+    db: Session,
+    review_id: str,
+    *,
+    review_side: str,
+    review_conclusion: str,
+    review_remark: Optional[str] = None,
+    review_attachments: Optional[str] = None,
+    tenant_no: str,
+    user_id: str,
+) -> BizStorageReleaseReview:
+```
+
+查到 `rev` 后:
+
+```python
+    side = normalize_review_side(review_side)
+    if side not in ("production", "qc"):
+        raise ValueError("审核侧无效")
+    conclusion = (review_conclusion or "").strip()
+    if conclusion not in ("tg", "btg"):
+        raise ValueError("审核结论无效")
+    user_name = _get_user_name(db, user_id)
+    now = datetime.now()
+    remark = (review_remark or "").strip() or None
+    attachments = (review_attachments or "").strip() or None
+    if side == "production":
+        rev.prod_review_conclusion = conclusion
+        rev.prod_review_remark = remark
+        rev.prod_review_attachments = attachments
+        rev.prod_review_by = user_id
+        rev.prod_review_by_name = user_name
+        rev.prod_review_time = now
+    else:
+        rev.qc_review_conclusion = conclusion
+        rev.qc_review_remark = remark
+        rev.qc_review_attachments = attachments
+        rev.qc_review_by = user_id
+        rev.qc_review_by_name = user_name
+        rev.qc_review_time = now
+    agg = aggregate_review_conclusion(rev.prod_review_conclusion, rev.qc_review_conclusion)
+    rev.review_conclusion = agg
+    rev.review_remark = remark
+    rev.review_attachments = attachments
+    rev.review_by = user_id
+    rev.review_by_name = user_name
+    rev.review_time = now
+    rev.update_by = user_id
+    rev.update_time = now
+    db.add(rev)
+    db.flush()
+    action = storage_transition(
+        rev.prod_review_conclusion, rev.qc_review_conclusion, rev.storage_status
+    )
+    if action == "store":
+        store_sample(db, review_id, tenant_no=tenant_no, user_id=user_id)
+        db.refresh(rev)
+    elif action == "unstore":
+        unstore_sample(db, review_id, tenant_no=tenant_no, user_id=user_id)
+        db.refresh(rev)
+    return rev
+```
+
+`batch_submit_review` 增加必填 `review_side`,并传给 `submit_review`。
+
+`store_sample` 在 `submit_review` 之后调用时,记录已 flush;`store_sample` 会再查一次。注意 `submit_review` 里先 flush 再 `store_sample`,避免读到旧 `storage_status`。
+
+- [ ] **Step 4: 更新 `get_storage_release_map_for_samples`**
+
+不要再用「汇总结论为空则跳过」。有任一侧结论或已入库都应返回。`out[sid]` 增加:
+
+```python
+            "qcReviewConclusion": rev.qc_review_conclusion or "",
+            "qcReviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(rev.qc_review_conclusion or "", rev.qc_review_conclusion or ""),
+            "qcReviewByName": rev.qc_review_by_name or "",
+            "qcReviewTime": _fmt_dt(rev.qc_review_time),
+            "qcReviewRemark": rev.qc_review_remark or "",
+            "qcReviewAttachments": rev.qc_review_attachments or "",
+            "prodReviewConclusion": rev.prod_review_conclusion or "",
+            "prodReviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(rev.prod_review_conclusion or "", rev.prod_review_conclusion or ""),
+            "prodReviewByName": rev.prod_review_by_name or "",
+            "prodReviewTime": _fmt_dt(rev.prod_review_time),
+            "prodReviewRemark": rev.prod_review_remark or "",
+            "prodReviewAttachments": rev.prod_review_attachments or "",
+            "storageStatus": rev.storage_status or "drk",
+            "storageStatusLabel": _STORAGE_STATUS_LABELS.get(rev.storage_status or "drk", rev.storage_status or "drk"),
+```
+
+保留原汇总 `reviewConclusion*` 字段。跳过条件改为:记录不存在则 continue;存在则始终输出。
+
+- [ ] **Step 5: 再跑纯函数单测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_storage_release_dual_gate.py tests/test_storage_release_review_phase.py tests/test_storage_release_review_ready_filter.py tests/test_storage_release_review_hide_voided.py -v
+```
+
+Expected: PASS。`normalize_review_phase` 测试仍成立。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/crud/storage_release_review.py
+git commit -m "feat: apply dual-gate storage release submit, auto store, and unstore."
+```
+
+---
+
+### Task 6: API 接入 `reviewSide` 与写权限
+
+**Files:**
+- Modify: `backend/app/api/storage_release_review.py`
+
+- [ ] **Step 1: 增加菜单常量与写权限校验**
+
+文件顶部增加:
+
+```python
+from app.core.menu_guard import (
+    require_admin_or_super_admin,
+    require_any_menu_code,
+    user_has_menu_perm,
+)
+
+STORAGE_RELEASE_READ_MENU_CODES = (
+    "biz:storageReleaseReview:list",
+    "production:storageReleaseReview:list",
+    "quality:storageReleaseReview:list",
+)
+STORAGE_RELEASE_WRITE_MENU_CODES = (
+    "production:storageReleaseReview:list",
+    "quality:storageReleaseReview:list",
+)
+
+
+def _ensure_review_side_write(db: Session, user: dict, review_side: str) -> str:
+    side = srr_svc.normalize_review_side(review_side)
+    if side not in ("production", "qc"):
+        raise ValueError("审核侧无效")
+    menu = srr_svc.review_side_required_menu(side)
+    if not menu or not user_has_menu_perm(db, user, menu):
+        raise PermissionError("无权提交该侧审核")
+    return side
+```
+
+列表 Depends 改为 `require_any_menu_code(*STORAGE_RELEASE_READ_MENU_CODES)`(行为仍等同运营端登录,但标明三入口)。
+
+列表函数增加 Query:`reviewSide`、`prodReviewConclusion`、`qcReviewConclusion`,传入 `list_reviews` 的 `review_side` / `prod_review_conclusion` / `qc_review_conclusion`。
+
+- [ ] **Step 2: 提交/批量/入库走写权限**
+
+`submit_single_review`:
+
+```python
+        side = _ensure_review_side_write(db, user, body.reviewSide)
+        rev = srr_svc.submit_review(
+            db,
+            review_id,
+            review_side=side,
+            review_conclusion=body.reviewConclusion,
+            review_remark=body.reviewRemark,
+            review_attachments=body.reviewAttachments,
+            tenant_no=tenant_no,
+            user_id=user_id,
+        )
+```
+
+`batch_review` 同样先 `_ensure_review_side_write`,再 `batch_submit_review(..., review_side=side, ...)`。
+
+`store_single_sample` / `batch_store_samples`:用户必须拥有 `STORAGE_RELEASE_WRITE_MENU_CODES` 中任一(`user_has_menu_perm` 循环)。细胞存储菜单不够。无权限 `raise PermissionError`。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/app/api/storage_release_review.py
+git commit -m "feat: require reviewSide and write menus on storage release APIs."
+```
+
+---
+
+### Task 7: 菜单与路由三入口
+
+**Files:**
+- Modify: `web-admin/src/router/modules/sqxp.ts`
+- Modify: `backend/app/api/menu.py`
+
+- [ ] **Step 1: 生产管理增加菜单**
+
+在 `sqxp.ts` 的 `ProductionWorkOrder`(生产工单)之后插入:
+
+```typescript
+          {
+            id: '95400000000000000000000000000001',
+            name: 'ProductionStorageReleaseReview',
+            path: 'storage-release-review',
+            component: '/quality-inspection/storage-release-review/index',
+            meta: {
+              title: '入库放行审核',
+              keepAlive: true,
+              menuCode: 'production:storageReleaseReview:list'
+            },
+            props: { reviewRole: 'production' }
+          },
+```
+
+`menu.py` 在 `ProductionWorkOrder` 对象之后插入对应 JSON(`id` / `name` / `path` / `component` / `meta` 与上面一致,`props` 若静态树不支持可只放 meta;前端以 `sqxp.ts` 的 `props` 为准)。
+
+- [ ] **Step 2: 质检管理改为真实页**
+
+替换 `sqxp.ts` 里 `QualityInspectionStorageReleaseReviewRedirect` 整段为:
+
+```typescript
+          {
+            id: '70000000000000000000000000000014',
+            name: 'QualityInspectionStorageReleaseReview',
+            path: 'storage-release-review',
+            component: '/quality-inspection/storage-release-review/index',
+            meta: {
+              title: '入库放行审核',
+              keepAlive: true,
+              menuCode: 'quality:storageReleaseReview:list'
+            },
+            props: { reviewRole: 'qc' }
+          },
+```
+
+去掉 `isHide` 与 `redirect`。`menu.py` 的质检管理 `children` 在「质检工单」之后增加同样一项。
+
+细胞存储现有路由保持不变,**不要**加 `reviewRole`(页面默认 `overview`)。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/router/modules/sqxp.ts backend/app/api/menu.py
+git commit -m "feat: add production and QC menus for storage release review."
+```
+
+---
+
+### Task 8: 前端 API 类型
+
+**Files:**
+- Modify: `web-admin/src/api/storage-release-review.ts`
+
+- [ ] **Step 1: 扩展类型与请求**
+
+`StorageReleaseReview` 增加:
+
+```typescript
+  qcReviewConclusion?: string
+  qcReviewConclusionLabel?: string
+  qcReviewRemark?: string
+  qcReviewAttachments?: string
+  qcReviewBy?: string
+  qcReviewByName?: string
+  qcReviewTime?: string
+  prodReviewConclusion?: string
+  prodReviewConclusionLabel?: string
+  prodReviewRemark?: string
+  prodReviewAttachments?: string
+  prodReviewBy?: string
+  prodReviewByName?: string
+  prodReviewTime?: string
+```
+
+`fetchStorageReleaseReviewList` params 增加:
+
+```typescript
+  reviewSide?: 'overview' | 'production' | 'qc'
+  prodReviewConclusion?: string
+  qcReviewConclusion?: string
+```
+
+`submitStorageReleaseReview` / `batchReviewStorageRelease` 的 data 增加必填 `reviewSide: 'production' | 'qc'`。
+
+可删除前端对 `storeSample(id)` 的页面调用(Task 9),但 API 函数可保留。
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web-admin/src/api/storage-release-review.ts
+git commit -m "feat: add dual-side fields to storage release frontend API."
+```
+
+---
+
+### Task 9: 列表页按入口显隐并拆结论列
+
+**Files:**
+- Modify: `web-admin/src/views/quality-inspection/storage-release-review/index.vue`
+
+- [ ] **Step 1: 增加 `reviewRole` 并驱动请求**
+
+在 `defineOptions` 后:
+
+```typescript
+  const props = defineProps<{
+    reviewRole?: 'overview' | 'production' | 'qc'
+  }>()
+
+  const reviewRole = computed<'overview' | 'production' | 'qc'>(() => {
+    const r = props.reviewRole
+    if (r === 'production' || r === 'qc' || r === 'overview') return r
+    return 'overview'
+  })
+
+  const canWrite = computed(() => reviewRole.value !== 'overview')
+```
+
+`loadData` 的请求增加 `reviewSide: reviewRole.value`,筛选改为:
+
+```typescript
+        prodReviewConclusion: searchForm.prodReviewConclusion || undefined,
+        qcReviewConclusion: searchForm.qcReviewConclusion || undefined,
+```
+
+去掉 `reviewConclusion` 入参。
+
+`searchForm` 把 `reviewConclusion` 换成 `prodReviewConclusion: ''`、`qcReviewConclusion: ''`。`handleReset` 同步清空这两个字段。
+
+`watch(reviewRole, () => { pagination.page = 1; selectedRows.value = []; loadData() })`
+
+`onActivated(() => loadData())`(三路由共用 SFC,避免 keep-alive 脏数据)。
+
+- [ ] **Step 2: 筛选、工具栏、列、操作**
+
+筛选区原「审核结论」换成两个 `el-form-item`:
+
+- label `生产结论`,`v-model="searchForm.prodReviewConclusion"`,options 仍用 `reviewConclusionOptions`
+- label `质检结论`,`v-model="searchForm.qcReviewConclusion"`
+
+工具栏:
+
+- `v-if="canWrite"` 包住「批量审核」
+- `v-if="canWrite"` 包住「批量入库」
+- `canBatchReview` 保持所选非空
+- `canBatchStore` 改为:
+
+```typescript
+  const canBatchStore = computed(
+    () =>
+      selectedRows.value.length > 0 &&
+      selectedRows.value.every(
+        (r) =>
+          r.prodReviewConclusion === 'tg' &&
+          r.qcReviewConclusion === 'tg' &&
+          r.storageStatus === 'drk'
+      )
+  )
+```
+
+表格:用两列替换原「审核结论」列;**删除**「审核人」「审核时间」列。
+
+生产结论列模板(质检列同理,字段换 `qcReviewConclusion`):
+
+```vue
+          <el-table-column
+            prop="prodReviewConclusion"
+            label="生产结论"
+            min-width="110"
+            align="center"
+            header-align="center"
+          >
+            <template #default="{ row }">
+              <el-tag
+                v-if="row.prodReviewConclusion"
+                :type="storageReleaseConclusionTagType(row.prodReviewConclusion)"
+                size="small"
+              >
+                {{ storageReleaseConclusionLabel(row.prodReviewConclusion) }}
+              </el-tag>
+              <el-tag v-else type="info" size="small">待审核</el-tag>
+            </template>
+          </el-table-column>
+```
+
+操作列:
+
+- 「查看」始终显示
+- 「审核」`v-if="canWrite"`
+- **删除**「样本入库」按钮及 `handleStore`、对 `storeSample` 的 import 使用(批量仍用 `batchStoreSample`)
+
+`StorageReleaseReviewDialog` 增加 `:review-side="reviewRole === 'overview' ? 'qc' : reviewRole"`(overview 不会打开审核弹窗)。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/views/quality-inspection/storage-release-review/index.vue
+git commit -m "feat: split storage release list by review role and dual conclusions."
+```
+
+---
+
+### Task 10: 审核弹窗只提交本侧
+
+**Files:**
+- Modify: `web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseReviewDialog.vue`
+
+- [ ] **Step 1: 增加 `reviewSide` prop**
+
+```typescript
+  const props = defineProps<{
+    modelValue: boolean
+    reviewIds: string[]
+    reviewItems?: StorageReleaseReviewQueueItem[]
+    reviewSide: 'production' | 'qc'
+  }>()
+```
+
+`dialogTitle`:非批量时 `props.reviewSide === 'production' ? '生产入库放行审核' : '质检入库放行审核'`。
+
+在证据区 verdict chips 之后、本侧表单之前,增加对侧只读(用 `activeDetail.review`):
+
+```vue
+            <div v-if="peerSideLabel" class="review-peer-readonly">
+              <span>{{ peerSideLabel }}:</span>
+              <el-tag :type="peerTagType" size="small">{{ peerConclusionText }}</el-tag>
+            </div>
+```
+
+```typescript
+  const peerSideLabel = computed(() =>
+    props.reviewSide === 'production' ? '质检结论' : '生产结论'
+  )
+  const peerConclusion = computed(() => {
+    const rev = activeDetail.value?.review
+    return props.reviewSide === 'production'
+      ? rev?.qcReviewConclusion || ''
+      : rev?.prodReviewConclusion || ''
+  })
+  const peerConclusionText = computed(() =>
+    peerConclusion.value
+      ? storageReleaseConclusionLabel(peerConclusion.value)
+      : '待审核'
+  )
+  const peerTagType = computed(() =>
+    peerConclusion.value
+      ? storageReleaseConclusionTagType(peerConclusion.value)
+      : 'info'
+  )
+```
+
+若尚未 import `storageReleaseConclusionLabel` / `storageReleaseConclusionTagType`,从 `@/utils/status-tag-registry` 引入。
+
+- [ ] **Step 2: payload 带 `reviewSide`**
+
+`buildPayload`:
+
+```typescript
+    return {
+      reviewSide: props.reviewSide,
+      reviewConclusion: form.reviewConclusion,
+      reviewRemark: form.reviewRemark.trim() || undefined,
+      reviewAttachments: attachmentUrls || undefined
+    }
+```
+
+打开弹窗加载详情后,本侧表单可预填已有结论:`reviewSide === 'production' ? review.prodReviewConclusion : review.qcReviewConclusion`,空则默认 `tg`。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseReviewDialog.vue
+git commit -m "feat: submit storage release reviews for one side only."
+```
+
+---
+
+### Task 11: 查看弹窗拆质检/生产/入库
+
+**Files:**
+- Modify: `web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseViewDialog.vue`
+
+- [ ] **Step 1: 替换原「放行审核」一节**
+
+增加本地函数:
+
+```typescript
+  function sideConclusionDisplay(code?: string) {
+    const v = code || ''
+    return v ? storageReleaseConclusionLabel(v) : '待审核'
+  }
+  function sideConclusionTag(code?: string) {
+    const v = code || ''
+    return v ? storageReleaseConclusionTagType(v) : 'info'
+  }
+```
+
+用三节替换原来的 `title: '放行审核'`:
+
+```typescript
+      {
+        title: '质检放行',
+        slot: 'qc-review-attachments',
+        fields: [
+          {
+            label: '审核结论',
+            value: sideConclusionDisplay(rev.qcReviewConclusion),
+            tagType: sideConclusionTag(rev.qcReviewConclusion)
+          },
+          { label: '审核人', value: rev.qcReviewByName || '—' },
+          { label: '审核时间', value: rev.qcReviewTime || '—' },
+          { label: '审核备注', value: rev.qcReviewRemark || '—', span: true }
+        ]
+      },
+      {
+        title: '生产放行',
+        slot: 'prod-review-attachments',
+        fields: [
+          {
+            label: '审核结论',
+            value: sideConclusionDisplay(rev.prodReviewConclusion),
+            tagType: sideConclusionTag(rev.prodReviewConclusion)
+          },
+          { label: '审核人', value: rev.prodReviewByName || '—' },
+          { label: '审核时间', value: rev.prodReviewTime || '—' },
+          { label: '审核备注', value: rev.prodReviewRemark || '—', span: true }
+        ]
+      },
+      {
+        title: '入库',
+        fields: [
+          {
+            label: '入库状态',
+            value: storageStatusLabel(rev.storageStatus || 'drk'),
+            tagType: storageStatusTagType(rev.storageStatus || 'drk')
+          },
+          { label: '入库人', value: rev.storageByName || '—' },
+          { label: '入库时间', value: rev.storageTime || '—' }
+        ]
+      }
+```
+
+从 `@/utils/status-tag-registry` 增加 `storageStatusLabel`、`storageStatusTagType`(若尚未导入)。
+
+附件 slot:按 `qcReviewAttachments` / `prodReviewAttachments` 各做只读列表,逻辑克隆现有 `reviewAttachments` 拆 URL 代码。删除对汇总 `reviewAttachments` 的唯一 slot(或保留但不作为主展示)。
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web-admin/src/views/quality-inspection/storage-release-review/StorageReleaseViewDialog.vue
+git commit -m "feat: show production and QC verdicts in storage release view dialog."
+```
+
+---
+
+### Task 12: 协议查看卡展示两侧结论
+
+**Files:**
+- Modify: `web-admin/src/components/business/sample-storage-release-info/index.vue`
+
+- [ ] **Step 1: 扩展类型与模板**
+
+`StorageReleaseInfo` 增加 `qcReviewConclusion`、`qcReviewConclusionLabel`、`prodReviewConclusion`、`prodReviewConclusionLabel`、`storageStatus`、`storageStatusLabel`(以及两侧人/时间可选)。
+
+网格改为四行 Tag:生产结论、质检结论、入库状态;人/时间可各侧一行。空结论显示「待审核」。组件内用与现网相同的 `el-tag` 语义(`tg` success、`btg` danger、空 info)。
+
+无记录仍显示「暂无入库放行审核信息」。有记录但两侧都待审也应展示(配合 CRUD map 不再因汇总结论为空而省略)。
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add web-admin/src/components/business/sample-storage-release-info/index.vue
+git commit -m "feat: show both release verdicts on protocol sample cards."
+```
+
+---
+
+### Task 13: 字段一致性、图谱、手工验收
+
+**Files:**
+- 按需:`backend/scripts/check_field_consistency.py` 若已注册该实体则修 diff
+- 图谱:`./scripts/update-graph.sh all`
+
+- [ ] **Step 1: 字段一致性**
+
+```bash
+cd backend && source venv/bin/activate && python scripts/check_field_consistency.py
+```
+
+`submit` 的可写字段是 `reviewSide` + 本侧结论,不是把 `prodReview*` 全部放进 formData。若脚本误报,只对已注册实体修真实遗漏,不要为通过脚本把只读两侧字段塞进 CreateBody。
+
+- [ ] **Step 2: 更新图谱**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+- [ ] **Step 3: 手工清单(实现者勾选)**
+
+1. 质检入口审通过、生产入口再通过 → 自动已入库;采集样本 `yrk`。
+2. 对调顺序同样自动入库。
+3. 已入库后任一侧改不通过 → 放行 `drk`,样本 `jcz`,库位产品仍在。
+4. 两侧再都通过 → 再次 `yrk`。
+5. 细胞存储入口无审核/批量入库;生产入口改不了质检字段。
+6. 批量入库只对双 `tg` 且 `drk` 生效。
+7. 历史已入库行:总览待审核 Tab 可见「已入库 + 生产待审」。
+8. 待审核就绪过滤、作废隐藏仍有效。
+
+- [ ] **Step 4: Commit 图谱(若有 diff)**
+
+```bash
+git add backend/graphify-out web-admin/graphify-out
+git commit -m "chore: update graphify after storage release dual-gate."
+```
+
+---
+
+## Spec coverage(自检)
+
+| 规格章节 | 任务 |
+|----------|------|
+| §3 三入口与 menuCode | Task 7 |
+| §3 写接口校验 reviewSide | Task 6 |
+| §4 汇总与状态机 | Task 1、5 |
+| §5 表结构 | Task 2、3、4 |
+| §6 自动入库 / 回退 / 批量入库 | Task 5、6、9 |
+| §7 API | Task 4–6 |
+| §8.1–8.2 列表 | Task 9 |
+| §8.3 审核弹窗 | Task 10 |
+| §8.4 查看弹窗 | Task 11 |
+| §8.5 协议卡 | Task 5 map + Task 12 |
+| §9 历史迁移 | Task 2 |
+| §10 短信仍用汇总 | 不改短信模块;汇总由 Task 5 维护 |
+| §11 测试要点 | Task 1 单测 + Task 13 手工 |
+| 去掉单条入库按钮 | Task 9 |
+| 就绪过滤 / 作废隐藏不变 | Task 5 明确不改那些函数 |