2026-08-19-storage-release-dual-gate.md 41 KB

入库放行审核双闸门 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:

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: 跑单测确认失败
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 之后插入:

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: 跑单测确认通过
cd backend && source venv/bin/activate && pytest tests/test_storage_release_dual_gate.py -v

Expected: PASS(全部 green)

  • Step 5: Commit
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:

-- 入库放行审核:生产/质检双闸门
-- 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 客户端 / 运维方式,不要把密码写进仓库)。

抽查:

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
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(...) 之前插入:

    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: 确认模型可导入
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
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_* 保留):

        "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 增加:

    reviewSide: Optional[str] = None
    prodReviewConclusion: Optional[str] = None
    qcReviewConclusion: Optional[str] = None

ReviewSubmitBody 与 BatchReviewBody 增加必填:

    reviewSide: str = Field(..., description="production/qc")

StorageReleaseReviewListItem 增加与 _row_to_dict 对应的 qcReview* / prodReview* 可选字段。

  • Step 3: Commit
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 之后):

    review_side: Optional[str] = None,
    prod_review_conclusion: Optional[str] = None,
    qc_review_conclusion: Optional[str] = None,

删除(或替换)现有按汇总 review_conclusion 是否为空做 reviewPhase 的那段,改为:

    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": 换成:

    if aggregate_review_conclusion(rev.prod_review_conclusion, rev.qc_review_conclusion) != "tg":
        raise ValueError("生产与质检均通过后才能入库")

在 store_sample 之后新增:

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

签名改为:

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 后:

    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] 增加:

            "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: 再跑纯函数单测
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
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: 增加菜单常量与写权限校验

文件顶部增加:

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:

        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
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(生产工单)之后插入:

          {
            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 整段为:

          {
            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
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 增加:

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

  reviewSide?: 'overview' | 'production' | 'qc'
  prodReviewConclusion?: string
  qcReviewConclusion?: string

submitStorageReleaseReview / batchReviewStorageRelease 的 data 增加必填 reviewSide: 'production' | 'qc'。

可删除前端对 storeSample(id) 的页面调用(Task 9),但 API 函数可保留。

  • Step 2: Commit
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 后:

  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,筛选改为:

        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 改为:
  const canBatchStore = computed(
    () =>
      selectedRows.value.length > 0 &&
      selectedRows.value.every(
        (r) =>
          r.prodReviewConclusion === 'tg' &&
          r.qcReviewConclusion === 'tg' &&
          r.storageStatus === 'drk'
      )
  )

表格:用两列替换原「审核结论」列;删除「审核人」「审核时间」列。

生产结论列模板(质检列同理,字段换 qcReviewConclusion):

          <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
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

  const props = defineProps<{
    modelValue: boolean
    reviewIds: string[]
    reviewItems?: StorageReleaseReviewQueueItem[]
    reviewSide: 'production' | 'qc'
  }>()

dialogTitle:非批量时 props.reviewSide === 'production' ? '生产入库放行审核' : '质检入库放行审核'。

在证据区 verdict chips 之后、本侧表单之前,增加对侧只读(用 activeDetail.review):

            <div v-if="peerSideLabel" class="review-peer-readonly">
              <span>{{ peerSideLabel }}:</span>
              <el-tag :type="peerTagType" size="small">{{ peerConclusionText }}</el-tag>
            </div>
  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:

    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
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: 替换原「放行审核」一节

增加本地函数:

  function sideConclusionDisplay(code?: string) {
    const v = code || ''
    return v ? storageReleaseConclusionLabel(v) : '待审核'
  }
  function sideConclusionTag(code?: string) {
    const v = code || ''
    return v ? storageReleaseConclusionTagType(v) : 'info'
  }

用三节替换原来的 title: '放行审核':

      {
        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
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
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: 字段一致性

cd backend && source venv/bin/activate && python scripts/check_field_consistency.py

submit 的可写字段是 reviewSide + 本侧结论,不是把 prodReview* 全部放进 formData。若脚本误报,只对已注册实体修真实遗漏,不要为通过脚本把只读两侧字段塞进 CreateBody。

  • Step 2: 更新图谱
./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)
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 明确不改那些函数