feat(cell-storage): 储存证质检字段级质检/生产权限

按方案 A 增加租户级 ACL(字段+人员),详情返回可编辑字段,保存改为服务端 merge;弹窗 footer 提供权限配置入口。关联 #93。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-11-11 16:13:20 +08:00
1 parent 9dc6812393
commit 529f36eac6
16 files changed
+1210 -32

No files matched your search

+95 -3
View File
@@ -1,19 +1,28 @@
"""样本储存证质检信息 API"""
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import require_any_menu_code, require_strict_any_menu_code
from app.core.menu_guard import (
require_any_menu_code,
require_strict_any_menu_code,
require_strict_menu_code,
strict_user_has_any_menu_perm,
strict_user_has_menu_perm,
user_has_menu_perm,
)
from app.core.response import error_response, success_response
from app.crud import sample_storage_cert_qc as svc
from app.crud import sample_storage_cert_qc_acl as acl_svc
from app.models.models import SysUser
from app.services.sample_storage_cert_qc import ACL_CONFIG_MENU
router = APIRouter(prefix="/web/admin", tags=["储存证质检信息"])
# 市场 / 质检 / 生产三端共用读写(方案 A:完整表后写覆盖)
# 市场 / 质检 / 生产三端共用读写
READ_MENUS = (
"biz:qcWorkOrder:list",
"biz:myQcTask:list",
@@ -41,6 +50,12 @@ def _get_current_user_id(user: dict) -> str:
return str(user_id)
def _can_configure_acl(db: Session, user: dict) -> bool:
return user_has_menu_perm(db, user, ACL_CONFIG_MENU) or strict_user_has_menu_perm(
db, user, ACL_CONFIG_MENU
)
class UpsertBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
@@ -48,6 +63,13 @@ class UpsertBody(BaseModel):
qc_work_order_id: Optional[str] = Field(None, alias="qcWorkOrderId")
class AclUpsertBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
field_keys: List[str] = Field(default_factory=list, alias="fieldKeys")
user_ids: List[str] = Field(default_factory=list, alias="userIds")
@router.get("/sample-storage-cert-qc/{sample_code}")
async def get_sample_storage_cert_qc(
sample_code: str,
@@ -55,9 +77,19 @@ async def get_sample_storage_cert_qc(
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
detail = svc.get_detail(db, tenant_no, sample_code)
if detail is None:
return error_response(404, "样本不存在")
has_legacy_write = strict_user_has_any_menu_perm(db, user, WRITE_MENUS)
svc.attach_acl_context(
db,
detail,
tenant_no=tenant_no,
user_id=user_id,
has_legacy_write=has_legacy_write,
can_configure_acl=_can_configure_acl(db, user),
)
return success_response(detail)
@@ -70,6 +102,18 @@ async def put_sample_storage_cert_qc(
):
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
has_legacy_write = strict_user_has_any_menu_perm(db, user, WRITE_MENUS)
# 先用空 sampleTypeKey 解析严格模式名单;类型在 upsert 内再收窄
preview = svc.get_detail(db, tenant_no, sample_code)
if preview is None:
return error_response(404, "样本不存在")
editable = acl_svc.resolve_editable_field_keys(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_type_key=preview.get("sampleTypeKey"),
has_legacy_write=has_legacy_write,
)
try:
detail = svc.upsert(
db,
@@ -78,6 +122,54 @@ async def put_sample_storage_cert_qc(
sample_code=sample_code,
payload=body.payload,
qc_work_order_id=body.qc_work_order_id,
editable_field_keys=editable,
)
svc.attach_acl_context(
db,
detail,
tenant_no=tenant_no,
user_id=user_id,
has_legacy_write=has_legacy_write,
can_configure_acl=_can_configure_acl(db, user),
)
return success_response(detail, message="保存成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.get("/sample-storage-cert-qc-acl/{scope}")
async def get_sample_storage_cert_qc_acl(
scope: str,
user: dict = Depends(require_strict_menu_code(ACL_CONFIG_MENU, allow_super_admin=True)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
try:
detail = acl_svc.get_acl_detail(db, tenant_no, scope)
return success_response(detail)
except ValueError as e:
return error_response(400, str(e))
@router.put("/sample-storage-cert-qc-acl/{scope}")
async def put_sample_storage_cert_qc_acl(
scope: str,
body: AclUpsertBody,
user: dict = Depends(require_strict_menu_code(ACL_CONFIG_MENU, allow_super_admin=True)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
try:
detail = acl_svc.upsert_acl(
db,
tenant_no=tenant_no,
user_id=user_id,
scope=scope,
field_keys=body.field_keys,
user_ids=body.user_ids,
)
return success_response(detail, message="保存成功")
except ValueError as e:
+32 -2
View File
@@ -15,11 +15,12 @@ from app.models.models import (
BizProtocolSample,
BizSampleStorageCertQc,
)
from app.crud import sample_storage_cert_qc_acl as acl_svc
from app.services.sample_storage_cert_qc import (
dumps_payload,
empty_detail,
loads_payload,
normalize_payload,
merge_payload_with_acl,
resolve_sample_type_key,
sanitize_payload_for_type,
)
@@ -166,6 +167,7 @@ def upsert(
sample_code: str,
payload: Any,
qc_work_order_id: Optional[str] = None,
editable_field_keys: Optional[list] = None,
) -> Dict[str, Any]:
ctx = _load_sample_context(db, tenant_no, sample_code)
if not ctx:
@@ -174,7 +176,6 @@ def upsert(
key = resolve_sample_type_key(type_name)
if not key:
raise ValueError("该样本类型不支持填写储存证质检信息")
clean = sanitize_payload_for_type(normalize_payload(payload), key)
now = datetime.now()
row = (
db.query(BizSampleStorageCertQc)
@@ -185,6 +186,14 @@ def upsert(
)
.first()
)
existing = loads_payload(row.payload) if row else {}
allowed = list(editable_field_keys or [])
clean = merge_payload_with_acl(
existing=existing,
incoming=payload,
editable_keys=allowed,
sample_type_key=key,
)
if not row:
row = BizSampleStorageCertQc(
id=generate_uuid(),
@@ -221,3 +230,24 @@ def upsert(
mother_name=mother_name,
sample_type_name=type_name,
)
def attach_acl_context(
db: Session,
detail: Dict[str, Any],
*,
tenant_no: str,
user_id: str,
has_legacy_write: bool,
can_configure_acl: bool,
) -> Dict[str, Any]:
detail["editableFieldKeys"] = acl_svc.resolve_editable_field_keys(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_type_key=detail.get("sampleTypeKey"),
has_legacy_write=has_legacy_write,
)
detail["canConfigureAcl"] = bool(can_configure_acl)
detail["aclStrict"] = acl_svc.is_acl_strict(db, tenant_no)
return detail
@@ -0,0 +1,271 @@
"""储存证质检字段 ACL CRUD。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Set
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.models import BizStorageCertQcAcl, BizStorageCertQcAclUser, SysUser
from app.services.sample_storage_cert_qc import (
ACL_SCOPES,
ALL_STORAGE_CERT_QC_FIELD_KEYS,
dumps_field_keys,
legal_field_keys_for_type,
loads_field_keys,
)
def is_acl_strict(db: Session, tenant_no: str) -> bool:
row = (
db.query(BizStorageCertQcAcl.id)
.filter(
BizStorageCertQcAcl.tenant_no == tenant_no,
BizStorageCertQcAcl.configured_flag == "1",
BizStorageCertQcAcl.del_flag == 0,
)
.first()
)
return row is not None
def resolve_editable_field_keys(
db: Session,
*,
tenant_no: str,
user_id: str,
sample_type_key: Optional[str],
has_legacy_write: bool,
) -> List[str]:
legal = legal_field_keys_for_type(sample_type_key)
if not is_acl_strict(db, tenant_no):
return sorted(legal) if has_legacy_write else []
uid = (user_id or "").strip()
if not uid:
return []
acls = (
db.query(BizStorageCertQcAcl)
.filter(
BizStorageCertQcAcl.tenant_no == tenant_no,
BizStorageCertQcAcl.configured_flag == "1",
BizStorageCertQcAcl.del_flag == 0,
)
.all()
)
if not acls:
return []
acl_ids = [row.id for row in acls]
membership = (
db.query(BizStorageCertQcAclUser.acl_id)
.filter(
BizStorageCertQcAclUser.tenant_no == tenant_no,
BizStorageCertQcAclUser.acl_id.in_(acl_ids),
BizStorageCertQcAclUser.user_id == uid,
BizStorageCertQcAclUser.del_flag == 0,
)
.all()
)
member_acl_ids = {row[0] for row in membership}
keys: Set[str] = set()
for acl in acls:
if acl.id not in member_acl_ids:
continue
keys.update(loads_field_keys(acl.field_keys))
return sorted(keys & legal)
def _validate_scope(scope: str) -> str:
value = (scope or "").strip()
if value not in ACL_SCOPES:
raise ValueError("scope 必须为 qc 或 production")
return value
def _validate_field_keys(field_keys: Any) -> List[str]:
if not isinstance(field_keys, list):
raise ValueError("fieldKeys 必须为数组")
out: List[str] = []
seen = set()
for item in field_keys:
key = str(item or "").strip()
if not key or key in seen:
continue
if key not in ALL_STORAGE_CERT_QC_FIELD_KEYS:
raise ValueError(f"非法字段: {key}")
seen.add(key)
out.append(key)
return out
def _validate_user_ids(db: Session, tenant_no: str, user_ids: Any) -> List[str]:
if not isinstance(user_ids, list):
raise ValueError("userIds 必须为数组")
out: List[str] = []
seen = set()
for item in user_ids:
uid = str(item or "").strip()
if not uid or uid in seen:
continue
seen.add(uid)
out.append(uid)
if not out:
return []
rows = (
db.query(SysUser.id)
.filter(
SysUser.tenant_no == tenant_no,
SysUser.id.in_(out),
SysUser.del_flag == 0,
)
.all()
)
found = {row[0] for row in rows}
missing = [uid for uid in out if uid not in found]
if missing:
raise ValueError(f"用户不存在或不属于当前租户: {','.join(missing[:5])}")
return out
def get_acl_detail(db: Session, tenant_no: str, scope: str) -> Dict[str, Any]:
scope_key = _validate_scope(scope)
row = (
db.query(BizStorageCertQcAcl)
.filter(
BizStorageCertQcAcl.tenant_no == tenant_no,
BizStorageCertQcAcl.scope == scope_key,
BizStorageCertQcAcl.del_flag == 0,
)
.first()
)
if not row:
return {
"scope": scope_key,
"fieldKeys": [],
"userIds": [],
"users": [],
"configuredFlag": False,
"aclStrict": is_acl_strict(db, tenant_no),
}
users = (
db.query(BizStorageCertQcAclUser, SysUser)
.outerjoin(
SysUser,
(SysUser.id == BizStorageCertQcAclUser.user_id) & (SysUser.del_flag == 0),
)
.filter(
BizStorageCertQcAclUser.tenant_no == tenant_no,
BizStorageCertQcAclUser.acl_id == row.id,
BizStorageCertQcAclUser.del_flag == 0,
)
.order_by(BizStorageCertQcAclUser.order_num.asc(), BizStorageCertQcAclUser.create_time.asc())
.all()
)
user_ids: List[str] = []
user_rows: List[Dict[str, str]] = []
for link, user in users:
uid = link.user_id or ""
if not uid:
continue
user_ids.append(uid)
user_rows.append(
{
"id": uid,
"nickName": (user.nick_name if user else "") or "",
"userName": (user.user_no if user else "") or "",
}
)
return {
"scope": scope_key,
"fieldKeys": loads_field_keys(row.field_keys),
"userIds": user_ids,
"users": user_rows,
"configuredFlag": str(row.configured_flag or "0") == "1",
"aclStrict": is_acl_strict(db, tenant_no),
}
def upsert_acl(
db: Session,
*,
tenant_no: str,
user_id: str,
scope: str,
field_keys: Any,
user_ids: Any,
) -> Dict[str, Any]:
scope_key = _validate_scope(scope)
keys = _validate_field_keys(field_keys)
users = _validate_user_ids(db, tenant_no, user_ids)
now = datetime.now()
row = (
db.query(BizStorageCertQcAcl)
.filter(
BizStorageCertQcAcl.tenant_no == tenant_no,
BizStorageCertQcAcl.scope == scope_key,
BizStorageCertQcAcl.del_flag == 0,
)
.first()
)
if not row:
row = BizStorageCertQcAcl(
id=generate_uuid(),
scope=scope_key,
field_keys=dumps_field_keys(keys),
configured_flag="1",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(row)
db.flush()
else:
row.field_keys = dumps_field_keys(keys)
row.configured_flag = "1"
row.update_by = user_id
row.update_time = now
def _build_row(new_id: str):
return BizStorageCertQcAclUser(
id=new_id,
acl_id=row.id,
user_id="",
tenant_no=tenant_no,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
def _apply_fields(link, item: Dict[str, Any], index: int, ts: datetime):
link.user_id = str(item.get("userId") or "").strip()
link.tenant_no = tenant_no
link.order_num = index
link.update_by = user_id
link.update_time = ts
sync_child_rows(
db,
BizStorageCertQcAclUser,
parent_attr="acl_id",
parent_id=row.id,
items=[{"userId": uid} for uid in users],
build_row=_build_row,
apply_fields=_apply_fields,
tenant_no=tenant_no,
user_id=user_id,
now=now,
natural_key=lambda item: str(item.get("userId") or "").strip(),
row_natural_key=lambda link: str(getattr(link, "user_id", "") or "").strip(),
duplicate_message="人员重复",
foreign_message="人员记录不属于当前 ACL",
)
db.commit()
return get_acl_detail(db, tenant_no, scope_key)
+39
View File
@@ -1779,6 +1779,45 @@ class BizSampleStorageCertQc(Base):
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizStorageCertQcAcl(Base):
"""储存证质检字段 ACL 主表 biz_storage_cert_qc_acl"""
__tablename__ = "biz_storage_cert_qc_acl"
id = Column(String(64), primary_key=True, comment="主键ID")
scope = Column(String(32), nullable=False, comment="qc|production")
field_keys = Column(Text, nullable=False, comment="可编辑字段 key JSON 数组")
configured_flag = Column(CHAR(1), nullable=False, default="0", comment="是否已正式配置 0否1是")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizStorageCertQcAclUser(Base):
"""储存证质检字段 ACL 人员子表 biz_storage_cert_qc_acl_user"""
__tablename__ = "biz_storage_cert_qc_acl_user"
id = Column(String(64), primary_key=True, comment="主键ID")
acl_id = Column(String(64), nullable=False, comment="ACL 主表ID")
user_id = Column(String(64), nullable=False, comment="可编辑用户ID")
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
order_num = Column(Integer, nullable=True, comment="排序号")
remark = Column(String(512), nullable=True, comment="备注")
create_by = Column(String(64), nullable=True, comment="创建人id")
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
update_by = Column(String(64), nullable=True, comment="更新人id")
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
class BizSampleCodeCorrectionLog(Base):
"""样本编码纠错审计日志 biz_sample_code_correction_log"""
+122 -9
View File
@@ -2,18 +2,78 @@
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from typing import Any, Dict, Iterable, List, Optional, Set
SAMPLE_TYPE_NAME_PERINATAL_HSC = "围产期造血干细胞"
SAMPLE_TYPE_NAME_UC_MSC = "脐带间充质干细胞"
SAMPLE_TYPE_KEY_PERINATAL_HSC = "perinatal_hsc"
SAMPLE_TYPE_KEY_UC_MSC = "uc_msc"
ACL_SCOPE_QC = "qc"
ACL_SCOPE_PRODUCTION = "production"
ACL_SCOPES = frozenset({ACL_SCOPE_QC, ACL_SCOPE_PRODUCTION})
ACL_CONFIG_MENU = "cell-storage:sample-info:storage-cert-qc-acl"
_NAME_TO_KEY = {
SAMPLE_TYPE_NAME_PERINATAL_HSC: SAMPLE_TYPE_KEY_PERINATAL_HSC,
SAMPLE_TYPE_NAME_UC_MSC: SAMPLE_TYPE_KEY_UC_MSC,
}
# 与 web-admin STORAGE_CERT_QC_GROUPS 对齐的合法 payload 键(两类型并集)。
ALL_STORAGE_CERT_QC_FIELD_KEYS = frozenset(
{
"collection_time",
"transport_temp_c",
"receive_time",
"cryo_volume_ml",
"tnc_1e8",
"cd34_count_1e6",
"cd34_cd45dim_1e5",
"viability_pct",
"bacteria_test",
"hbsag",
"anti_hcv",
"anti_tp",
"anti_hiv",
"cmv_igm",
"mother_hbsag",
"mother_hbv_dna",
"mother_anti_hcv",
"mother_hcv_rna",
"mother_anti_tp",
"mother_cmv_igm",
"mother_anti_hiv",
"mother_hiv1_rna",
"hla_typing",
"storage_date",
"storage_years",
"result_comment",
"report_issue_date",
"storage_tube_count",
"available_cell_type",
"available_cell_count_1e7",
"available_viability_pct",
"endotoxin_test",
"fungus_test",
"mycoplasma_test",
"available_cell_morphology",
"available_immunophenotype",
"result_judgment",
"storage_date_p2",
"freeze_method",
"storage_temp",
}
)
# 脐带间充质储存证不收录母亲血液三项核酸检测。
UC_MSC_EXCLUDED_PAYLOAD_KEYS = frozenset(
{
"mother_hbv_dna",
"mother_hcv_rna",
"mother_hiv1_rna",
}
)
def resolve_sample_type_key(sample_type_name: Optional[str]) -> Optional[str]:
name = (sample_type_name or "").strip()
@@ -24,14 +84,11 @@ def supports_storage_cert_qc(sample_type_name: Optional[str]) -> bool:
return resolve_sample_type_key(sample_type_name) is not None
# 脐带间充质储存证不收录母亲血液三项核酸检测。
UC_MSC_EXCLUDED_PAYLOAD_KEYS = frozenset(
{
"mother_hbv_dna",
"mother_hcv_rna",
"mother_hiv1_rna",
}
)
def legal_field_keys_for_type(sample_type_key: Optional[str]) -> Set[str]:
keys = set(ALL_STORAGE_CERT_QC_FIELD_KEYS)
if sample_type_key == SAMPLE_TYPE_KEY_UC_MSC:
keys -= UC_MSC_EXCLUDED_PAYLOAD_KEYS
return keys
def normalize_payload(payload: Any) -> Dict[str, Any]:
@@ -74,6 +131,62 @@ def loads_payload(raw: Optional[str]) -> Dict[str, Any]:
return data
def dumps_field_keys(keys: Iterable[str]) -> str:
cleaned = sorted({str(k).strip() for k in keys if str(k).strip()})
return json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"))
def loads_field_keys(raw: Optional[str]) -> List[str]:
if not raw:
return []
try:
data = json.loads(raw)
except (TypeError, ValueError):
return []
if not isinstance(data, list):
return []
out: List[str] = []
seen = set()
for item in data:
key = str(item or "").strip()
if not key or key in seen:
continue
seen.add(key)
out.append(key)
return out
def _payload_value_text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def merge_payload_with_acl(
*,
existing: Dict[str, Any],
incoming: Dict[str, Any],
editable_keys: Iterable[str],
sample_type_key: Optional[str],
) -> Dict[str, Any]:
"""仅合并有权字段;无权字段若相对原值变化则拒绝。"""
allowed = {str(k).strip() for k in editable_keys if str(k).strip()}
if not allowed:
raise PermissionError("无字段编辑权限")
clean_incoming = sanitize_payload_for_type(normalize_payload(incoming), sample_type_key)
base = sanitize_payload_for_type(dict(existing or {}), sample_type_key)
for key, value in clean_incoming.items():
if key in allowed:
continue
if _payload_value_text(base.get(key)) != _payload_value_text(value):
raise PermissionError(f"无权修改字段: {key}")
merged = dict(base)
for key in allowed:
if key in clean_incoming:
merged[key] = clean_incoming[key]
return sanitize_payload_for_type(merged, sample_type_key)
def empty_detail(
*,
sample_code: str,
@@ -8,6 +8,7 @@ from app.services.sample_storage_cert_qc import (
SAMPLE_TYPE_KEY_PERINATAL_HSC,
SAMPLE_TYPE_KEY_UC_MSC,
empty_detail,
merge_payload_with_acl,
normalize_payload,
resolve_sample_type_key,
sanitize_payload_for_type,
@@ -368,5 +369,35 @@ class TestStorageCertPrefill(unittest.TestCase):
self.assertNotIn("hbsag", merged)
class TestMergePayloadWithAcl(unittest.TestCase):
def test_merges_only_editable_keys(self):
merged = merge_payload_with_acl(
existing={"receive_time": "a", "bacteria_test": "阴性"},
incoming={"receive_time": "b", "bacteria_test": "阴性"},
editable_keys=["receive_time"],
sample_type_key=SAMPLE_TYPE_KEY_PERINATAL_HSC,
)
self.assertEqual(merged["receive_time"], "b")
self.assertEqual(merged["bacteria_test"], "阴性")
def test_rejects_unauthorized_change(self):
with self.assertRaises(PermissionError):
merge_payload_with_acl(
existing={"bacteria_test": "阴性"},
incoming={"bacteria_test": "阳性"},
editable_keys=["receive_time"],
sample_type_key=SAMPLE_TYPE_KEY_PERINATAL_HSC,
)
def test_rejects_empty_editable_keys(self):
with self.assertRaises(PermissionError):
merge_payload_with_acl(
existing={},
incoming={"receive_time": "x"},
editable_keys=[],
sample_type_key=SAMPLE_TYPE_KEY_PERINATAL_HSC,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,35 @@
"""储存证质检字段 ACL:合法字段与 scope。"""
import unittest
from app.services.sample_storage_cert_qc import (
ACL_SCOPE_PRODUCTION,
ACL_SCOPE_QC,
ALL_STORAGE_CERT_QC_FIELD_KEYS,
SAMPLE_TYPE_KEY_UC_MSC,
dumps_field_keys,
legal_field_keys_for_type,
loads_field_keys,
)
class TestAclFieldKeys(unittest.TestCase):
def test_uc_msc_excludes_mother_nucleic_acids(self):
keys = legal_field_keys_for_type(SAMPLE_TYPE_KEY_UC_MSC)
self.assertNotIn("mother_hbv_dna", keys)
self.assertIn("mother_hbsag", keys)
def test_roundtrip_field_keys(self):
raw = dumps_field_keys(["receive_time", "bacteria_test", "receive_time", ""])
self.assertEqual(loads_field_keys(raw), ["bacteria_test", "receive_time"])
def test_all_keys_cover_common_fields(self):
self.assertIn("receive_time", ALL_STORAGE_CERT_QC_FIELD_KEYS)
self.assertIn("mother_anti_hiv", ALL_STORAGE_CERT_QC_FIELD_KEYS)
def test_scopes(self):
self.assertEqual(ACL_SCOPE_QC, "qc")
self.assertEqual(ACL_SCOPE_PRODUCTION, "production")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,65 @@
-- Issue #93:储存证质检信息字段级 ACL(质检/生产)。MySQL 5.7,可重复执行。
-- 不自动授予任何角色;管理员在角色管理中勾选「储存证质检字段权限配置」。
CREATE TABLE IF NOT EXISTS biz_storage_cert_qc_acl (
id VARCHAR(64) NOT NULL,
scope VARCHAR(32) NOT NULL COMMENT 'qc|production',
field_keys TEXT NOT NULL COMMENT '可编辑字段 key JSON 数组',
configured_flag CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否已正式配置 0否1是',
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY ix_tenant_scope (tenant_no, scope, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS biz_storage_cert_qc_acl_user (
id VARCHAR(64) NOT NULL,
acl_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY ix_tenant_acl (tenant_no, acl_id, del_flag),
KEY ix_tenant_user (tenant_no, user_id, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO sys_menu (
id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, remark,
create_by, create_dept_id, create_time, update_by, update_time, del_flag
)
SELECT
INSERT(INSERT(MD5(CONCAT('storage-cert-qc-acl:', parent.tenant_no, ':', parent.id)),
13, 1, '3'), 17, 1, '8'),
'储存证质检字段权限配置', parent.id, 'F', '', NULL,
'cell-storage:sample-info:storage-cert-qc-acl', 1, '1', '1', parent.tenant_no, 95,
'控制储存证质检弹窗中质检/生产字段与人员 ACL 配置入口',
'migration', '', NOW(), 'migration', NOW(), 0
FROM sys_menu AS parent
WHERE parent.del_flag = 0 AND parent.status = '1'
AND parent.perms = 'cell-storage:sample-info:list'
AND NOT EXISTS (
SELECT 1 FROM sys_menu AS existing
WHERE existing.parent_id = parent.id
AND existing.tenant_no = parent.tenant_no
AND existing.perms = 'cell-storage:sample-info:storage-cert-qc-acl'
AND existing.del_flag = 0
);
SELECT tenant_no, parent_id, id, menu_name, perms, status
FROM sys_menu
WHERE perms = 'cell-storage:sample-info:storage-cert-qc-acl' AND del_flag = 0;
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-11
@@ -0,0 +1,47 @@
## Context
储存证质检信息由 `biz_sample_storage_cert_qc.payload` JSON 保存,Web 共用 `storage-cert-qc-dialog`,API 以菜单码控制读写且保存为整单覆盖。业务现要求按「质检 / 生产」拆分字段编辑权,并可由管理员配置人员。
已确认:方案 A(租户级配置 + 按人授权);配置入口独立菜单码;未配置前兼容旧写权限;同人字段并集;修改端为 web-admin + backend。
## Goals / Non-Goals
**Goals:**
- 租户可配置质检、生产两套「字段 + 人员」ACL
- 前端按字段只读/可写;后端 merge 强制执行
- 三处入口行为一致;配置 UI 符合 Fresh Mint 弹窗规范
**Non-Goals:**
- 不按样本、不按工单单独配置 ACL
- 不引入角色绑定作为本期主路径(人员勾选)
- 不改造 mobile-h5 / mobile-oa-h5
- 不改变 payload 字段语义与预填逻辑(仅限制谁能改)
## Decisions
1. **存储**:主表 `biz_storage_cert_qc_acl`(`tenant_no`+`scope` 唯一,`field_keys` JSON,`configured_flag`)+ 子表 `biz_storage_cert_qc_acl_user`(`acl_id`+`user_id`,软删同步)。相对单 JSON 大包,子表便于人员校验与审计扩展。
2. **权限解析**:用户可编辑字段 = 其所属各已配置 scope 的 field_keys 并集;严格模式在「任一 scope 的 configured_flag=1」时启用;否则有原 WRITE_MENUS 即可编辑全部合法字段。
3. **写路径**:**BREAKING 行为**:`PUT` 从整单覆盖改为服务端 merge——仅更新 `editableFieldKeys` 内键;无任何可写字段拒绝保存;提交中含无权键且值相对库内有变化则 403。
4. **配置权限**:独立菜单码(建议 `cell-storage:sample-info:storage-cert-qc-acl`);与业务填写菜单分离。按钮仅在 `canConfigureAcl=true` 时展示。
5. **字段候选**:前后端共用类型字段清单(与 `STORAGE_CERT_QC_GROUPS` / 后端 type keys 对齐);配置 UI 展示两样本类型字段并集,非法 key 拒绝保存。
6. **UI**:主弹窗 footer 左侧配置按钮、右侧取消/确定;配置子弹两个 Tab(字段勾选 / 人员多选)。先 Figma 确认再编码。
## Risks / Trade-offs
- [未配置→已配置切换瞬间全员变严] → 上线说明;兼容期默认未配置仍可写;配置保存前可预览影响(可选,本期不做强制)。
- [人员名单维护成本] → 接受方案 A;后续可加角色扩展,不阻塞本期。
- [整单覆盖客户端旧代码] → 后端 merge 兼容旧客户端:无权字段被忽略或在变更时 403;推荐 403 以便发现错误集成。
- [超级管理员] → 不默认绕过 ACL;仅配置菜单控制谁能改 ACL。若需超管旁路,另开需求。
## Migration Plan
1. 执行 SQL 建表;部署后端(读接口先返回兼容态 editableFieldKeys=全部或空+aclStrict=false)。
2. 部署前端(无配置权限时 UI 与现网接近)。
3. 管理员配置质检/生产后该租户进入严格模式。
4. 回滚:保留表数据;回退代码后配置表闲置;严格模式逻辑随代码回退消失。
## Open Questions
- 无(关键项已在对话确认:租户级、独立配置菜单、兼容期、字段并集、先 Figma)。
@@ -0,0 +1,28 @@
## Why
「填写储存证质检信息」弹窗目前只要具备菜单写权限即可改全部字段,无法按质检 / 生产职责拆分。业务需要:质检与生产各自配置可编辑字段与人员,范围外用户仅可查看,且服务端必须强制校验。
## What Changes
- 新增租户级储存证质检字段 ACL:两个 scope(`qc` / `production`),各自保存可编辑字段列表与可编辑人员。
- 详情接口返回当前用户 `editableFieldKeys` 等权限上下文;保存改为服务端按权限 merge,拒绝无权字段篡改。
- 主弹窗 footer 左侧增加「质检权限」「生产权限」入口(仅配置权限可见);配置子弹含「勾选字段」「配置人员」两个 Tab。
- 未对任一 scope 保存过配置时,兼容现有菜单写权限(可改全部);任一 scope 首次配置后该租户进入严格 ACL。
- **不改**小程序 / mobile-oa-h5;三处 Web 入口(客户服务、质检工单、生产工单)共用同一弹窗组件一并生效。
## Capabilities
### New Capabilities
- `storage-cert-qc-field-acl`: 储存证质检信息字段级编辑权限(配置、解析、读写强制)
### Modified Capabilities
- (无;`openspec/specs/` 尚未建立长期基线中的对应能力)
## Impact
- **backend**:新表与 CRUD/API;`sample_storage_cert_qc` 读/写路径;菜单码与鉴权
- **web-admin**:`storage-cert-qc-dialog`、ACL 配置子弹、API 客户端
- **docs/sql**:结构迁移脚本
- **权限**:租户隔离;配置入口独立菜单码;写入不信任客户端字段权限声明
@@ -0,0 +1,76 @@
## ADDED Requirements
### Requirement: 租户级质检与生产字段 ACL 配置
系统 SHALL 按租户维护储存证质检信息的两套编辑权限配置,scope 分别为 `qc`(质检)与 `production`(生产)。每套配置 SHALL 包含可编辑字段 key 列表与可编辑用户列表。字段 key MUST 属于该业务已定义的合法 payload 键集合。
#### Scenario: 管理员保存质检权限
- **WHEN** 具备 ACL 配置菜单权限的用户保存 scope=`qc` 的字段列表与人员列表
- **THEN** 系统持久化该租户的质检 ACL,并将该 scope 标记为已配置
#### Scenario: 非法字段被拒绝
- **WHEN** 配置请求包含不在合法 payload 键集合中的 field key
- **THEN** 系统拒绝保存并返回错误,不部分写入
### Requirement: 按当前用户解析可编辑字段
系统在返回储存证质检详情时 SHALL 附带当前用户可编辑字段集合。可编辑字段 MUST 为用户所属各 scope 字段列表的并集。未出现在任一 ACL 人员名单中的用户,在严格模式下 MUST 得到空的可编辑字段集合(仍可只读查看详情)。
#### Scenario: 同属两 scope 取并集
- **WHEN** 用户同时在质检与生产人员名单中,且两 scope 勾选了不同字段
- **THEN** 详情中的可编辑字段为两 scope 字段的并集
#### Scenario: 未授权用户只读
- **WHEN** 租户已进入严格 ACL,且当前用户不在任一 scope 人员名单中
- **THEN** 可编辑字段为空,用户可查看已有数据但不能修改
### Requirement: 未配置前兼容菜单写权限
当租户的质检与生产 scope 均未被标记为已配置时,系统 SHALL 保持兼容行为:只要用户具备既有储存证质检写菜单权限,即可编辑全部合法字段。当任一 scope 被标记为已配置后,该租户 MUST 进入严格 ACL,不再仅凭菜单写权限获得全字段编辑权。
#### Scenario: 全新租户未配置
- **WHEN** 租户两 scope 均未配置,且用户具备既有写菜单权限
- **THEN** 详情返回全部合法字段为可编辑,保存可更新这些字段
#### Scenario: 首次配置后收紧
- **WHEN** 管理员首次成功保存任一 scope 的 ACL
- **THEN** 之后该租户按严格 ACL 解析可编辑字段与保存权限
### Requirement: 保存时服务端强制 merge
储存证质检保存接口 MUST 仅更新当前用户有权编辑的字段;无权字段 MUST 保留库中原值。若请求试图修改无权字段的值,系统 MUST 拒绝该请求。当用户没有任何可编辑字段时,系统 MUST 拒绝保存。
#### Scenario: 部分字段可写
- **WHEN** 用户仅对字段集合 A 有编辑权并提交包含 A 与其他字段的 payload
- **THEN** 仅 A 中相对原值有权限的变更被写入,其他键保持原值;若其他键相对原值发生变化则整单拒绝
#### Scenario: 无可写字段
- **WHEN** 当前用户可编辑字段为空并调用保存接口
- **THEN** 系统拒绝保存
### Requirement: 配置入口与主弹窗交互
Web 管理端储存证质检弹窗在用户具备 ACL 配置权限时,SHALL 在 footer 左侧提供「质检权限」「生产权限」入口。配置界面 SHALL 提供两个 Tab:勾选可编辑字段、配置可编辑人员。不具备配置权限的用户 MUST 看不到上述入口。不具备字段编辑权的用户打开业务弹窗时 MUST 以只读方式展示字段,且不得出现可提交业务保存的主按钮(或等价禁用)。
#### Scenario: 管理员可见配置按钮
- **WHEN** 具备 ACL 配置菜单权限的用户打开填写弹窗
- **THEN** footer 左侧显示质检权限与生产权限按钮
#### Scenario: 普通填写人不可见配置
- **WHEN** 不具备 ACL 配置菜单权限的用户打开填写弹窗
- **THEN** 不显示质检权限与生产权限按钮
#### Scenario: 三处入口一致
- **WHEN** 用户从客户服务、质检工单或生产工单打开同一储存证质检弹窗
- **THEN** 字段可编辑性与配置入口可见性均按同一 ACL 与菜单规则生效
@@ -0,0 +1,26 @@
## 1. 数据与后端 ACL
- [x] 1.1 新增 `docs/sql/` 迁移:`biz_storage_cert_qc_acl`、`biz_storage_cert_qc_acl_user`(租户、软删、标准审计字段)
- [x] 1.2 新增 ORM / CRUD:按 tenant+scope 读写字段列表与人员;保存时校验合法 field key、同步子行
- [x] 1.3 实现当前用户可编辑字段解析(兼容模式 vs 严格模式、两 scope 并集)
- [x] 1.4 扩展 GET 详情:返回 `editableFieldKeys`、`canConfigureAcl`、`aclStrict`
- [x] 1.5 改造 PUT 保存为服务端 merge,无权字段变更拒绝,无可写字段拒绝
- [x] 1.6 新增 ACL GET/PUT API,鉴权独立配置菜单码
- [x] 1.7 补充/调整 `backend/tests/test_sample_storage_cert_qc.py`(及 ACL 用例)覆盖兼容、严格、并集、拒绝篡改
## 2. Web 主弹窗与配置弹窗
- [x] 2.1 按已确认 Figma 原型调整 `storage-cert-qc-dialog` footer(左侧配置按钮、右侧取消/确定)与字段只读态
- [x] 2.2 新增 ACL 配置弹窗(质检/生产):Tab1 分组勾选字段,Tab2 选人,保存调 ACL API
- [x] 2.3 接入 API 客户端与菜单码;无配置权限不展示入口;无可写字段隐藏业务确定
- [x] 2.4 核对客户服务 / 质检工单 / 生产工单三处入口共用行为
## 3. 权限种子与文档回写
- [x] 3.1 增加配置菜单码种子/SQL(命名与路由约定一致),说明仅管理员授予
- [ ] 3.2 工单回写:OpenSpec 路径、Figma 链接、验收要点;原型确认结论
## 4. 验证
- [x] 4.1 在任务 worktree 运行受影响后端单元测试并记录结果
- [x] 4.2 源码级对照已确认原型(footer 布局、双 Tab、按钮主次、只读态)
@@ -12,6 +12,20 @@ export type SampleStorageCertQcDetail = {
filledFlag: boolean
payload: Record<string, unknown>
prefill?: Record<string, unknown>
editableFieldKeys?: string[]
canConfigureAcl?: boolean
aclStrict?: boolean
}
export type StorageCertQcAclScope = 'qc' | 'production'
export type StorageCertQcAclDetail = {
scope: StorageCertQcAclScope
fieldKeys: string[]
userIds: string[]
users: Array<{ id: string; nickName: string; userName: string }>
configuredFlag: boolean
aclStrict: boolean
}
export function fetchSampleStorageCertQc(sampleCode: string) {
@@ -29,3 +43,19 @@ export function saveSampleStorageCertQc(
data
})
}
export function fetchSampleStorageCertQcAcl(scope: StorageCertQcAclScope) {
return request.get({
url: `/web/admin/sample-storage-cert-qc-acl/${encodeURIComponent(scope)}`
})
}
export function saveSampleStorageCertQcAcl(
scope: StorageCertQcAclScope,
data: { fieldKeys: string[]; userIds: string[] }
) {
return request.put({
url: `/web/admin/sample-storage-cert-qc-acl/${encodeURIComponent(scope)}`,
data
})
}
@@ -0,0 +1,218 @@
<template>
<el-dialog
:model-value="visible"
:title="dialogTitle"
width="720px"
class="dialog-form-layout storage-cert-qc-acl-dialog"
align-center
append-to-body
destroy-on-close
modal-class="protocol-dialog-mask"
@update:model-value="emit('update:visible', $event)"
>
<div v-loading="loading" class="storage-cert-qc-acl-dialog__body">
<el-tabs v-model="activeTab">
<el-tab-pane label="勾选可编辑字段" name="fields">
<div v-for="(group, gi) in fieldGroups" :key="gi" class="field-block">
<div class="field-block__title">{{ group.title }}</div>
<el-checkbox-group v-model="selectedKeys">
<el-checkbox
v-for="field in group.fields"
:key="field.key"
:label="field.key"
class="field-check"
>
{{ field.label }}
</el-checkbox>
</el-checkbox-group>
</div>
</el-tab-pane>
<el-tab-pane label="配置人员" name="users">
<el-select
v-model="selectedUserIds"
multiple
filterable
remote
clearable
reserve-keyword
:remote-method="searchUsers"
:loading="userLoading"
placeholder="输入姓名或账号搜索"
style="width: 100%"
>
<el-option
v-for="u in userOptions"
:key="u.value"
:label="u.label"
:value="u.value"
/>
</el-select>
<div class="form-tip">仅名单内用户可修改本权限勾选的字段。</div>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button @click="emit('update:visible', false)">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
保存
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchUserList } from '@/api/admin'
import {
fetchSampleStorageCertQcAcl,
saveSampleStorageCertQcAcl,
type StorageCertQcAclScope
} from '@/api/sample-storage-cert-qc'
import { STORAGE_CERT_QC_GROUPS } from '@/utils/storage-cert-qc-fields'
const props = defineProps<{
visible: boolean
scope: StorageCertQcAclScope
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'saved'): void
}>()
const loading = ref(false)
const saving = ref(false)
const userLoading = ref(false)
const activeTab = ref('fields')
const selectedKeys = ref<string[]>([])
const selectedUserIds = ref<string[]>([])
const userOptions = ref<Array<{ label: string; value: string }>>([])
const dialogTitle = computed(() => (props.scope === 'qc' ? '质检权限' : '生产权限'))
const fieldGroups = computed(() => {
const map = new Map<string, { title: string; fields: Array<{ key: string; label: string }> }>()
for (const groups of Object.values(STORAGE_CERT_QC_GROUPS)) {
for (const group of groups) {
const item = map.get(group.title) || { title: group.title, fields: [] }
const seen = new Set(item.fields.map((f) => f.key))
for (const field of group.fields) {
if (seen.has(field.key)) continue
seen.add(field.key)
item.fields.push({ key: field.key, label: field.label })
}
map.set(group.title, item)
}
}
return Array.from(map.values())
})
function upsertUserOption(id: string, label: string) {
if (!id) return
if (userOptions.value.some((u) => u.value === id)) return
userOptions.value.push({ value: id, label: label || id })
}
async function searchUsers(keyword: string) {
userLoading.value = true
try {
const res = await fetchUserList({
page: 1,
pageSize: 50,
status: '1',
keyword: keyword || undefined
})
const list = (res as any)?.list || []
for (const row of list) {
const id = String(row.id || '')
const name = String(row.nickName || row.userName || row.userNo || id)
const account = String(row.userName || row.userNo || '')
upsertUserOption(id, account ? `${name}(${account})` : name)
}
} finally {
userLoading.value = false
}
}
async function loadDetail() {
loading.value = true
activeTab.value = 'fields'
try {
const detail = (await fetchSampleStorageCertQcAcl(props.scope)) as any
selectedKeys.value = Array.isArray(detail?.fieldKeys) ? [...detail.fieldKeys] : []
selectedUserIds.value = Array.isArray(detail?.userIds) ? [...detail.userIds] : []
userOptions.value = []
for (const u of detail?.users || []) {
const id = String(u.id || '')
const name = String(u.nickName || u.userName || id)
const account = String(u.userName || '')
upsertUserOption(id, account ? `${name}(${account})` : name)
}
if (!userOptions.value.length) {
await searchUsers('')
}
} catch {
selectedKeys.value = []
selectedUserIds.value = []
} finally {
loading.value = false
}
}
async function handleSave() {
saving.value = true
try {
await saveSampleStorageCertQcAcl(props.scope, {
fieldKeys: [...selectedKeys.value],
userIds: [...selectedUserIds.value]
})
ElMessage.success('保存成功')
emit('saved')
emit('update:visible', false)
} finally {
saving.value = false
}
}
watch(
() => [props.visible, props.scope] as const,
([vis]) => {
if (vis) loadDetail()
}
)
</script>
<style scoped lang="scss">
.storage-cert-qc-acl-dialog__body {
min-height: 280px;
}
.field-block {
margin-bottom: 14px;
}
.field-block__title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-color-primary-dark-2, #059669);
}
.field-check {
display: flex;
margin-right: 0;
margin-bottom: 6px;
}
.form-tip {
margin-top: 10px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
</style>
@@ -54,7 +54,7 @@
'form-col-span-3': field.span === 3
}"
>
<template v-if="mode === 'view'">
<template v-if="mode === 'view' || !isFieldEditable(field.key)">
<div
class="readonly-box"
:class="{ 'is-positive': field.control === 'select_yny' && isPositiveQual(form[field.key]) }"
@@ -112,32 +112,56 @@
</div>
<template #footer>
<template v-if="mode === 'edit'">
<el-button @click="emit('update:visible', false)">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
<el-button v-else type="primary" @click="emit('update:visible', false)">
<el-icon><Check /></el-icon>
关闭
</el-button>
<div class="storage-cert-qc-dialog__footer">
<div class="footer-left">
<template v-if="canConfigureAcl">
<el-button @click="openAcl('qc')">
<el-icon><Setting /></el-icon>
质检权限
</el-button>
<el-button @click="openAcl('production')">
<el-icon><Setting /></el-icon>
生产权限
</el-button>
</template>
</div>
<div class="footer-right">
<template v-if="mode === 'edit' && canSaveAny">
<el-button @click="emit('update:visible', false)">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
<el-button v-else type="primary" @click="emit('update:visible', false)">
<el-icon><Check /></el-icon>
关闭
</el-button>
</div>
</div>
</template>
</el-dialog>
<StorageCertQcAclDialog
v-model:visible="aclVisible"
:scope="aclScope"
@saved="loadDetail"
/>
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import { Check, Close, Setting } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import {
fetchSampleStorageCertQc,
saveSampleStorageCertQc,
type SampleStorageCertQcDetail
type SampleStorageCertQcDetail,
type StorageCertQcAclScope
} from '@/api/sample-storage-cert-qc'
import StorageCertQcAclDialog from '@/components/business/storage-cert-qc-acl-dialog/index.vue'
import {
STORAGE_CERT_QC_GROUPS,
isPerinatalQualField,
@@ -166,6 +190,10 @@
const filledFlag = ref(false)
const typeKey = ref<StorageCertQcTypeKey | null>(null)
const form = reactive<Record<string, string>>({})
const editableKeySet = ref<Set<string>>(new Set())
const canConfigureAcl = ref(false)
const aclVisible = ref(false)
const aclScope = ref<StorageCertQcAclScope>('qc')
const identity = reactive({
labFlowCode: '',
sampleCode: '',
@@ -174,7 +202,7 @@
})
const dialogTitle = computed(() =>
props.mode === 'edit' ? '填写储存证质检信息' : '查看储存证的质检信息'
props.mode === 'edit' && canSaveAny.value ? '填写储存证质检信息' : '查看储存证的质检信息'
)
const codeFieldLabel = computed(() => {
@@ -184,6 +212,19 @@
const groups = computed(() => (typeKey.value ? STORAGE_CERT_QC_GROUPS[typeKey.value] || [] : []))
const canSaveAny = computed(
() => props.mode === 'edit' && editableKeySet.value.size > 0
)
function isFieldEditable(key: string) {
return props.mode === 'edit' && editableKeySet.value.has(key)
}
function openAcl(scope: StorageCertQcAclScope) {
aclScope.value = scope
aclVisible.value = true
}
function displayValue(key: string) {
const v = form[key]
if (v == null || String(v).trim() === '') return '—'
@@ -213,6 +254,9 @@
identity.motherName = detail.motherName || ''
identity.sampleTypeName = detail.sampleTypeName || props.sampleTypeName || ''
filledFlag.value = !!detail.filledFlag
canConfigureAcl.value = !!detail.canConfigureAcl
const keys = Array.isArray(detail.editableFieldKeys) ? detail.editableFieldKeys : []
editableKeySet.value = new Set(keys.map((k) => String(k)))
const resolved =
(detail.sampleTypeKey as StorageCertQcTypeKey | null) ||
resolveStorageCertQcTypeKey(identity.sampleTypeName)
@@ -234,6 +278,8 @@
} catch {
typeKey.value = resolveStorageCertQcTypeKey(props.sampleTypeName)
filledFlag.value = false
editableKeySet.value = new Set()
canConfigureAcl.value = false
resetFormKeys(typeKey.value, {})
identity.labFlowCode = ''
identity.sampleCode = code
@@ -249,6 +295,10 @@
ElMessage.warning('无法保存:样本类型不支持或缺少样本编码')
return
}
if (!canSaveAny.value) {
ElMessage.warning('当前无字段编辑权限')
return
}
const payload: Record<string, unknown> = {}
for (const group of groups.value) {
for (const field of group.fields) {
@@ -283,6 +333,21 @@
min-height: 120px;
}
.storage-cert-qc-dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
}
.footer-left,
.footer-right {
display: flex;
align-items: center;
gap: 8px;
}
.ctx-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -405,5 +470,15 @@
.form-col-span-3 {
grid-column: 1 / -1;
}
.storage-cert-qc-dialog__footer {
flex-direction: column;
align-items: stretch;
}
.footer-left,
.footer-right {
justify-content: flex-end;
}
}
</style>