Compare commits

..
Author SHA1 Message Date
zlwuandCursor a64dd563bd fix(sql): 储存证 ACL 配置按钮授权给全部角色
continuous-integration/drone/tag Build is passing
迁移脚本为各租户有效角色补齐 storage-cert-qc-acl 菜单,使质检/生产权限入口对全部角色可见。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 17:03:49 +08:00
zlwuandCursor 377e0981ab fix(web-admin): 储存证 ACL 配置人员支持姓名模糊检索
远程下拉改为用检索结果替换选项并保留已选,修复只追加导致输入姓名后列表不收缩的问题。质检/生产权限共用同一弹窗。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 17:01:28 +08:00
zlwuandCursor 4654678231 fix(cell-storage): 储存证 ACL 保存避免 collation 混用 503
ACL 表与 sys_user JOIN/条件比较统一 utf8mb4_unicode_ci,并补建表 COLLATE 与 CONVERT 修复脚本(#97)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:56:58 +08:00
zlwu a6d3f6f610 merge: #96 储存证 ACL 字段勾选全选/取消全选 2026-11-11 16:42:25 +08:00
zlwu 6cd5ced688 feat(web-admin): 储存证 ACL 字段勾选支持全选/取消全选
质检/生产权限弹窗「勾选可编辑字段」Tab 增加一键全选与取消全选。关联 #96。
2026-11-11 16:42:10 +08:00
zlwuandCursor 843f44929c fix(web-admin): 储存证 ACL 字段勾选改为两列布局
质检/生产权限弹窗「勾选可编辑字段」Tab 使用两列网格,窄屏回退单列。关联 #95。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:37:30 +08:00
zlwu 87a22880e8 merge: #93 储存证质检字段级质检/生产权限 2026-11-11 16:15:07 +08:00
zlwuandCursor 529f36eac6 feat(cell-storage): 储存证质检字段级质检/生产权限
按方案 A 增加租户级 ACL(字段+人员),详情返回可编辑字段,保存改为服务端 merge;弹窗 footer 提供权限配置入口。关联 #93。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:13:20 +08:00
zlwu 9dc6812393 merge: #94 客户服务管理列表性别显示修复 2026-11-11 15:49:36 +08:00
zlwuandCursor e482980c7d fix(web-admin): 客户服务管理列表性别按新生儿约定显示
本地 sexLabel 误将女映射为 2,导致 0 原样展示;改为复用 newbornSexLabel(1男/0女)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:48:37 +08:00
zlwu 900e957f25 merge: #91 脐带间充质储存证隐藏母血三项核酸 2026-11-11 15:15:43 +08:00
zlwuandCursor 15a20db725 fix(cell-storage): 脐带间充质储存证隐藏母血三项核酸
脐带间充质干细胞填写/查看储存证质检时不展示、不预填、不落库
HBV DNA / HCV RNA / HIV-1RNA;围产期造血干细胞保持原字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:15:01 +08:00
zlwuandCursor 3ac30a6c2c fix(web-admin): 恢复采集大表导出并按运费批次归拢合并
去掉临时隐藏开关,导出自存/科研表时先按运费批次聚拢再合并单元格。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 14:56:45 +08:00
zlwuandCursor 95d6b6025f fix(web-admin): 样本接收列表卡片协议附件与弹窗对齐
卡片改用完整附件分组,展示知情同意书/来源主附件及协议附件或其他附件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 14:54:16 +08:00
cykj d0eb97b167 fix(backend): 协议列表从 V2 样本缴费补齐已收金额 (#88)
fix(backend): 协议列表从 V2 样本缴费补齐已收金额 (#88)

Merged from PR #89.
2026-11-11 06:17:00 +00:00
26 changed files with 1685 additions and 91 deletions

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:
+13 -1
View File
@@ -9,7 +9,19 @@ def unicode_ci(col):
def eq_unicode(left, right):
"""跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。"""
"""跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。
列与列:两侧 collate。列与字面量:仅列侧 collate,避免连接 collation
(常见 utf8mb4_general_ci)与列 collation(utf8mb4_unicode_ci)混用。
"""
left_is_str = isinstance(left, str)
right_is_str = isinstance(right, str)
if left_is_str and right_is_str:
return left == right
if left_is_str:
return left == unicode_ci(right)
if right_is_str:
return unicode_ci(left) == right
return unicode_ci(left) == unicode_ci(right)
+34 -3
View File
@@ -15,12 +15,14 @@ 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,
)
from app.services.sample_storage_cert_qc_prefill import collect_prefill
@@ -44,7 +46,7 @@ def _row_to_dict(
"collectionProtocolId": row.collection_protocol_id or "",
"qcWorkOrderId": row.qc_work_order_id or "",
"filledFlag": str(row.filled_flag or "0") == "1",
"payload": loads_payload(row.payload),
"payload": sanitize_payload_for_type(loads_payload(row.payload), row.sample_type_key),
"createTime": row.create_time.isoformat(sep=" ", timespec="seconds") if row.create_time else "",
"updateTime": row.update_time.isoformat(sep=" ", timespec="seconds") if row.update_time else "",
}
@@ -165,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:
@@ -173,7 +176,6 @@ def upsert(
key = resolve_sample_type_key(type_name)
if not key:
raise ValueError("该样本类型不支持填写储存证质检信息")
clean = normalize_payload(payload)
now = datetime.now()
row = (
db.query(BizSampleStorageCertQc)
@@ -184,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(),
@@ -220,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,272 @@
"""储存证质检字段 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.db_collation import eq_unicode, unicode_ci
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(
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(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(
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(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(
eq_unicode(BizStorageCertQcAclUser.tenant_no, tenant_no),
unicode_ci(BizStorageCertQcAclUser.acl_id).in_(acl_ids),
eq_unicode(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(
eq_unicode(SysUser.tenant_no, tenant_no),
unicode_ci(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(
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(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,
eq_unicode(SysUser.id, BizStorageCertQcAclUser.user_id) & (SysUser.del_flag == 0),
)
.filter(
eq_unicode(BizStorageCertQcAclUser.tenant_no, tenant_no),
eq_unicode(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(
eq_unicode(BizStorageCertQcAcl.tenant_no, tenant_no),
eq_unicode(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"""
+134 -1
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,6 +84,13 @@ def supports_storage_cert_qc(sample_type_name: Optional[str]) -> bool:
return resolve_sample_type_key(sample_type_name) is not None
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]:
if not isinstance(payload, dict):
raise ValueError("payload 必须为对象")
@@ -38,6 +105,16 @@ def normalize_payload(payload: Any) -> Dict[str, Any]:
return out
def sanitize_payload_for_type(
payload: Dict[str, Any],
sample_type_key: Optional[str],
) -> Dict[str, Any]:
"""按样本类型去掉不应展示/落库的键。"""
if sample_type_key != SAMPLE_TYPE_KEY_UC_MSC:
return payload
return {key: value for key, value in payload.items() if key not in UC_MSC_EXCLUDED_PAYLOAD_KEYS}
def dumps_payload(payload: Dict[str, Any]) -> str:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
@@ -54,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,
@@ -168,6 +168,11 @@ _HSC_SEROLOGY_RULES = (
)
_MOTHER_SEROLOGY_RULES = tuple(rule for rule in _HSC_SEROLOGY_RULES if str(rule[0]).startswith("mother_"))
# 脐带间充质不预填母亲血液三项核酸。
_UC_MSC_EXCLUDED_MOTHER_KEYS = frozenset({"mother_hbv_dna", "mother_hcv_rna", "mother_hiv1_rna"})
_MOTHER_SEROLOGY_RULES_UC_MSC = tuple(
rule for rule in _MOTHER_SEROLOGY_RULES if str(rule[0]) not in _UC_MSC_EXCLUDED_MOTHER_KEYS
)
def _serology_from_tasks(
@@ -278,9 +283,19 @@ def _process_qual_from_tasks(tasks: List[Dict[str, Any]]) -> Dict[str, str]:
return out
def _fill_shared_mother(suggested: Dict[str, str], tasks: Optional[List[Dict[str, Any]]]) -> None:
def _fill_shared_mother(
suggested: Dict[str, str],
tasks: Optional[List[Dict[str, Any]]],
*,
sample_type_key: str,
) -> None:
"""只补母亲血液空项。共享任务里的脐血不参与。"""
shared = _serology_from_tasks(tasks or [], _MOTHER_SEROLOGY_RULES)
rules = (
_MOTHER_SEROLOGY_RULES_UC_MSC
if sample_type_key == "uc_msc"
else _MOTHER_SEROLOGY_RULES
)
shared = _serology_from_tasks(tasks or [], rules)
for key, value in shared.items():
suggested.setdefault(key, value)
@@ -330,7 +345,7 @@ def build_suggested(
if count:
suggested["available_cell_count_1e7"] = count
suggested.update(_process_qual_from_tasks(process_tasks or []))
suggested.update(_serology_from_tasks(gate_tasks or [], _MOTHER_SEROLOGY_RULES))
suggested.update(_serology_from_tasks(gate_tasks or [], _MOTHER_SEROLOGY_RULES_UC_MSC))
suggested.setdefault("bacteria_test", "阴性")
suggested.setdefault("result_judgment", "合格")
if freeze:
@@ -338,7 +353,7 @@ def build_suggested(
suggested.update(_FIXED_COMMON)
suggested.update(_FIXED_UC_MSC)
if sample_type_key in ("perinatal_hsc", "uc_msc"):
_fill_shared_mother(suggested, shared_mother_tasks)
_fill_shared_mother(suggested, shared_mother_tasks, sample_type_key=sample_type_key)
return {key: value for key, value in suggested.items() if str(value).strip()}
+63 -3
View File
@@ -8,8 +8,10 @@ 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,
supports_storage_cert_qc,
)
from app.services.sample_storage_cert_qc_prefill import (
@@ -51,6 +53,34 @@ class TestNormalizePayload(unittest.TestCase):
self.assertNotIn(1, out)
class TestSanitizePayloadForType(unittest.TestCase):
def test_uc_msc_drops_mother_nucleic_acids(self):
out = sanitize_payload_for_type(
{
"mother_hbsag": "阴性",
"mother_hbv_dna": "阴性",
"mother_hcv_rna": "阳性",
"mother_hiv1_rna": "阴性",
"mother_cmv_igm": "阴性",
},
SAMPLE_TYPE_KEY_UC_MSC,
)
self.assertEqual(out["mother_hbsag"], "阴性")
self.assertEqual(out["mother_cmv_igm"], "阴性")
self.assertNotIn("mother_hbv_dna", out)
self.assertNotIn("mother_hcv_rna", out)
self.assertNotIn("mother_hiv1_rna", out)
def test_perinatal_keeps_mother_nucleic_acids(self):
payload = {
"mother_hbv_dna": "阴性",
"mother_hcv_rna": "阴性",
"mother_hiv1_rna": "阴性",
}
out = sanitize_payload_for_type(payload, SAMPLE_TYPE_KEY_PERINATAL_HSC)
self.assertEqual(out, payload)
class TestEmptyDetail(unittest.TestCase):
def test_shape(self):
d = empty_detail(
@@ -302,13 +332,13 @@ class TestStorageCertPrefill(unittest.TestCase):
]
suggested = build_suggested(sample_type_key="uc_msc", shared_mother_tasks=shared)
self.assertEqual(suggested["mother_hbsag"], "阴性")
self.assertEqual(suggested["mother_hbv_dna"], "阴性")
self.assertNotIn("mother_hbv_dna", suggested)
self.assertEqual(suggested["mother_anti_hcv"], "阴性")
self.assertEqual(suggested["mother_hcv_rna"], "阴性")
self.assertNotIn("mother_hcv_rna", suggested)
self.assertEqual(suggested["mother_anti_tp"], "阴性")
self.assertEqual(suggested["mother_cmv_igm"], "阴性")
self.assertEqual(suggested["mother_anti_hiv"], "阴性")
self.assertEqual(suggested["mother_hiv1_rna"], "阴性")
self.assertNotIn("mother_hiv1_rna", suggested)
self.assertNotIn("hbsag", suggested)
def test_own_mother_blood_wins_and_shared_only_fills_gaps(self):
@@ -339,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,101 @@
"""储存证质检字段 ACL:合法字段与 scope。"""
import unittest
from unittest.mock import MagicMock
from sqlalchemy.dialects import mysql
from app.crud import sample_storage_cert_qc_acl as acl_svc
from app.models.models import BizStorageCertQcAcl, BizStorageCertQcAclUser, SysUser
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")
class TestAclUserJoinCollation(unittest.TestCase):
def test_detail_user_join_uses_unicode_collate(self):
"""JOIN sys_user 时统一 utf8mb4_unicode_ci,避免 1267 mix of collations。"""
acl = MagicMock()
acl.id = "acl-1"
acl.field_keys = "[]"
acl.configured_flag = "1"
captured = {}
class _Query:
def __init__(self, *args, **kwargs):
pass
def filter(self, *args, **kwargs):
captured.setdefault("filters", []).extend(args)
return self
def first(self):
return acl
def outerjoin(self, *args, **kwargs):
captured["join_on"] = args[1] if len(args) > 1 else None
return self
def order_by(self, *args, **kwargs):
return self
def all(self):
return []
db = MagicMock()
db.query.side_effect = lambda *args, **kwargs: _Query()
detail = acl_svc.get_acl_detail(db, "t1", "qc")
self.assertEqual(detail["scope"], "qc")
join_on = captured.get("join_on")
self.assertIsNotNone(join_on)
sql = str(
join_on.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True})
).lower()
self.assertIn("utf8mb4_unicode_ci", sql)
filter_sql = " ".join(
str(f.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True})).lower()
for f in captured.get("filters", [])
)
self.assertIn("utf8mb4_unicode_ci", filter_sql)
self.assertIn("sys_user", SysUser.__tablename__)
self.assertIn("biz_storage_cert_qc_acl_user", BizStorageCertQcAclUser.__tablename__)
self.assertEqual(BizStorageCertQcAcl.__tablename__, "biz_storage_cert_qc_acl")
def test_eq_unicode_literal_only_collates_column(self):
from app.core.db_collation import eq_unicode
expr = eq_unicode(BizStorageCertQcAcl.tenant_no, "t1")
sql = str(
expr.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True})
).lower()
self.assertIn("utf8mb4_unicode_ci", sql)
self.assertEqual(sql.count("utf8mb4_unicode_ci"), 1)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,14 @@
-- Issue #97:将储存证质检 ACL 表 collation 对齐库默认 utf8mb4_unicode_ci。
-- 修复与 sys_user 等表 JOIN 时 Illegal mix of collations。可重复执行。
ALTER TABLE biz_storage_cert_qc_acl
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE biz_storage_cert_qc_acl_user
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
SELECT table_name, table_collation
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('biz_storage_cert_qc_acl', 'biz_storage_cert_qc_acl_user')
ORDER BY table_name;
@@ -0,0 +1,108 @@
-- Issue #93:储存证质检信息字段级 ACL(质检/生产)。MySQL 5.7,可重复执行。
-- 菜单按钮「储存证质检字段权限配置」控制弹窗 footer 的「质检权限」「生产权限」入口;
-- 本脚本会将该按钮授权给当前租户下全部有效角色。
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 COLLATE=utf8mb4_unicode_ci;
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 COLLATE=utf8mb4_unicode_ci;
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
);
-- 给所有有效角色授予「质检权限 / 生产权限」配置按钮(同一菜单码)
INSERT INTO sys_role_menu (
id, role_id, menu_id, 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-role:', r.tenant_no, ':', r.id, ':', m.id)),
13, 1, '3'), 17, 1, '8'),
r.id,
m.id,
r.tenant_no,
NULL,
'migration: grant storage-cert-qc-acl to all roles',
'migration',
'',
NOW(),
'migration',
NOW(),
0
FROM sys_role AS r
JOIN sys_menu AS m
ON m.tenant_no = r.tenant_no
AND m.perms = 'cell-storage:sample-info:storage-cert-qc-acl'
AND m.del_flag = 0
AND m.status = '1'
WHERE r.del_flag = 0
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu AS existing
WHERE existing.role_id = r.id
AND existing.menu_id = m.id
AND existing.tenant_no = r.tenant_no
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;
SELECT r.tenant_no, r.role_name, m.perms, rm.id AS role_menu_id
FROM sys_role_menu AS rm
JOIN sys_role AS r ON r.id = rm.role_id AND r.tenant_no = rm.tenant_no AND r.del_flag = 0
JOIN sys_menu AS m ON m.id = rm.menu_id AND m.tenant_no = rm.tenant_no AND m.del_flag = 0
WHERE m.perms = 'cell-storage:sample-info:storage-cert-qc-acl'
AND rm.del_flag = 0
ORDER BY r.tenant_no, r.role_name;
@@ -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,308 @@
<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 class="field-toolbar">
<el-button @click="selectAllFields">
<el-icon><Select /></el-icon>
全选
</el-button>
<el-button @click="clearAllFields">
<el-icon><RefreshLeft /></el-icon>
取消全选
</el-button>
</div>
<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" class="field-check-grid">
<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, RefreshLeft, Select } 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())
})
const allFieldKeys = computed(() => {
const keys: string[] = []
const seen = new Set<string>()
for (const group of fieldGroups.value) {
for (const field of group.fields) {
if (seen.has(field.key)) continue
seen.add(field.key)
keys.push(field.key)
}
}
return keys
})
function selectAllFields() {
selectedKeys.value = [...allFieldKeys.value]
}
function clearAllFields() {
selectedKeys.value = []
}
function formatUserOption(row: {
id?: string
nickName?: string
userName?: string
userNo?: string
}) {
const id = String(row.id || '')
if (!id) return null
const name = String(row.nickName || row.userName || row.userNo || id)
const account = String(row.userName || row.userNo || '')
return { value: id, label: account ? `${name}(${account})` : name }
}
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 || '').trim() || undefined
})
const list = (res as any)?.list || []
const next: Array<{ label: string; value: string }> = []
const seen = new Set<string>()
for (const row of list) {
const opt = formatUserOption(row)
if (!opt || seen.has(opt.value)) continue
seen.add(opt.value)
next.push(opt)
}
// 已选人员不在本页结果中时保留,避免标签丢失且不影响远程模糊检索展示
for (const id of selectedUserIds.value) {
if (seen.has(id)) continue
const keep = userOptions.value.find((u) => u.value === id)
if (keep) {
seen.add(id)
next.unshift(keep)
}
}
userOptions.value = next
} catch {
// 检索失败时仍保留已选项,避免清空已配置人员标签
userOptions.value = userOptions.value.filter((u) =>
selectedUserIds.value.includes(u.value)
)
} 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 opt = formatUserOption({
id: u.id,
nickName: u.nickName,
userName: u.userName
})
if (opt) upsertUserOption(opt.value, opt.label)
}
// 预拉一批启用用户;真正按姓名/账号模糊检索在输入时由 remote-method 触发
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-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.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-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 16px;
width: 100%;
}
.field-check {
display: flex;
align-items: flex-start;
margin-right: 0;
margin-bottom: 0;
height: auto;
white-space: normal;
:deep(.el-checkbox__label) {
white-space: normal;
line-height: 1.4;
}
}
@media (max-width: 560px) {
.field-check-grid {
grid-template-columns: 1fr;
}
}
.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>
@@ -10,7 +10,8 @@ import {
buildFreightMergeRanges,
buildPresignLedgerRows,
buildResearchLedgerRows,
formatLedgerDateTime
formatLedgerDateTime,
groupFlatRowsByFreightBatch
} from './collection-ledger-export'
import type { ProtocolAuditFlatRow } from './protocol-audit-flatten'
@@ -194,6 +195,71 @@ describe('buildCellStorageLedgerRows', () => {
{ startRow: 2, endRow: 3, startCol: 54, endCol: 54 }
])
})
it('groups non-adjacent same freight batch before merging cells', () => {
const shared = {
protocol: { protocolStatus: 'sxz', remark: '', voidType: '', voidDesc: '' },
sampleCollectTime: '2025-09-11 18:13:00',
sampleTypeName: 'NK',
regionName: '常州',
hospitalName: '溧阳市人民医院',
freightTransportLabel: '专线车',
freightAmountLabel: '100'
}
const { rows, merges } = buildCellStorageLedgerRows([
{
...shared,
motherName: '甲',
sampleCode: 'S1',
labStorageCode: 'UBMC1',
freightId: 'batch-cell'
} as unknown as ProtocolAuditFlatRow,
{
...shared,
motherName: '丙',
sampleCode: 'S3',
labStorageCode: 'UBMC3',
freightId: 'other',
freightTransportLabel: '快递',
freightAmountLabel: '25'
} as unknown as ProtocolAuditFlatRow,
{
...shared,
motherName: '乙',
sampleCode: 'S2',
labStorageCode: 'UBMC2',
freightId: 'batch-cell'
} as unknown as ProtocolAuditFlatRow
])
assert.equal(rows[0][8], '甲')
assert.equal(rows[1][8], '乙')
assert.equal(rows[2][8], '丙')
assert.equal(rows[0][52], '专线车')
assert.equal(rows[0][53], '100')
assert.equal(rows[1][52], '')
assert.equal(rows[1][53], '')
assert.equal(rows[2][52], '快递')
assert.equal(rows[2][53], '25')
assert.deepEqual(merges, [
{ startRow: 2, endRow: 3, startCol: 53, endCol: 53 },
{ startRow: 2, endRow: 3, startCol: 54, endCol: 54 }
])
})
})
describe('groupFlatRowsByFreightBatch', () => {
it('keeps first-seen batch order and leaves empty freight rows in place', () => {
const arranged = groupFlatRowsByFreightBatch([
{ motherName: 'A', freightId: 'f1' },
{ motherName: 'B', freightId: '' },
{ motherName: 'C', freightId: 'f1' },
{ motherName: 'D', freightId: 'f2' }
] as unknown as ProtocolAuditFlatRow[])
assert.deepEqual(
arranged.map((row) => row.motherName),
['A', 'C', 'B', 'D']
)
})
})
describe('buildResearchLedgerRows', () => {
@@ -335,6 +401,50 @@ describe('buildResearchLedgerRows', () => {
{ startRow: 3, endRow: 4, startCol: 18, endCol: 18 }
])
})
it('groups non-adjacent same freight batch before merging research cells', () => {
const shared = {
protocol: { donationStatus: 'sxz', voidType: '', voidDesc: '', monthlyVerifyNo: '' },
sampleCollectTime: '2025-09-11 18:13:00',
sampleTypeName: 'NK',
regionName: '常州',
hospitalName: '溧阳市人民医院',
freightTransportLabel: '专线车',
freightAmountLabel: '100'
}
const { rows, merges } = buildResearchLedgerRows([
{
...shared,
motherName: '甲',
labStorageCode: 'A1',
freightId: 'batch-a'
} as unknown as ProtocolAuditFlatRow,
{
...shared,
motherName: '散',
labStorageCode: 'A9',
freightId: '',
freightTransportLabel: '',
freightAmountLabel: ''
} as unknown as ProtocolAuditFlatRow,
{
...shared,
motherName: '乙',
labStorageCode: 'A2',
freightId: 'batch-a'
} as unknown as ProtocolAuditFlatRow
])
assert.equal(rows[0][8], '甲')
assert.equal(rows[1][8], '乙')
assert.equal(rows[2][8], '散')
assert.equal(rows[0][16], '专线车')
assert.equal(rows[1][16], '')
assert.equal(rows[1][17], '')
assert.deepEqual(merges, [
{ startRow: 2, endRow: 3, startCol: 17, endCol: 17 },
{ startRow: 2, endRow: 3, startCol: 18, endCol: 18 }
])
})
})
describe('buildCustomerServiceLedgerRows', () => {
@@ -1,8 +1,5 @@
import type { ProtocolAuditFlatRow } from './protocol-audit-flatten'
/** 采集大表导出入口开关;验收未完成前隐藏按钮,改 true 即可恢复。 */
export const COLLECTION_LEDGER_EXPORT_ENABLED = false
type Row = Record<string, unknown>
type PayDetail = {
installmentNo?: number | null
@@ -341,6 +338,36 @@ function researchMaternalBloodText(value: unknown): string {
return text(value) ? text(value) : '无母血'
}
/**
* 将同一运费批次的行聚拢到一起(按首次出现顺序),无批次行保持相对位置。
* 模版要求同批次运输方式/运费合并单元格,故导出前先归拢再合并。
*/
export function groupFlatRowsByFreightBatch(
rows: ProtocolAuditFlatRow[]
): ProtocolAuditFlatRow[] {
const groups = new Map<string, ProtocolAuditFlatRow[]>()
const sequence: Array<{ kind: 'batch'; id: string } | { kind: 'single'; row: ProtocolAuditFlatRow }> =
[]
for (const row of rows) {
const freightId = text(row.freightId)
if (!freightId) {
sequence.push({ kind: 'single', row })
continue
}
if (!groups.has(freightId)) {
groups.set(freightId, [])
sequence.push({ kind: 'batch', id: freightId })
}
groups.get(freightId)!.push(row)
}
const arranged: ProtocolAuditFlatRow[] = []
for (const item of sequence) {
if (item.kind === 'single') arranged.push(item.row)
else arranged.push(...(groups.get(item.id) || []))
}
return arranged
}
/**
* 相邻且 freightId 相同的导出行,合并运输方式/运费列(Excel 行号含表头)。
* @param transportCol 运输方式列(1-based)
@@ -626,9 +653,10 @@ export function buildCellStorageLedgerRows(rows: ProtocolAuditFlatRow[]): {
rows: string[][]
merges: LedgerMergeRange[]
} {
const freightIds = rows.map((row) => text(row.freightId))
const built = rows.map((row, index) => storageLedgerRow(row, index, false))
// 自存表:运输方式=53,运费=54
const arranged = groupFlatRowsByFreightBatch(rows)
const freightIds = arranged.map((row) => text(row.freightId))
const built = arranged.map((row, index) => storageLedgerRow(row, index, false))
// 自存表:运输方式=53,运费=54(对齐模版《自存表》)
return { rows: built, merges: applyFreightMerges(built, freightIds, 53, 54) }
}
@@ -636,9 +664,10 @@ export function buildResearchLedgerRows(rows: ProtocolAuditFlatRow[]): {
rows: string[][]
merges: LedgerMergeRange[]
} {
const freightIds = rows.map((row) => text(row.freightId))
const built = rows.map((row, index) => storageLedgerRow(row, index, true))
// 科研表:运输方式=17,运费=18
const arranged = groupFlatRowsByFreightBatch(rows)
const freightIds = arranged.map((row) => text(row.freightId))
const built = arranged.map((row, index) => storageLedgerRow(row, index, true))
// 科研表:运输方式=17,运费=18(对齐模版《科研表》)
return { rows: built, merges: applyFreightMerges(built, freightIds, 17, 18) }
}
+13 -3
View File
@@ -33,7 +33,8 @@ export function supportsStorageCertQc(sampleTypeName?: string | null): boolean {
return resolveStorageCertQcTypeKey(sampleTypeName) != null
}
const MOTHER_BLOOD: StorageCertQcField[] = [
/** 围产期造血:母亲血液含三项核酸检测。 */
const MOTHER_BLOOD_PERINATAL: StorageCertQcField[] = [
{ key: 'mother_hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' },
{ key: 'mother_hbv_dna', label: '乙型肝炎病毒核酸(HBV DNA)', control: 'select_yny' },
{ key: 'mother_anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' },
@@ -48,6 +49,15 @@ const MOTHER_BLOOD: StorageCertQcField[] = [
}
]
/** 脐带间充质:不展示 HBV DNA / HCV RNA / HIV-1RNA。 */
const MOTHER_BLOOD_UC_MSC: StorageCertQcField[] = [
{ key: 'mother_hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' },
{ key: 'mother_anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' },
{ key: 'mother_anti_tp', label: '梅毒螺旋体特异性抗体(抗TP)', control: 'select_yny' },
{ key: 'mother_cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV IgM)', control: 'select_yny' },
{ key: 'mother_anti_hiv', label: '人类免疫缺陷病毒抗体(抗HIV-1/2)', control: 'select_yny' }
]
export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcGroup[]> = {
perinatal_hsc: [
{
@@ -77,7 +87,7 @@ export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcG
{ key: 'cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV-IgM)', control: 'select_yny' }
]
},
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD },
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD_PERINATAL },
{
title: '储存证收尾信息',
variant: 'meta',
@@ -120,7 +130,7 @@ export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcG
}
]
},
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD },
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD_UC_MSC },
{
title: '储存证收尾信息',
variant: 'meta',
@@ -75,12 +75,7 @@
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button
v-if="COLLECTION_LEDGER_EXPORT_ENABLED"
class="btn-ghost"
:loading="exporting"
@click="handleExport"
>
<el-button class="btn-ghost" :loading="exporting" @click="handleExport">
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
@@ -467,7 +462,6 @@
} from '@/utils/protocol-doc-label'
import { loadVoidTypeDict } from '@/utils/void-type-dict'
import {
COLLECTION_LEDGER_EXPORT_ENABLED,
RESEARCH_HEADERS,
buildResearchLedgerRows,
fetchAllPages,
@@ -79,12 +79,7 @@
<el-icon><Plus /></el-icon>
新增
</el-button>
<el-button
v-if="COLLECTION_LEDGER_EXPORT_ENABLED"
class="btn-ghost"
:loading="exporting"
@click="handleExport"
>
<el-button class="btn-ghost" :loading="exporting" @click="handleExport">
<el-icon><Download /></el-icon>
导出 Excel
</el-button>
@@ -435,7 +430,6 @@
} from '@/utils/status-tag-registry'
import { loadVoidTypeDict } from '@/utils/void-type-dict'
import {
COLLECTION_LEDGER_EXPORT_ENABLED,
CELL_STORAGE_HEADERS,
buildCellStorageLedgerRows,
fetchAllPages,
@@ -93,7 +93,7 @@
登记入库
</el-button>
<el-button
v-if="isCellStorage && COLLECTION_LEDGER_EXPORT_ENABLED"
v-if="isCellStorage"
class="btn-ghost"
:loading="exporting"
@click="handleExport"
@@ -243,7 +243,7 @@
<template #default="{ row }">{{ row.newbornIdCard || '—' }}</template>
</el-table-column>
<el-table-column prop="newbornSex" label="性别" min-width="70" align="center">
<template #default="{ row }">{{ sexLabel(row.newbornSex) }}</template>
<template #default="{ row }">{{ formatNewbornSex(row.newbornSex) }}</template>
</el-table-column>
<el-table-column prop="newbornDeliveryTime" label="分娩时间" min-width="160" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.newbornDeliveryTime || '—' }}</template>
@@ -510,6 +510,7 @@
donationStatusTagType,
handleDoneFlagLabel,
handleDoneFlagTagType,
newbornSexLabel,
protocolStatusDisplayLabel,
protocolStatusTagType,
sampleCollectionFlagLabel,
@@ -522,7 +523,6 @@
sampleStorageFlagTagType
} from '@/utils/status-tag-registry'
import {
COLLECTION_LEDGER_EXPORT_ENABLED,
CUSTOMER_SERVICE_HEADERS,
buildCustomerServiceLedgerRows,
fetchAllPages,
@@ -572,11 +572,11 @@
const codeCorrectionVisible = ref(false)
const codeCorrectionOldCode = ref('')
function sexLabel(sex: unknown) {
const s = String(sex || '').trim()
if (s === '1' || s === '男') return '男'
if (s === '2' || s === '女') return '女'
return s || '—'
/** 与协议表单一致:'1'=男,'0'=女;空值显示 —(勿与客户 sexLabel 0男1女混淆) */
function formatNewbornSex(sex: unknown) {
const s = sex == null || sex === '' ? '' : String(sex).trim()
if (!s) return '—'
return newbornSexLabel(s) || '—'
}
function openStorageCertQcEdit(row: Record<string, unknown>) {
@@ -70,21 +70,16 @@
<dt>样本类型</dt>
<dd>{{ row.sampleTypeName || '—' }}</dd>
</div>
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
<dt>{{ attachmentMeta(row).label }}</dt>
<div
v-for="group in protocolAttachmentGroups(row)"
:key="group.label"
class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment"
>
<dt>{{ group.label }}</dt>
<dd>
<ProtocolAttachmentMiniReadonly
:files="attachmentMeta(row).files"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</dd>
</div>
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
<dt>{{ consentAttachmentMeta(row).label }}</dt>
<dd>
<ProtocolAttachmentMiniReadonly
:files="consentAttachmentMeta(row).files"
:files="group.files"
empty-text="暂无附件"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
@@ -252,10 +247,7 @@
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
import {
getConsentProtocolAttachment,
getSourceProtocolAttachment
} from '@/utils/protocol-source-attachment'
import { getSourceProtocolAttachmentGroups } from '@/utils/protocol-source-attachment'
import { useAttachmentActions } from '@/composables/useAttachmentActions'
defineOptions({ name: 'SampleReceptionCardList' })
@@ -293,12 +285,8 @@
const { handleAttachmentPreview, handleAttachmentDownload } = useAttachmentActions()
function attachmentMeta(row: Record<string, unknown>) {
return getSourceProtocolAttachment(row)
}
function consentAttachmentMeta(row: Record<string, unknown>) {
return getConsentProtocolAttachment(row)
function protocolAttachmentGroups(row: Record<string, unknown>) {
return getSourceProtocolAttachmentGroups(row)
}
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {