diff --git a/backend/app/api/sample_storage_cert_qc.py b/backend/app/api/sample_storage_cert_qc.py index d16b1e9b..30b79452 100644 --- a/backend/app/api/sample_storage_cert_qc.py +++ b/backend/app/api/sample_storage_cert_qc.py @@ -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: diff --git a/backend/app/crud/sample_storage_cert_qc.py b/backend/app/crud/sample_storage_cert_qc.py index 3e63bdca..838796de 100644 --- a/backend/app/crud/sample_storage_cert_qc.py +++ b/backend/app/crud/sample_storage_cert_qc.py @@ -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 diff --git a/backend/app/crud/sample_storage_cert_qc_acl.py b/backend/app/crud/sample_storage_cert_qc_acl.py new file mode 100644 index 00000000..0ccffe3a --- /dev/null +++ b/backend/app/crud/sample_storage_cert_qc_acl.py @@ -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) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 7bce008d..aa533866 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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""" diff --git a/backend/app/services/sample_storage_cert_qc.py b/backend/app/services/sample_storage_cert_qc.py index ecb106ab..645e50a2 100644 --- a/backend/app/services/sample_storage_cert_qc.py +++ b/backend/app/services/sample_storage_cert_qc.py @@ -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, diff --git a/backend/tests/test_sample_storage_cert_qc.py b/backend/tests/test_sample_storage_cert_qc.py index 9bde6dfd..93e2819f 100644 --- a/backend/tests/test_sample_storage_cert_qc.py +++ b/backend/tests/test_sample_storage_cert_qc.py @@ -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() diff --git a/backend/tests/test_sample_storage_cert_qc_acl.py b/backend/tests/test_sample_storage_cert_qc_acl.py new file mode 100644 index 00000000..b602f526 --- /dev/null +++ b/backend/tests/test_sample_storage_cert_qc_acl.py @@ -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() diff --git a/docs/sql/migrations/2026-11-11-storage-cert-qc-field-acl.sql b/docs/sql/migrations/2026-11-11-storage-cert-qc-field-acl.sql new file mode 100644 index 00000000..26935b29 --- /dev/null +++ b/docs/sql/migrations/2026-11-11-storage-cert-qc-field-acl.sql @@ -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; diff --git a/openspec/changes/storage-cert-qc-field-acl/.openspec.yaml b/openspec/changes/storage-cert-qc-field-acl/.openspec.yaml new file mode 100644 index 00000000..2a7773d6 --- /dev/null +++ b/openspec/changes/storage-cert-qc-field-acl/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-11 diff --git a/openspec/changes/storage-cert-qc-field-acl/design.md b/openspec/changes/storage-cert-qc-field-acl/design.md new file mode 100644 index 00000000..78a7f125 --- /dev/null +++ b/openspec/changes/storage-cert-qc-field-acl/design.md @@ -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)。 diff --git a/openspec/changes/storage-cert-qc-field-acl/proposal.md b/openspec/changes/storage-cert-qc-field-acl/proposal.md new file mode 100644 index 00000000..a7db5376 --- /dev/null +++ b/openspec/changes/storage-cert-qc-field-acl/proposal.md @@ -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**:结构迁移脚本 +- **权限**:租户隔离;配置入口独立菜单码;写入不信任客户端字段权限声明 diff --git a/openspec/changes/storage-cert-qc-field-acl/specs/storage-cert-qc-field-acl/spec.md b/openspec/changes/storage-cert-qc-field-acl/specs/storage-cert-qc-field-acl/spec.md new file mode 100644 index 00000000..73276367 --- /dev/null +++ b/openspec/changes/storage-cert-qc-field-acl/specs/storage-cert-qc-field-acl/spec.md @@ -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 与菜单规则生效 diff --git a/openspec/changes/storage-cert-qc-field-acl/tasks.md b/openspec/changes/storage-cert-qc-field-acl/tasks.md new file mode 100644 index 00000000..e346e664 --- /dev/null +++ b/openspec/changes/storage-cert-qc-field-acl/tasks.md @@ -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、按钮主次、只读态) diff --git a/web-admin/src/api/sample-storage-cert-qc.ts b/web-admin/src/api/sample-storage-cert-qc.ts index ec11fe47..fb0ec61a 100644 --- a/web-admin/src/api/sample-storage-cert-qc.ts +++ b/web-admin/src/api/sample-storage-cert-qc.ts @@ -12,6 +12,20 @@ export type SampleStorageCertQcDetail = { filledFlag: boolean payload: Record prefill?: Record + 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 + }) +} diff --git a/web-admin/src/components/business/storage-cert-qc-acl-dialog/index.vue b/web-admin/src/components/business/storage-cert-qc-acl-dialog/index.vue new file mode 100644 index 00000000..1949342d --- /dev/null +++ b/web-admin/src/components/business/storage-cert-qc-acl-dialog/index.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/web-admin/src/components/business/storage-cert-qc-dialog/index.vue b/web-admin/src/components/business/storage-cert-qc-dialog/index.vue index 01a8ff75..7d03cdfb 100644 --- a/web-admin/src/components/business/storage-cert-qc-dialog/index.vue +++ b/web-admin/src/components/business/storage-cert-qc-dialog/index.vue @@ -54,7 +54,7 @@ 'form-col-span-3': field.span === 3 }" > -