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
| 文件 | 职责 |
|---|---|
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 |
协议卡展示两侧 |
Files:
backend/tests/test_storage_release_dual_gate.pyModify: 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
cd backend && source venv/bin/activate && pytest tests/test_storage_release_dual_gate.py -v
Expected: FAIL(无法导入新函数)
在 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
)
cd backend && source venv/bin/activate && pytest tests/test_storage_release_dual_gate.py -v
Expected: PASS(全部 green)
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."
Files:
docs/sql/2026-08-19-storage-release-dual-gate.sqlModify: 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' 判断。
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)。
对当前开发库执行 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 为空;旧未审 → 两侧都空。
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."
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="生产侧审核时间")
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
git add backend/app/models/models.py
git commit -m "feat: add production and QC review columns on storage release model."
_row_to_dict 与 Pydantic schemaFiles:
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),
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* 可选字段。
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."
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、作废隐藏,不要改那些函数。
store_sample 门槛,并实现 unstore_samplestore_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 对称)。不要碰细胞产品/库位。
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。
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;存在则始终输出。
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 测试仍成立。
git add backend/app/crud/storage_release_review.py
git commit -m "feat: apply dual-gate storage release submit, auto store, and unstore."
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。
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。
git add backend/app/api/storage_release_review.py
git commit -m "feat: require reviewSide and write menus on storage release APIs."
Files:
web-admin/src/router/modules/sqxp.tsModify: 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 为准)。
替换 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)。
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."
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 函数可保留。
git add web-admin/src/api/storage-release-review.ts
git commit -m "feat: add dual-side fields to storage release frontend API."
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 脏数据)。
筛选区原「审核结论」换成两个 el-form-item:
生产结论,v-model="searchForm.prodReviewConclusion",options 仍用 reviewConclusionOptions质检结论,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 不会打开审核弹窗)。
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."
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 引入。
reviewSidebuildPayload:
return {
reviewSide: props.reviewSide,
reviewConclusion: form.reviewConclusion,
reviewRemark: form.reviewRemark.trim() || undefined,
reviewAttachments: attachmentUrls || undefined
}
打开弹窗加载详情后,本侧表单可预填已有结论:reviewSide === 'production' ? review.prodReviewConclusion : review.qcReviewConclusion,空则默认 tg。
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."
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(或保留但不作为主展示)。
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."
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 不再因汇总结论为空而省略)。
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."
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。
./scripts/update-graph.sh all
yrk。drk,样本 jcz,库位产品仍在。yrk。tg 且 drk 生效。git add backend/graphify-out web-admin/graphify-out
git commit -m "chore: update graphify after storage release dual-gate."
| 规格章节 | 任务 |
|---|---|
| §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 明确不改那些函数 |