diff --git a/docs/superpowers/plans/2026-08-19-storage-release-dual-gate.md b/docs/superpowers/plans/2026-08-19-storage-release-dual-gate.md new file mode 100644 index 00000000..fe6a1c03 --- /dev/null +++ b/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 + + + +``` + +操作列: + +- 「查看」始终显示 +- 「审核」`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 +
+ {{ peerSideLabel }}: + {{ peerConclusionText }} +
+``` + +```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 明确不改那些函数 |