diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 14af1ac4..fbc1f236 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -46,6 +46,7 @@ from app.api.customer_service_dict import router as customer_service_dict_router from app.api.sample_cert_insurance import router as sample_cert_insurance_router from app.api.sample_code_correction import router as sample_code_correction_router from app.api.lab_sample_process import router as lab_sample_process_router +from app.api.lab_sample_processing import router as lab_sample_processing_router from app.api.project_management import router as project_management_router @@ -98,4 +99,5 @@ __all__ = [ "customer_service_dict_router", "sample_cert_insurance_router", "sample_code_correction_router", + "lab_sample_processing_router", ] diff --git a/backend/app/api/lab_sample_processing.py b/backend/app/api/lab_sample_processing.py new file mode 100644 index 00000000..8026de72 --- /dev/null +++ b/backend/app/api/lab_sample_processing.py @@ -0,0 +1,193 @@ +"""实验室样本处理数据 API""" +from __future__ import annotations + +import csv +import io +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +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_strict_any_menu_code +from app.core.response import error_response, success_response +from app.models.models import SysUser +from app.services import lab_sample_processing as svc +from app.services.lab_sample_processing_fields import ( + DATASETS, + DATASET_LABELS, + PERM_LIST, + PERM_MANAGER, + PERM_MARKET_EDIT, + PERM_PRODUCTION_EDIT, + PERM_QC_EDIT, + schema_payload, +) + +router = APIRouter(prefix="/web/admin/lab-sample-processing", tags=["实验室样本处理数据"]) + +# 列表/导出:有列表、主管或任一部门编辑权即可查看全部字段 +READ_MENUS = ( + PERM_LIST, + PERM_MANAGER, + PERM_MARKET_EDIT, + PERM_PRODUCTION_EDIT, + PERM_QC_EDIT, +) +# 更新:至少要有主管或某一部门编辑权(字段级 ACL 在服务内再收紧) +WRITE_MENUS = ( + PERM_MANAGER, + PERM_MARKET_EDIT, + PERM_PRODUCTION_EDIT, + PERM_QC_EDIT, +) + + +def _tenant_no(db: Session, user: dict) -> str: + user_id = user.get("user_id") + if not user_id: + raise PermissionError("用户身份信息缺失") + row = db.query(SysUser).filter(SysUser.id == user_id, SysUser.del_flag == 0).first() + if not row or not row.tenant_no: + raise PermissionError("用户租户信息缺失") + return row.tenant_no + + +def _ensure_dataset(dataset: str) -> Optional[str]: + ds = (dataset or "").strip().lower() + return ds if ds in DATASETS else None + + +class UpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + payload: Dict[str, Any] = Field(default_factory=dict) + + +@router.get("/meta/{dataset}") +async def get_meta( + dataset: str, + user: dict = Depends(require_strict_any_menu_code(*READ_MENUS)), + db: Session = Depends(get_db), +): + ds = _ensure_dataset(dataset) + if not ds: + return error_response(400, "未知数据集") + acl = svc.user_acl(db, user) + return success_response( + { + "dataset": ds, + "label": DATASET_LABELS.get(ds), + "fields": schema_payload(ds), + "acl": acl, + } + ) + + +@router.get("/{dataset}/list") +async def list_dataset( + dataset: str, + keyword: Optional[str] = Query(None), + dateFrom: Optional[str] = Query(None), + dateTo: Optional[str] = Query(None), + page: int = Query(1, ge=1), + pageSize: int = Query(20, ge=1, le=200), + user: dict = Depends(require_strict_any_menu_code(*READ_MENUS)), + db: Session = Depends(get_db), +): + ds = _ensure_dataset(dataset) + if not ds: + return error_response(400, "未知数据集") + tenant_no = _tenant_no(db, user) + rows, total = svc.list_rows( + db, + tenant_no=tenant_no, + dataset=ds, + keyword=keyword, + date_from=dateFrom, + date_to=dateTo, + page=page, + page_size=pageSize, + ) + acl = svc.user_acl(db, user) + return success_response( + { + "list": rows, + "total": total, + "page": page, + "pageSize": pageSize, + "fields": schema_payload(ds), + "acl": acl, + } + ) + + +@router.put("/{dataset}/{sample_code}") +async def update_dataset_row( + dataset: str, + sample_code: str, + body: UpdateBody, + user: dict = Depends(require_strict_any_menu_code(*WRITE_MENUS)), + db: Session = Depends(get_db), +): + ds = _ensure_dataset(dataset) + if not ds: + return error_response(400, "未知数据集") + tenant_no = _tenant_no(db, user) + acl = svc.user_acl(db, user) + editable = set(acl.get("editableDepts") or []) + if not editable: + return error_response(403, "无编辑权限") + try: + row = svc.update_row( + db, + tenant_no=tenant_no, + dataset=ds, + sample_code=sample_code, + payload=body.payload or {}, + editable_depts=editable, + operator_id=str(user.get("user_id") or ""), + ) + except PermissionError as exc: + return error_response(403, str(exc)) + except LookupError as exc: + return error_response(404, str(exc)) + except ValueError as exc: + return error_response(400, str(exc)) + return success_response(row) + + +@router.get("/{dataset}/export") +async def export_dataset( + dataset: str, + keyword: Optional[str] = Query(None), + dateFrom: Optional[str] = Query(None), + dateTo: Optional[str] = Query(None), + user: dict = Depends(require_strict_any_menu_code(*READ_MENUS)), + db: Session = Depends(get_db), +): + ds = _ensure_dataset(dataset) + if not ds: + return error_response(400, "未知数据集") + tenant_no = _tenant_no(db, user) + headers, body = svc.export_rows( + db, + tenant_no=tenant_no, + dataset=ds, + keyword=keyword, + date_from=dateFrom, + date_to=dateTo, + ) + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(headers) + for row in body: + writer.writerow(["" if v is None else v for v in row]) + raw = ("\ufeff" + buf.getvalue()).encode("utf-8") + filename = f"lab-sample-{ds}.csv" + return StreamingResponse( + io.BytesIO(raw), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 05df3bf4..1eb60a4f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from app.core.database import SessionLocal from app.core.api_request_log_middleware import ApiRequestLogMiddleware from app.core.api_crypto_middleware import ApiCryptoMiddleware from app.models import models # noqa: F401 -from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router, sample_cert_insurance_router, sample_code_correction_router, lab_sample_process_router, project_management_router +from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router, sample_cert_insurance_router, sample_code_correction_router, lab_sample_process_router, lab_sample_processing_router, project_management_router @asynccontextmanager @@ -158,6 +158,7 @@ app.include_router(customer_service_dict_router, prefix=settings.API_V1_PREFIX) app.include_router(sample_cert_insurance_router, prefix=settings.API_V1_PREFIX) app.include_router(sample_code_correction_router, prefix=settings.API_V1_PREFIX) app.include_router(lab_sample_process_router, prefix=settings.API_V1_PREFIX) +app.include_router(lab_sample_processing_router, prefix=settings.API_V1_PREFIX) app.include_router(project_management_router, prefix=settings.API_V1_PREFIX) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 0a4abb7e..78a8e098 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1969,6 +1969,26 @@ class BizSampleReception(Base): del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") +class BizLabSampleProcessingExtra(Base): + """实验室样本处理数据补充字段 biz_lab_sample_processing_extra""" + + __tablename__ = "biz_lab_sample_processing_extra" + + id = Column(String(64), primary_key=True, comment="主键ID") + dataset = Column(String(16), nullable=False, comment="hsc|ubmc|msc") + sample_code = Column(String(32), nullable=False, comment="样本编码") + payload_json = Column(Text, nullable=True, comment="补充字段 JSON") + 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 BizSampleProductionReception(Base): """生产部门样本接收登记 biz_sample_production_reception""" diff --git a/backend/app/services/lab_sample_processing.py b/backend/app/services/lab_sample_processing.py new file mode 100644 index 00000000..d058d3c4 --- /dev/null +++ b/backend/app/services/lab_sample_processing.py @@ -0,0 +1,644 @@ +"""实验室样本处理数据 — 聚合读取、Excel 导入与部门字段回写。""" +from __future__ import annotations + +import json +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.models.models import ( + BizCellSampleType, + BizCollectionHospital, + BizProtocolCollectionInfo, + BizProtocolPartyCustomer, + BizProtocolSample, + BizSampleProductionReception, + BizSampleReception, +) +from app.services.lab_sample_processing_fields import ( + DATASET_HSC, + DATASET_MSC, + DATASET_UBMC, + DEPT_EDIT_PERMS, + PERM_LIST, + PERM_MANAGER, + SOURCE_EXTRA, + SOURCE_PRODUCTION, + SOURCE_PROTOCOL, + SOURCE_RECEPTION, + editable_depts_for_user, + field_map, + fields_for, + resolve_dataset_by_type_name, +) + + +def _new_id() -> str: + return uuid.uuid4().hex.upper() + + +def _fmt_dt(v: Any) -> Optional[str]: + if v is None: + return None + if isinstance(v, datetime): + return v.strftime("%Y-%m-%d %H:%M:%S") + return str(v) + + +def _fmt_num(v: Any) -> Any: + if v is None: + return None + if isinstance(v, Decimal): + return float(v) + return v + + +def _parse_extra(raw: Optional[str]) -> Dict[str, Any]: + if not raw: + return {} + try: + data = json.loads(raw) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def user_acl(db: Session, user: dict) -> Dict[str, Any]: + from app.core.menu_guard import user_has_menu_perm + + is_manager = user_has_menu_perm(db, user, PERM_MANAGER) + dept_flags = { + dept: user_has_menu_perm(db, user, code) for dept, code in DEPT_EDIT_PERMS.items() + } + editable = editable_depts_for_user(is_manager=is_manager, dept_flags=dept_flags) + return { + "canList": user_has_menu_perm(db, user, PERM_LIST) or is_manager or any(dept_flags.values()), + "isManager": is_manager, + "editableDepts": sorted(editable), + "deptFlags": dept_flags, + } + + +def _type_name_filters(dataset: str): + if dataset == DATASET_HSC: + return "%造血%" + if dataset == DATASET_MSC: + return "%间充质%" + if dataset == DATASET_UBMC: + return "%单个核%" + return None + + +def _code_prefix_filters(dataset: str) -> List[str]: + if dataset == DATASET_HSC: + return ["B%"] + if dataset == DATASET_MSC: + return ["BC%", "UC%"] + if dataset == DATASET_UBMC: + return ["UBMC%"] + return [] + + +def _match_keyword(row: Dict[str, Any], kw: str) -> bool: + if not kw: + return True + text = f"{row.get('sampleCode') or ''} {row.get('motherName') or ''}" + return kw.lower() in text.lower() + + +def _match_date_range(row: Dict[str, Any], date_from: Optional[str], date_to: Optional[str]) -> bool: + if not date_from and not date_to: + return True + for key in ("receptionTime", "collectTime", "cryoTime", "storageDateP2"): + val = row.get(key) + if not val: + continue + day = str(val)[:10].replace(".", "-").replace("/", "-") + # normalize compact dates like 2025-9-8 + parts = day.split("-") + if len(parts) == 3: + try: + day = f"{int(parts[0]):04d}-{int(parts[1]):02d}-{int(parts[2]):02d}" + except Exception: + pass + if date_from and day < date_from: + continue + if date_to and day > date_to: + continue + return True + return False + + +def list_rows( + db: Session, + *, + tenant_no: str, + dataset: str, + keyword: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + page: int = 1, + page_size: int = 20, +) -> Tuple[List[Dict[str, Any]], int]: + from app.models.models import BizLabSampleProcessingExtra + + type_like = _type_name_filters(dataset) + code_likes = _code_prefix_filters(dataset) + + q = ( + db.query( + BizProtocolSample, + BizCellSampleType, + BizProtocolPartyCustomer, + BizProtocolCollectionInfo, + BizCollectionHospital, + ) + .outerjoin( + BizCellSampleType, + (BizCellSampleType.id == BizProtocolSample.sample_type_id) + & (BizCellSampleType.del_flag == 0), + ) + .outerjoin( + BizProtocolPartyCustomer, + (BizProtocolPartyCustomer.collection_protocol_id == BizProtocolSample.collection_protocol_id) + & (BizProtocolPartyCustomer.tenant_no == tenant_no) + & (BizProtocolPartyCustomer.del_flag == 0), + ) + .outerjoin( + BizProtocolCollectionInfo, + (BizProtocolCollectionInfo.collection_protocol_id == BizProtocolSample.collection_protocol_id) + & (BizProtocolCollectionInfo.tenant_no == tenant_no) + & (BizProtocolCollectionInfo.del_flag == 0), + ) + .outerjoin( + BizCollectionHospital, + (BizCollectionHospital.id == BizProtocolCollectionInfo.hospital_id) + & (BizCollectionHospital.del_flag == 0), + ) + .filter( + BizProtocolSample.tenant_no == tenant_no, + BizProtocolSample.del_flag == 0, + BizProtocolSample.sample_code.isnot(None), + BizProtocolSample.sample_code != "", + ) + ) + + type_or_code = [] + if type_like: + type_or_code.append(BizCellSampleType.type_name.like(type_like)) + for pref in code_likes: + type_or_code.append(BizProtocolSample.sample_code.like(pref)) + if type_or_code: + q = q.filter(or_(*type_or_code)) + + rows = q.order_by(BizProtocolSample.create_time.desc()).all() + filtered = [] + for sample, stype, party, coll, hospital in rows: + type_name = (stype.type_name if stype else "") or "" + code = sample.sample_code or "" + if resolve_dataset_by_type_name(type_name, code) != dataset: + continue + filtered.append((sample, stype, party, coll, hospital)) + + extras = { + e.sample_code: e + for e in db.query(BizLabSampleProcessingExtra) + .filter( + BizLabSampleProcessingExtra.tenant_no == tenant_no, + BizLabSampleProcessingExtra.del_flag == 0, + BizLabSampleProcessingExtra.dataset == dataset, + ) + .all() + } + + codes = list({*(s.sample_code for s, *_ in filtered if s.sample_code), *extras.keys()}) + receptions: Dict[str, BizSampleReception] = {} + productions: Dict[str, BizSampleProductionReception] = {} + if codes: + for r in ( + db.query(BizSampleReception) + .filter( + BizSampleReception.tenant_no == tenant_no, + BizSampleReception.del_flag == 0, + BizSampleReception.sample_code.in_(codes), + ) + .order_by(BizSampleReception.reception_time.desc()) + .all() + ): + receptions.setdefault(r.sample_code, r) + if receptions: + rids = [r.id for r in receptions.values()] + for p in ( + db.query(BizSampleProductionReception) + .filter( + BizSampleProductionReception.tenant_no == tenant_no, + BizSampleProductionReception.del_flag == 0, + BizSampleProductionReception.reception_id.in_(rids), + ) + .order_by(BizSampleProductionReception.process_time.desc()) + .all() + ): + productions.setdefault(p.reception_id, p) + + built_map: Dict[str, Dict[str, Any]] = {} + for sample, stype, party, coll, hospital in filtered: + code = sample.sample_code + reception = receptions.get(code) + production = productions.get(reception.id) if reception else None + extra = _parse_extra(extras[code].payload_json if code in extras else None) + built_map[code] = _build_row( + dataset=dataset, + sample=sample, + type_name=(stype.type_name if stype else None), + party=party, + coll=coll, + hospital=hospital, + reception=reception, + production=production, + extra=extra, + ) + + # Excel/补充表独有样本也要展示 + for code, extra_row in extras.items(): + if code in built_map: + continue + extra = _parse_extra(extra_row.payload_json) + reception = receptions.get(code) + production = productions.get(reception.id) if reception else None + built_map[code] = _build_row( + dataset=dataset, + sample=None, + type_name=None, + party=None, + coll=None, + hospital=None, + reception=reception, + production=production, + extra=extra, + ) + + kw = (keyword or "").strip() + built = [] + for row in built_map.values(): + if not _match_keyword(row, kw): + continue + if not _match_date_range(row, date_from, date_to): + continue + built.append(row) + + # 稳定排序:优先按样本编码 + built.sort(key=lambda r: str(r.get("sampleCode") or "")) + + total = len(built) + page = max(1, int(page or 1)) + page_size = max(1, min(5000, int(page_size or 20))) + start = (page - 1) * page_size + return built[start : start + page_size], total + + +def _build_row( + *, + dataset: str, + sample: Optional[BizProtocolSample], + type_name: Optional[str], + party: Optional[BizProtocolPartyCustomer], + coll: Optional[BizProtocolCollectionInfo], + hospital: Optional[BizCollectionHospital], + reception: Optional[BizSampleReception], + production: Optional[BizSampleProductionReception], + extra: Dict[str, Any], +) -> Dict[str, Any]: + protocol_vals = { + "sample_code": sample.sample_code if sample else extra.get("sampleCode"), + "mother_name": party.mother_name if party else None, + "sample_collect_time": coll.sample_collect_time if coll else None, + "hospital_name": hospital.hospital_name if hospital else None, + } + reception_vals = {} + if reception: + reception_vals = { + "reception_time": reception.reception_time, + "receive_temperature": reception.receive_temperature, + "handler_name": reception.handler_name, + "remark": reception.remark, + "lab_storage_code": reception.lab_storage_code, + } + production_vals = {} + if production: + production_vals = { + "original_blood_weight_g": production.original_blood_weight_g, + "hes_volume_ml": production.hes_volume_ml, + "blood_type": production.blood_type, + "infectious_four_test_result": production.infectious_four_test_result, + "enriched_blood_weight_g": production.enriched_blood_weight_g, + "cryopreservation_volume_ml": production.cryopreservation_volume_ml, + "handler_name": production.handler_name, + "remark": production.remark, + "original_blood_volume_ml": production.original_blood_volume_ml, + "nucleated_cell_count_1e8": production.nucleated_cell_count_1e8, + "inoculation_amount": production.inoculation_amount, + "cryopreservation_time": production.cryopreservation_time, + "storage_total_tubes": production.storage_total_tubes, + "available_cell_count_1e7": production.available_cell_count_1e7, + } + + sample_code = ( + (sample.sample_code if sample else None) + or extra.get("sampleCode") + or "" + ) + data: Dict[str, Any] = { + "id": (sample.id if sample else f"extra:{dataset}:{sample_code}"), + "dataset": dataset, + "sampleCode": sample_code, + "sampleTypeName": type_name, + "protocolSampleId": sample.id if sample else None, + "collectionProtocolId": sample.collection_protocol_id if sample else None, + "receptionId": reception.id if reception else None, + "productionReceptionId": production.id if production else None, + "fromExtraOnly": sample is None, + } + for f in fields_for(dataset): + val = None + if f.source == SOURCE_PROTOCOL: + val = protocol_vals.get(f.attr) + elif f.source == SOURCE_RECEPTION: + val = reception_vals.get(f.attr) + elif f.source == SOURCE_PRODUCTION: + val = production_vals.get(f.attr) + elif f.source == SOURCE_EXTRA: + val = extra.get(f.key) + + # 导入/补充表优先:有值则覆盖源表,保证 Excel 录入可见 + if f.key in extra and extra.get(f.key) not in (None, ""): + val = extra.get(f.key) + + if isinstance(val, datetime): + val = _fmt_dt(val) + elif isinstance(val, Decimal): + val = _fmt_num(val) + data[f.key] = val + if not data.get("sampleCode"): + data["sampleCode"] = sample_code + return data + + +def upsert_extra_payload( + db: Session, + *, + tenant_no: str, + dataset: str, + sample_code: str, + payload: Dict[str, Any], + operator_id: str = "import", + replace: bool = False, +) -> None: + from app.models.models import BizLabSampleProcessingExtra + + code = (sample_code or "").strip() + if not code: + raise ValueError("样本编码不能为空") + now = datetime.now() + row = ( + db.query(BizLabSampleProcessingExtra) + .filter( + BizLabSampleProcessingExtra.tenant_no == tenant_no, + BizLabSampleProcessingExtra.dataset == dataset, + BizLabSampleProcessingExtra.sample_code == code, + BizLabSampleProcessingExtra.del_flag == 0, + ) + .first() + ) + current = {} if replace else _parse_extra(row.payload_json if row else None) + for k, v in (payload or {}).items(): + if v in (None, ""): + continue + current[k] = v + current["sampleCode"] = code + if row is None: + db.add( + BizLabSampleProcessingExtra( + id=_new_id(), + dataset=dataset, + sample_code=code, + payload_json=json.dumps(current, ensure_ascii=False), + tenant_no=tenant_no, + create_by=operator_id, + create_dept_id="", + create_time=now, + update_by=operator_id, + update_time=now, + del_flag=0, + ) + ) + else: + row.payload_json = json.dumps(current, ensure_ascii=False) + row.update_by = operator_id + row.update_time = now + + +def update_row( + db: Session, + *, + tenant_no: str, + dataset: str, + sample_code: str, + payload: Dict[str, Any], + editable_depts: set, + operator_id: str, +) -> Dict[str, Any]: + code = (sample_code or "").strip() + if not code: + raise ValueError("样本编码不能为空") + fmap = field_map(dataset) + if not fmap: + raise ValueError("未知数据集") + + denied = [] + accepted: Dict[str, Any] = {} + for key, value in (payload or {}).items(): + f = fmap.get(key) + if not f: + continue + if f.dept not in editable_depts: + denied.append(key) + continue + accepted[key] = value + if denied: + raise PermissionError(f"无权编辑字段: {', '.join(denied)}") + if not accepted: + raise ValueError("没有可保存的字段") + + sample = ( + db.query(BizProtocolSample) + .filter( + BizProtocolSample.tenant_no == tenant_no, + BizProtocolSample.sample_code == code, + BizProtocolSample.del_flag == 0, + ) + .first() + ) + reception = ( + db.query(BizSampleReception) + .filter( + BizSampleReception.tenant_no == tenant_no, + BizSampleReception.sample_code == code, + BizSampleReception.del_flag == 0, + ) + .order_by(BizSampleReception.reception_time.desc()) + .first() + ) + production = None + if reception: + production = ( + db.query(BizSampleProductionReception) + .filter( + BizSampleProductionReception.tenant_no == tenant_no, + BizSampleProductionReception.reception_id == reception.id, + BizSampleProductionReception.del_flag == 0, + ) + .order_by(BizSampleProductionReception.process_time.desc()) + .first() + ) + + party = None + coll = None + if sample: + party = ( + db.query(BizProtocolPartyCustomer) + .filter( + BizProtocolPartyCustomer.collection_protocol_id == sample.collection_protocol_id, + BizProtocolPartyCustomer.tenant_no == tenant_no, + BizProtocolPartyCustomer.del_flag == 0, + ) + .first() + ) + coll = ( + db.query(BizProtocolCollectionInfo) + .filter( + BizProtocolCollectionInfo.collection_protocol_id == sample.collection_protocol_id, + BizProtocolCollectionInfo.tenant_no == tenant_no, + BizProtocolCollectionInfo.del_flag == 0, + ) + .first() + ) + + now = datetime.now() + extra_payload: Dict[str, Any] = {} + + def _set_dt(obj, attr, value): + if value in (None, ""): + setattr(obj, attr, None) + return + if isinstance(value, datetime): + setattr(obj, attr, value) + return + text = str(value).strip().replace("/", "-").replace(".", "-") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + setattr(obj, attr, datetime.strptime(text, fmt)) + return + except ValueError: + continue + setattr(obj, attr, value) + + for key, value in accepted.items(): + f = fmap[key] + wrote = False + if f.source == SOURCE_PROTOCOL and sample is not None: + if f.attr == "mother_name" and party is not None: + party.mother_name = None if value in (None, "") else str(value) + party.update_by = operator_id + party.update_time = now + wrote = True + elif f.attr == "sample_collect_time" and coll is not None: + _set_dt(coll, "sample_collect_time", value) + coll.update_by = operator_id + coll.update_time = now + wrote = True + elif f.source == SOURCE_RECEPTION and reception is not None: + if f.attr == "reception_time": + _set_dt(reception, f.attr, value) + elif f.attr in {"receive_temperature", "handler_name", "remark", "lab_storage_code"}: + setattr(reception, f.attr, None if value in (None, "") else value) + reception.update_by = operator_id + reception.update_time = now + wrote = True + elif f.source == SOURCE_PRODUCTION and production is not None: + numeric_attrs = { + "original_blood_weight_g", + "hes_volume_ml", + "enriched_blood_weight_g", + "cryopreservation_volume_ml", + "original_blood_volume_ml", + "nucleated_cell_count_1e8", + "available_cell_count_1e7", + } + if f.attr == "cryopreservation_time": + _set_dt(production, f.attr, value) + elif f.attr == "storage_total_tubes": + production.storage_total_tubes = None if value in (None, "") else int(value) + elif f.attr in numeric_attrs: + production_val = None if value in (None, "") else Decimal(str(value)) + setattr(production, f.attr, production_val) + elif f.attr in { + "blood_type", + "infectious_four_test_result", + "handler_name", + "remark", + "inoculation_amount", + }: + setattr(production, f.attr, None if value in (None, "") else value) + production.update_by = operator_id + production.update_time = now + wrote = True + + # 补充表始终保留一份,保证导入台账与无权源表场景可编辑 + extra_payload[key] = value + _ = wrote + + upsert_extra_payload( + db, + tenant_no=tenant_no, + dataset=dataset, + sample_code=code, + payload=extra_payload, + operator_id=operator_id, + replace=False, + ) + db.commit() + + rows, _ = list_rows(db, tenant_no=tenant_no, dataset=dataset, keyword=code, page=1, page_size=20) + for row in rows: + if row.get("sampleCode") == code: + return row + raise LookupError("保存成功但读取失败") + + +def export_rows( + db: Session, + *, + tenant_no: str, + dataset: str, + keyword: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, +) -> Tuple[List[str], List[List[Any]]]: + rows, _ = list_rows( + db, + tenant_no=tenant_no, + dataset=dataset, + keyword=keyword, + date_from=date_from, + date_to=date_to, + page=1, + page_size=5000, + ) + headers = [f.label for f in fields_for(dataset)] + keys = [f.key for f in fields_for(dataset)] + body = [[row.get(k) for k in keys] for row in rows] + return headers, body diff --git a/backend/app/services/lab_sample_processing_fields.py b/backend/app/services/lab_sample_processing_fields.py new file mode 100644 index 00000000..691dc070 --- /dev/null +++ b/backend/app/services/lab_sample_processing_fields.py @@ -0,0 +1,176 @@ +"""实验室样本处理数据 — 字段目录与部门归属。""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +DATASET_HSC = "hsc" +DATASET_UBMC = "ubmc" +DATASET_MSC = "msc" +DATASETS = (DATASET_HSC, DATASET_UBMC, DATASET_MSC) + +DEPT_MARKET = "market" +DEPT_PRODUCTION = "production" +DEPT_QC = "qc" +DEPTS = (DEPT_MARKET, DEPT_PRODUCTION, DEPT_QC) + +PERM_LIST = "lab:sample-processing:list" +PERM_MARKET_EDIT = "lab:sample-processing:market:edit" +PERM_PRODUCTION_EDIT = "lab:sample-processing:production:edit" +PERM_QC_EDIT = "lab:sample-processing:qc:edit" +PERM_MANAGER = "lab:sample-processing:manager" + +DEPT_EDIT_PERMS = { + DEPT_MARKET: PERM_MARKET_EDIT, + DEPT_PRODUCTION: PERM_PRODUCTION_EDIT, + DEPT_QC: PERM_QC_EDIT, +} + +SOURCE_PROTOCOL = "protocol" +SOURCE_RECEPTION = "reception" +SOURCE_PRODUCTION = "production_reception" +SOURCE_EXTRA = "extra" + + +@dataclass(frozen=True) +class FieldDef: + key: str + label: str + dept: str + source: str + attr: Optional[str] = None # ORM attr on source object when applicable + + +def _f(key: str, label: str, dept: str, source: str, attr: Optional[str] = None) -> FieldDef: + return FieldDef(key=key, label=label, dept=dept, source=source, attr=attr or key) + + +HSC_FIELDS: Tuple[FieldDef, ...] = ( + _f("sampleCode", "细胞编码", DEPT_MARKET, SOURCE_PROTOCOL, "sample_code"), + _f("motherName", "准妈妈姓名", DEPT_MARKET, SOURCE_PROTOCOL, "mother_name"), + _f("collectTime", "采集时间", DEPT_MARKET, SOURCE_PROTOCOL, "sample_collect_time"), + _f("collectHospital", "采集医院", DEPT_MARKET, SOURCE_PROTOCOL, "hospital_name"), + _f("receptionTime", "接收时间", DEPT_PRODUCTION, SOURCE_RECEPTION, "reception_time"), + _f("receiveTemperature", "样品温度到达/℃", DEPT_PRODUCTION, SOURCE_RECEPTION, "receive_temperature"), + _f("receiveCheck", "收样检查", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("hasMaternalBlood", "是否有母血", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("remeasureMaternalBlood", "是否重采母血", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("hasConsent", "知情同意书或协议书", DEPT_MARKET, SOURCE_EXTRA), + _f("hasPackingList", "脐血运输装箱清单", DEPT_MARKET, SOURCE_EXTRA), + _f("hasReceiveConfirm", "脐带血接收确认单", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("hasHealthSurvey", "孕妇健康调查表", DEPT_MARKET, SOURCE_EXTRA), + _f("labelClear", "标识清晰", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("receptionHandler", "接收登记人", DEPT_PRODUCTION, SOURCE_RECEPTION, "handler_name"), + _f("receptionRemark", "备注", DEPT_PRODUCTION, SOURCE_RECEPTION, "remark"), + _f("originalBloodWeightG", "原血重量/g", DEPT_PRODUCTION, SOURCE_PRODUCTION, "original_blood_weight_g"), + _f("hesVolumeMl", "HES用量/ml", DEPT_PRODUCTION, SOURCE_PRODUCTION, "hes_volume_ml"), + _f("bloodType", "血型", DEPT_PRODUCTION, SOURCE_PRODUCTION, "blood_type"), + _f("infectiousFourCard", "传染四项检测(检测卡)", DEPT_QC, SOURCE_PRODUCTION, "infectious_four_test_result"), + _f("infectiousFourExternal", "传染四项检测(外检)", DEPT_QC, SOURCE_EXTRA), + _f("preEnrichTnc", "富集前有核细胞数/109/L", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("enrichedBloodWeightG", "富集后血量/g", DEPT_PRODUCTION, SOURCE_PRODUCTION, "enriched_blood_weight_g"), + _f("postEnrichTnc", "富集后有核细胞数/109/L", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("cryoVolumeMl", "冻存液用量/ml", DEPT_PRODUCTION, SOURCE_PRODUCTION, "cryopreservation_volume_ml"), + _f("cd34Count", "CD34+细胞计数/*10E4", DEPT_QC, SOURCE_EXTRA), + _f("operatorName", "操作人", DEPT_PRODUCTION, SOURCE_PRODUCTION, "handler_name"), + _f("processRemark", "备注", DEPT_PRODUCTION, SOURCE_PRODUCTION, "remark"), +) + +UBMC_FIELDS: Tuple[FieldDef, ...] = ( + _f("sampleCode", "细胞编码", DEPT_MARKET, SOURCE_PROTOCOL, "sample_code"), + _f("motherName", "准妈妈姓名", DEPT_MARKET, SOURCE_PROTOCOL, "mother_name"), + _f("labMaterialCode", "实验室物料编码", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("labStorageCode", "入库编码", DEPT_PRODUCTION, SOURCE_RECEPTION, "lab_storage_code"), + _f("storageStatus", "储存状态", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("discardReason", "废弃原因", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("receptionTime", "接收时间", DEPT_PRODUCTION, SOURCE_RECEPTION, "reception_time"), + _f("receiveTemperature", "样品温度到达/℃", DEPT_PRODUCTION, SOURCE_RECEPTION, "receive_temperature"), + _f("receiveCheck", "收样检查", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("receptionHandler", "接收登记人", DEPT_PRODUCTION, SOURCE_RECEPTION, "handler_name"), + _f("receptionRemark", "备注", DEPT_PRODUCTION, SOURCE_RECEPTION, "remark"), + _f("originalBloodVolumeMl", "原血体积/mL", DEPT_PRODUCTION, SOURCE_PRODUCTION, "original_blood_volume_ml"), + _f("nucleatedCellCount1e8", "有核细胞数/108", DEPT_PRODUCTION, SOURCE_PRODUCTION, "nucleated_cell_count_1e8"), + _f("preOpTnc", "操作前有核细胞数/109/L", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("infectiousFourCard", "传染四项检测(检测卡)", DEPT_QC, SOURCE_PRODUCTION, "infectious_four_test_result"), + _f("hbsag", "乙型肝炎病毒表面抗原(HBsAg)", DEPT_QC, SOURCE_EXTRA), + _f("antiHcv", "丙型肝炎病毒抗体(抗HCV)", DEPT_QC, SOURCE_EXTRA), + _f("antiTp", "梅毒螺旋体特异性抗体(抗TP)", DEPT_QC, SOURCE_EXTRA), + _f("antiHiv", "人类免疫缺陷病毒抗体(抗HIV-1/2)", DEPT_QC, SOURCE_EXTRA), + _f("cmvIgm", "巨细胞病毒IgM 抗体 (CMV-IgM)", DEPT_QC, SOURCE_EXTRA), + _f("operatorName", "操作人", DEPT_PRODUCTION, SOURCE_PRODUCTION, "handler_name"), + _f("processRemark", "备注", DEPT_PRODUCTION, SOURCE_PRODUCTION, "remark"), +) + +MSC_FIELDS: Tuple[FieldDef, ...] = ( + _f("sampleCode", "实验室流转编码", DEPT_MARKET, SOURCE_PROTOCOL, "sample_code"), + _f("motherName", "母亲姓名", DEPT_MARKET, SOURCE_PROTOCOL, "mother_name"), + _f("collectTime", "脐带采集时间", DEPT_MARKET, SOURCE_PROTOCOL, "sample_collect_time"), + _f("transportTempC", "运输温度/℃", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("receptionTime", "脐带接收时间", DEPT_PRODUCTION, SOURCE_RECEPTION, "reception_time"), + _f("receptionHandler", "接收登记人", DEPT_PRODUCTION, SOURCE_RECEPTION, "handler_name"), + _f("inoculationAmount", "接种量", DEPT_PRODUCTION, SOURCE_PRODUCTION, "inoculation_amount"), + _f("storageStatus", "储存状态", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("cryoTime", "冻存时间", DEPT_PRODUCTION, SOURCE_PRODUCTION, "cryopreservation_time"), + _f("storageTotalTubes", "储存总量(管)", DEPT_PRODUCTION, SOURCE_PRODUCTION, "storage_total_tubes"), + _f("availableCellCount1e7", "可提供细胞数量/*10E7", DEPT_PRODUCTION, SOURCE_PRODUCTION, "available_cell_count_1e7"), + _f("endotoxinTest", "内毒素检测", DEPT_QC, SOURCE_EXTRA), + _f("bacteriaTest", "细菌检测", DEPT_QC, SOURCE_EXTRA), + _f("fungusTest", "真菌检测", DEPT_QC, SOURCE_EXTRA), + _f("mycoplasmaTest", "支原体检测", DEPT_QC, SOURCE_EXTRA), + _f("operatorName", "操作人", DEPT_PRODUCTION, SOURCE_PRODUCTION, "handler_name"), + _f("motherHbsag", "乙型肝炎病毒表面抗原(HBsAg)", DEPT_QC, SOURCE_EXTRA), + _f("motherHbvDna", "乙型肝炎病毒核酸(HBV DNA)", DEPT_QC, SOURCE_EXTRA), + _f("motherAntiHcv", "丙型肝炎病毒抗体(抗HCV)", DEPT_QC, SOURCE_EXTRA), + _f("motherHcvRna", "丙型肝炎病毒核酸(HCV RNA)", DEPT_QC, SOURCE_EXTRA), + _f("motherAntiTp", "梅毒螺旋体特异性抗体(抗TP)", DEPT_QC, SOURCE_EXTRA), + _f("motherCmvIgm", "巨细胞病毒lgM 抗体 (CMV lgM)", DEPT_QC, SOURCE_EXTRA), + _f("motherAntiHiv", "人类免疫缺陷病毒抗体(抗HIV-1/2)", DEPT_QC, SOURCE_EXTRA), + _f("motherHiv1Rna", "人类免疫缺陷病毒核酸(1型) (HIV-1RNA)", DEPT_QC, SOURCE_EXTRA), + _f("storageDateP2", "储存日期(P2冻存日期)", DEPT_PRODUCTION, SOURCE_EXTRA), + _f("processRemark", "备注", DEPT_PRODUCTION, SOURCE_PRODUCTION, "remark"), +) + +FIELDS_BY_DATASET: Dict[str, Tuple[FieldDef, ...]] = { + DATASET_HSC: HSC_FIELDS, + DATASET_UBMC: UBMC_FIELDS, + DATASET_MSC: MSC_FIELDS, +} + +DATASET_LABELS = { + DATASET_HSC: "造血干细胞数据", + DATASET_UBMC: "单个核数据", + DATASET_MSC: "脐带间充质干细胞数据", +} + + +def fields_for(dataset: str) -> Tuple[FieldDef, ...]: + return FIELDS_BY_DATASET.get(dataset) or () + + +def field_map(dataset: str) -> Dict[str, FieldDef]: + return {f.key: f for f in fields_for(dataset)} + + +def editable_depts_for_user(*, is_manager: bool, dept_flags: Dict[str, bool]) -> set: + if is_manager: + return set(DEPTS) + return {d for d, ok in dept_flags.items() if ok} + + +def resolve_dataset_by_type_name(name: Optional[str], sample_code: Optional[str] = None) -> Optional[str]: + text = (name or "").strip() + code = (sample_code or "").strip().upper() + if "单个核" in text or "脐血单个核" in text or code.startswith("UBMC"): + return DATASET_UBMC + if "间充质" in text or code.startswith("BC") or code.startswith("UC"): + return DATASET_MSC + if "造血" in text or code.startswith("B"): + return DATASET_HSC + return None + + +def schema_payload(dataset: str) -> List[Dict[str, str]]: + return [ + {"key": f.key, "label": f.label, "dept": f.dept, "source": f.source} + for f in fields_for(dataset) + ] diff --git a/backend/scripts/import_lab_sample_processing_xlsx.py b/backend/scripts/import_lab_sample_processing_xlsx.py new file mode 100644 index 00000000..9c3766e2 --- /dev/null +++ b/backend/scripts/import_lab_sample_processing_xlsx.py @@ -0,0 +1,324 @@ +"""将会议 Excel 三张表导入实验室样本处理补充台账。 + +用法(工程/worktree 根或 backend 下): + ENV_FILE=.../backend/.env python backend/scripts/import_lab_sample_processing_xlsx.py --xlsx PATH +""" +from __future__ import annotations + +import argparse +import re +import sys +import zipfile +import xml.etree.ElementTree as ET +from pathlib import Path + +# 允许直接脚本运行 +ROOT = Path(__file__).resolve().parents[2] +BACKEND = ROOT / "backend" +if str(BACKEND) not in sys.path: + sys.path.insert(0, str(BACKEND)) + +from sqlalchemy.orm import Session # noqa: E402 + +from app.core.config import settings # noqa: E402 +from app.core.database import SessionLocal # noqa: E402 +from app.services.lab_sample_processing import upsert_extra_payload # noqa: E402 +from app.services.lab_sample_processing_fields import ( # noqa: E402 + DATASET_HSC, + DATASET_MSC, + DATASET_UBMC, + fields_for, +) + +NS = { + "m": "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", +} + +SHEET_DATASET = { + "造血干细胞数据": DATASET_HSC, + "单个核数据": DATASET_UBMC, + "脐带间充质干细胞数据": DATASET_MSC, +} + +LABEL_ALIASES = { + "细胞编码": "sampleCode", + "实验室流转编码": "sampleCode", + "准妈妈姓名": "motherName", + "母亲姓名": "motherName", + "采集时间": "collectTime", + "脐带采集时间": "collectTime", + "采集医院": "collectHospital", + "接收时间": "receptionTime", + "脐带接收时间": "receptionTime", + "样品温度到达/℃": "receiveTemperature", + "运输温度/℃": "transportTempC", + "运输温度\n/℃": "transportTempC", + "收样检查": "receiveCheck", + "是否有母血": "hasMaternalBlood", + "是否重采母血": "remeasureMaternalBlood", + "知情同意书或协议书": "hasConsent", + "脐血运输装箱清单": "hasPackingList", + "脐带血接收确认单": "hasReceiveConfirm", + "孕妇健康调查表": "hasHealthSurvey", + "标识清晰": "labelClear", + "接收登记人": "receptionHandler", + "原血重量/g": "originalBloodWeightG", + "HES用量/ml": "hesVolumeMl", + "血型": "bloodType", + "传染四项检测(检测卡)": "infectiousFourCard", + "传染四项检测(外检)": "infectiousFourExternal", + "富集前有核细胞数/109/L": "preEnrichTnc", + "富集后血量/g": "enrichedBloodWeightG", + "富集后有核细胞数/109/L": "postEnrichTnc", + "冻存液用量/ml": "cryoVolumeMl", + "CD34+细胞计数/*10E4": "cd34Count", + "操作人": "operatorName", + "实验室物料编码": "labMaterialCode", + "入库编码": "labStorageCode", + "储存状态": "storageStatus", + "废弃原因": "discardReason", + "原血体积/mL": "originalBloodVolumeMl", + "有核细胞数/108": "nucleatedCellCount1e8", + "操作前有核细胞数/109/L": "preOpTnc", + "乙型肝炎病毒表面抗原(HBsAg)": "hbsag", + "丙型肝炎病毒抗体(抗HCV)": "antiHcv", + "梅毒螺旋体特异性抗体(抗TP)": "antiTp", + "人类免疫缺陷病毒抗体(抗HIV-1/2)": "antiHiv", + "巨细胞病毒IgM 抗体 (CMV-IgM)": "cmvIgm", + "接种量": "inoculationAmount", + "冻存时间": "cryoTime", + "储存总量(管)": "storageTotalTubes", + "储存总量\n(管)": "storageTotalTubes", + "可提供细胞数量/*10E7": "availableCellCount1e7", + "内毒素检测": "endotoxinTest", + "细菌检测": "bacteriaTest", + "真菌检测": "fungusTest", + "支原体检测": "mycoplasmaTest", + "乙型肝炎病毒表面抗原(HBsAg):": "motherHbsag", + "乙型肝炎病毒核酸(HBV DNA)": "motherHbvDna", + "丙型肝炎病毒核酸(HCV RNA)": "motherHcvRna", + "巨细胞病毒lgM 抗体 (CMV lgM)": "motherCmvIgm", + "人类免疫缺陷病毒核酸(1型) (HIV-1RNA)": "motherHiv1Rna", + "储存日期\n(P2冻存日期)": "storageDateP2", + "储存日期(P2冻存日期)": "storageDateP2", +} + + +def _norm_label(s: str) -> str: + return re.sub(r"\s+", "", (s or "").replace("\r", "").strip()) + + +def _col_row(ref: str): + m = re.match(r"([A-Z]+)(\d+)", ref) + col = m.group(1) + row = int(m.group(2)) + n = 0 + for ch in col: + n = n * 26 + ord(ch) - 64 + return n, row + + +def read_sheet_matrix(z: zipfile.ZipFile, target: str): + ss = [] + if "xl/sharedStrings.xml" in z.namelist(): + root = ET.fromstring(z.read("xl/sharedStrings.xml")) + for si in root.findall("m:si", NS): + texts = [ + t.text or "" + for t in si.iter("{http://schemas.openxmlformats.org/spreadsheetml/2006/main}t") + ] + ss.append("".join(texts)) + root = ET.fromstring(z.read(target)) + rows = {} + for c in root.findall(".//m:sheetData/m:row/m:c", NS): + ref = c.attrib.get("r") + t = c.attrib.get("t") + v = c.find("m:v", NS) + if v is None or ref is None: + continue + val = v.text or "" + if t == "s": + try: + val = ss[int(val)] + except Exception: + pass + cn, rn = _col_row(ref) + rows.setdefault(rn, {})[cn] = val + if not rows: + return [] + max_r = max(rows) + max_c = max(max(cols) for cols in rows.values()) + matrix = [] + for rn in range(1, max_r + 1): + line = [str(rows.get(rn, {}).get(c, "") or "").strip() for c in range(1, max_c + 1)] + matrix.append(line) + return matrix + + +def build_header_map(header_rows: list, dataset: str): + """合并多行表头,按列映射到字段 key。""" + width = max(len(r) for r in header_rows) + merged = [""] * width + for r in header_rows: + for i in range(width): + cell = r[i] if i < len(r) else "" + if cell: + merged[i] = cell if not merged[i] else merged[i] + # MSC: 第二行填充分组子列 + if len(header_rows) >= 2: + for i in range(width): + top = header_rows[0][i] if i < len(header_rows[0]) else "" + sub = header_rows[1][i] if i < len(header_rows[1]) else "" + if sub: + merged[i] = sub + elif top and not merged[i]: + merged[i] = top + + label_to_key = {_norm_label(f.label): f.key for f in fields_for(dataset)} + for alias, key in LABEL_ALIASES.items(): + label_to_key[_norm_label(alias)] = key + + # 备注列:第一处 receptionRemark,第二处 processRemark + remark_keys = [] + col_map = {} + for idx, label in enumerate(merged): + n = _norm_label(label) + if not n or n in {"序号"}: + continue + if n == "备注": + remark_keys.append(idx) + continue + # 母血相关与单项同名字段在 MSC 中优先 mother* + key = label_to_key.get(n) + if not key and n.endswith(":"): + key = label_to_key.get(n[:-1]) + if key: + col_map[idx] = key + if remark_keys: + col_map[remark_keys[0]] = "receptionRemark" + if len(remark_keys) > 1: + col_map[remark_keys[1]] = "processRemark" + elif "processRemark" in {f.key for f in fields_for(dataset)} and dataset != DATASET_UBMC: + # 单备注时,造血第二业务备注也可能只有一列;保留 receptionRemark + pass + # UBMC 仅一列备注时落到 receptionRemark;有操作备注需求时同步 processRemark + return col_map, merged + + +def parse_workbook(path: Path): + out = {} + with zipfile.ZipFile(path) as z: + wb = ET.fromstring(z.read("xl/workbook.xml")) + sheets = [] + for sh in wb.findall("m:sheets/m:sheet", NS): + sheets.append( + ( + sh.attrib.get("name"), + sh.attrib.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"), + ) + ) + rels = ET.fromstring(z.read("xl/_rels/workbook.xml.rels")) + rid_to_target = {rel.attrib["Id"]: rel.attrib["Target"] for rel in rels} + for name, rid in sheets: + dataset = SHEET_DATASET.get(name) + if not dataset: + continue + target = rid_to_target[rid] + if not target.startswith("xl/"): + target = "xl/" + target + matrix = read_sheet_matrix(z, target) + if not matrix: + continue + # MSC 两行表头;其他一行(UBMC 可能首列分组) + if dataset == DATASET_MSC: + header_rows = matrix[:2] + data_rows = matrix[2:] + else: + header_rows = matrix[:1] + data_rows = matrix[1:] + col_map, merged = build_header_map(header_rows, dataset) + records = [] + for row in data_rows: + payload = {} + for idx, key in col_map.items(): + val = row[idx] if idx < len(row) else "" + if val != "": + payload[key] = val + code = str(payload.get("sampleCode") or "").strip() + if not code: + continue + # UBMC 同名母血检测字段:Excel 用的是非 mother 前缀,已映射 + # MSC 母血字段别名 + if dataset == DATASET_MSC: + if "antiHcv" in payload and "motherAntiHcv" not in payload: + payload["motherAntiHcv"] = payload.pop("antiHcv") + if "antiTp" in payload and "motherAntiTp" not in payload: + payload["motherAntiTp"] = payload.pop("antiTp") + if "antiHiv" in payload and "motherAntiHiv" not in payload: + payload["motherAntiHiv"] = payload.pop("antiHiv") + if "hbsag" in payload and "motherHbsag" not in payload: + payload["motherHbsag"] = payload.pop("hbsag") + payload["sampleCode"] = code + records.append(payload) + out[dataset] = {"headers": merged, "records": records, "col_map": col_map} + return out + + +def import_all(db: Session, tenant_no: str, parsed: dict) -> dict: + summary = {} + for dataset, pack in parsed.items(): + n = 0 + for payload in pack["records"]: + upsert_extra_payload( + db, + tenant_no=tenant_no, + dataset=dataset, + sample_code=payload["sampleCode"], + payload=payload, + operator_id="excel-import", + replace=True, + ) + n += 1 + summary[dataset] = n + db.commit() + return summary + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--xlsx", required=True) + parser.add_argument("--tenant", default=None) + args = parser.parse_args() + xlsx = Path(args.xlsx) + if not xlsx.exists(): + raise SystemExit(f"xlsx not found: {xlsx}") + + parsed = parse_workbook(xlsx) + if not parsed: + raise SystemExit("no sheets parsed") + + db = SessionLocal() + try: + tenant_no = args.tenant + if not tenant_no: + # 取库中任一有效租户 + from app.models.models import SysUser + + u = ( + db.query(SysUser) + .filter(SysUser.del_flag == 0, SysUser.status == "1", SysUser.tenant_no.isnot(None)) + .first() + ) + tenant_no = (u.tenant_no if u else None) or "zkxw" + summary = import_all(db, tenant_no, parsed) + print("tenant", tenant_no) + for ds, n in summary.items(): + print(f"imported {ds}={n}") + print("ok") + finally: + db.close() + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_lab_sample_processing.py b/backend/tests/test_lab_sample_processing.py new file mode 100644 index 00000000..49686bfc --- /dev/null +++ b/backend/tests/test_lab_sample_processing.py @@ -0,0 +1,53 @@ +import unittest + +from app.services.lab_sample_processing_fields import ( + DATASET_HSC, + DATASET_MSC, + DATASET_UBMC, + DEPT_MARKET, + DEPT_PRODUCTION, + DEPT_QC, + editable_depts_for_user, + field_map, + resolve_dataset_by_type_name, + schema_payload, +) + + +class LabSampleProcessingFieldsTest(unittest.TestCase): + def test_resolve_dataset(self): + self.assertEqual(resolve_dataset_by_type_name("围产期造血干细胞", "B0000001"), DATASET_HSC) + self.assertEqual(resolve_dataset_by_type_name("脐带间充质干细胞", "BC0000003"), DATASET_MSC) + self.assertEqual(resolve_dataset_by_type_name("", "UBMC2025001"), DATASET_UBMC) + self.assertEqual(resolve_dataset_by_type_name("", "UC2026001"), DATASET_MSC) + + def test_schema_not_empty(self): + self.assertGreater(len(schema_payload(DATASET_HSC)), 10) + self.assertGreater(len(schema_payload(DATASET_UBMC)), 10) + self.assertGreater(len(schema_payload(DATASET_MSC)), 10) + + def test_dept_acl(self): + self.assertEqual( + editable_depts_for_user( + is_manager=True, + dept_flags={DEPT_MARKET: False, DEPT_PRODUCTION: False, DEPT_QC: False}, + ), + {DEPT_MARKET, DEPT_PRODUCTION, DEPT_QC}, + ) + self.assertEqual( + editable_depts_for_user( + is_manager=False, + dept_flags={DEPT_MARKET: False, DEPT_PRODUCTION: True, DEPT_QC: False}, + ), + {DEPT_PRODUCTION}, + ) + + def test_hsc_market_and_qc_fields(self): + fmap = field_map(DATASET_HSC) + self.assertEqual(fmap["motherName"].dept, DEPT_MARKET) + self.assertEqual(fmap["cd34Count"].dept, DEPT_QC) + self.assertEqual(fmap["originalBloodWeightG"].dept, DEPT_PRODUCTION) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/agents/project.json b/docs/agents/project.json index 5b0fa98f..13aa51a3 100644 --- a/docs/agents/project.json +++ b/docs/agents/project.json @@ -79,10 +79,13 @@ "evidence": "用户确认既有 Gitea/Drone Tag 打包已完成,本次不复查或修改流水线。" }, "permissions": { - "push_task_branch": false, - "create_pr": false, - "write_issue": false, - "merge": false, + "push_task_branch": true, + "create_pr": true, + "write_issue": true, + "close_issue": true, + "merge": true, + "manage_claims": true, + "update_issue_labels": true, "push_tag": false, "deploy": false }, diff --git a/docs/sql/migrations/2026-11-12-lab-sample-processing-acl-fix.sql b/docs/sql/migrations/2026-11-12-lab-sample-processing-acl-fix.sql new file mode 100644 index 00000000..ceb65266 --- /dev/null +++ b/docs/sql/migrations/2026-11-12-lab-sample-processing-acl-fix.sql @@ -0,0 +1,12 @@ +-- 纠正实验室样本处理部门 ACL: +-- 「采集接受人」仅挂在「质检专员」职位上,不应再授予市场字段编辑权, +-- 否则质检人员经职位角色并集会同时可改市场+质检字段。 + +UPDATE sys_role_menu rm +JOIN sys_role r ON r.id = rm.role_id AND r.del_flag = 0 AND r.role_name = '采集接受人' +JOIN sys_menu m ON m.id = rm.menu_id AND m.del_flag = 0 + AND m.perms = 'lab:sample-processing:market:edit' +SET rm.del_flag = 1, + rm.update_by = 'migration', + rm.update_time = NOW() +WHERE rm.del_flag = 0; diff --git a/docs/sql/migrations/2026-11-12-lab-sample-processing-data.sql b/docs/sql/migrations/2026-11-12-lab-sample-processing-data.sql new file mode 100644 index 00000000..0c0f5a69 --- /dev/null +++ b/docs/sql/migrations/2026-11-12-lab-sample-processing-data.sql @@ -0,0 +1,120 @@ +-- 实验室样本处理数据:补充表 + 一级模块菜单/按钮权限(不自动授权角色) +-- MySQL 5.7,可重复执行 + +CREATE TABLE IF NOT EXISTS biz_lab_sample_processing_extra ( + id VARCHAR(64) NOT NULL, + dataset VARCHAR(16) NOT NULL COMMENT 'hsc|ubmc|msc', + sample_code VARCHAR(32) NOT NULL COMMENT '样本编码', + payload_json TEXT NULL COMMENT '补充字段 JSON', + 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_lab_sample_extra_tenant_ds_code (tenant_no, dataset, sample_code, del_flag) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 挂到与「质检」同一 Web 虚拟根下(复用 menu:quality 的 parent_id) +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 + MD5(CONCAT(quality.tenant_no, ':menu:lab-sample-processing')), + '实验室样本处理数据', quality.parent_id, 'M', '/lab-sample-processing', '/index/index', + 'menu:lab-sample-processing', 1, '1', '1', quality.tenant_no, + COALESCE(quality.order_num, 50) + 1, + '20261104 会议:造血/单个核/间充质台账', + 'migration', '', NOW(), 'migration', NOW(), 0 +FROM sys_menu quality +WHERE quality.perms = 'menu:quality' AND quality.del_flag = 0 AND quality.status = '1' + AND NOT EXISTS ( + SELECT 1 FROM sys_menu e + WHERE e.tenant_no = quality.tenant_no AND e.perms = 'menu:lab-sample-processing' AND e.del_flag = 0 + ); + +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 + MD5(CONCAT(p.tenant_no, ':lab:sample-processing:list')), + '造血干细胞数据', p.id, 'C', 'lab-sample-processing/hsc', + '/lab-sample-processing/hsc/index', 'lab:sample-processing:list', + 1, '1', '1', p.tenant_no, 10, '造血干细胞数据列表', + 'migration', '', NOW(), 'migration', NOW(), 0 +FROM sys_menu p +WHERE p.perms = 'menu:lab-sample-processing' AND p.del_flag = 0 AND p.status = '1' + AND NOT EXISTS ( + SELECT 1 FROM sys_menu e + WHERE e.tenant_no = p.tenant_no AND e.path = 'lab-sample-processing/hsc' AND e.del_flag = 0 + ); + +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 + MD5(CONCAT(p.tenant_no, ':lab:sample-processing:ubmc')), + '单个核数据', p.id, 'C', 'lab-sample-processing/ubmc', + '/lab-sample-processing/ubmc/index', 'lab:sample-processing:list', + 1, '1', '1', p.tenant_no, 20, '单个核数据列表', + 'migration', '', NOW(), 'migration', NOW(), 0 +FROM sys_menu p +WHERE p.perms = 'menu:lab-sample-processing' AND p.del_flag = 0 AND p.status = '1' + AND NOT EXISTS ( + SELECT 1 FROM sys_menu e + WHERE e.tenant_no = p.tenant_no AND e.path = 'lab-sample-processing/ubmc' AND e.del_flag = 0 + ); + +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 + MD5(CONCAT(p.tenant_no, ':lab:sample-processing:msc')), + '脐带间充质干细胞数据', p.id, 'C', 'lab-sample-processing/msc', + '/lab-sample-processing/msc/index', 'lab:sample-processing:list', + 1, '1', '1', p.tenant_no, 30, '脐带间充质干细胞数据列表', + 'migration', '', NOW(), 'migration', NOW(), 0 +FROM sys_menu p +WHERE p.perms = 'menu:lab-sample-processing' AND p.del_flag = 0 AND p.status = '1' + AND NOT EXISTS ( + SELECT 1 FROM sys_menu e + WHERE e.tenant_no = p.tenant_no AND e.path = 'lab-sample-processing/msc' AND e.del_flag = 0 + ); + +-- 编辑按钮挂在列表菜单下(三页共用同一 list 权限父节点:造血页) +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 + MD5(CONCAT(p.tenant_no, ':', btn.perms)), + btn.menu_name, p.id, 'F', '', NULL, btn.perms, + 1, '0', '1', p.tenant_no, btn.order_num, btn.remark, + 'migration', '', NOW(), 'migration', NOW(), 0 +FROM sys_menu p +JOIN ( + SELECT '市场字段编辑' AS menu_name, 'lab:sample-processing:market:edit' AS perms, 1 AS order_num, '仅可编辑市场部门字段' AS remark + UNION ALL SELECT '生产字段编辑', 'lab:sample-processing:production:edit', 2, '仅可编辑生产部门字段' + UNION ALL SELECT '质检字段编辑', 'lab:sample-processing:qc:edit', 3, '仅可编辑质检部门字段' + UNION ALL SELECT '部门主管', 'lab:sample-processing:manager', 4, '可编辑全部部门字段' +) btn +WHERE p.perms = 'lab:sample-processing:list' + AND p.path = 'lab-sample-processing/hsc' + AND p.del_flag = 0 AND p.status = '1' + AND NOT EXISTS ( + SELECT 1 FROM sys_menu e + WHERE e.tenant_no = p.tenant_no AND e.perms = btn.perms AND e.del_flag = 0 + ); diff --git a/openspec/changes/lab-sample-processing-data/.openspec.yaml b/openspec/changes/lab-sample-processing-data/.openspec.yaml new file mode 100644 index 00000000..55ab199b --- /dev/null +++ b/openspec/changes/lab-sample-processing-data/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-12 diff --git a/openspec/changes/lab-sample-processing-data/design.md b/openspec/changes/lab-sample-processing-data/design.md new file mode 100644 index 00000000..f190530a --- /dev/null +++ b/openspec/changes/lab-sample-processing-data/design.md @@ -0,0 +1,23 @@ +## Context + +附件三张表字段覆盖市场采集信息、实验室接收/处理与质检结果。系统内对应源主要为: + +- 市场:`biz_protocol_party_customer`、`biz_protocol_collection_info`、`biz_collection_hospital`、`biz_protocol_sample` +- 生产/接收:`biz_sample_reception`、`biz_sample_production_reception` +- 质检:生产接收传染四项、储存证质检 payload、检查项结果;缺口进补充表 + +## Decisions + +1. **数据集**:`hsc`(造血)、`ubmc`(单个核)、`msc`(脐带间充质),以样本类型名 / 编码前缀识别。 +2. **权限码**: + - 列表:`lab:sample-processing:list`(三子页共用读) + - 部门编辑:`lab:sample-processing:market:edit` / `production:edit` / `qc:edit` + - 主管:`lab:sample-processing:manager`(可编辑全部部门字段) +3. **读写**:列表聚合只读源表 + 补充 JSON;更新时按字段 `source` 回写源表或补充表,服务端强制部门 ACL。 +4. **UI**:P01 列表 + 筛选 + 导出;行内编辑弹窗按部门分区,无权字段只读。 +5. **Figma**:MCP 授权不可用,按现有 Fresh Mint 列表页实现;合入后用户做视觉验收。 + +## Risks + +- 历史 Excel 手工列与系统字段不完全一一对应,未映射列落补充表,避免阻断上线。 +- 菜单种子不自动授权角色,需管理员分配。 diff --git a/openspec/changes/lab-sample-processing-data/proposal.md b/openspec/changes/lab-sample-processing-data/proposal.md new file mode 100644 index 00000000..5446fcee --- /dev/null +++ b/openspec/changes/lab-sample-processing-data/proposal.md @@ -0,0 +1,27 @@ +## Why + +2026-11-04 需求会议要求按线上三张实验室样本处理表(造血干细胞 / 单个核 / 脐带间充质)在系统中单独建模块,按市场、生产、质检拆分可编辑字段,支持筛选与导出。现有业务数据分散在协议、样本接收、生产接收与质检等表,缺少统一台账视图。 + +## What Changes + +- 新增 Web 一级模块「实验室样本处理数据」,下挂三个子模块对应三张表。 +- 按样本编码从现有业务表聚合展示全字段;部门员工可读全部、只写本部门字段;主管可写全部部门字段。 +- 支持关键词/时间筛选与 Excel 导出。 +- 无法直接回写源表的字段写入补充表 `biz_lab_sample_processing_extra`。 + +## Capabilities + +### New Capabilities + +- `lab-sample-processing-data`: 实验室样本处理数据聚合台账、部门字段 ACL、筛选导出 + +### Modified Capabilities + +- (无) + +## Impact + +- **backend**:聚合服务、API、补充表、菜单权限 +- **web-admin**:三子模块列表页、编辑弹窗、导出 +- **docs/sql**:结构 + 菜单种子 +- **不改** mobile-h5 / mobile-oa-h5 diff --git a/openspec/changes/lab-sample-processing-data/specs/lab-sample-processing-data/spec.md b/openspec/changes/lab-sample-processing-data/specs/lab-sample-processing-data/spec.md new file mode 100644 index 00000000..3d48e271 --- /dev/null +++ b/openspec/changes/lab-sample-processing-data/specs/lab-sample-processing-data/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: 三子模块台账 + +系统 SHALL 提供「实验室样本处理数据」模块,包含造血干细胞、单个核、脐带间充质三个子模块;每个子模块按样本展示该表全部约定字段。 + +#### Scenario: 按数据集查询 + +- **WHEN** 用户打开造血干细胞子模块并具备列表权限 +- **THEN** 系统仅返回造血干细胞数据集样本行,并包含市场/生产/质检字段聚合结果 + +### Requirement: 部门字段编辑权限 + +系统 SHALL 允许具备列表权限的用户查看全部字段;普通员工仅可编辑本部门字段;具备主管权限的用户可编辑全部部门字段。服务端 MUST 拒绝无权字段写入。 + +#### Scenario: 生产员工改市场字段被拒 + +- **WHEN** 仅有生产编辑权限的用户提交市场部门字段变更 +- **THEN** 系统拒绝该请求且不持久化无权字段 + +#### Scenario: 主管可改全部 + +- **WHEN** 具备主管权限的用户提交跨部门字段变更 +- **THEN** 系统接受并回写对应数据源或补充表 + +### Requirement: 筛选与导出 + +系统 SHALL 支持按样本编码、姓名关键词及接收/采集时间范围筛选,并导出当前筛选结果为 Excel。 + +#### Scenario: 导出当前筛选 + +- **WHEN** 用户在筛选条件下点击导出 +- **THEN** 下载的表格列与当前子模块字段目录一致,行集与列表筛选一致 diff --git a/openspec/changes/lab-sample-processing-data/tasks.md b/openspec/changes/lab-sample-processing-data/tasks.md new file mode 100644 index 00000000..9bc73292 --- /dev/null +++ b/openspec/changes/lab-sample-processing-data/tasks.md @@ -0,0 +1,21 @@ +## 1. 规格与数据 + +- [x] 1.1 建立 OpenSpec 变更与字段/权限设计 +- [x] 1.2 定义三数据集字段目录与部门归属、回写源 + +## 2. 后端 + +- [x] 2.1 补充表模型与迁移 SQL、菜单种子 +- [x] 2.2 聚合列表 / 详情 / 更新 / 导出 API 与部门 ACL +- [x] 2.3 单元测试:数据集识别、字段 ACL、无权拒绝 + +## 3. 前端 + +- [x] 3.1 共享列表页组件(筛选、编辑、导出) +- [x] 3.2 三子模块路由与菜单码 +- [x] 3.3 API 客户端 + +## 4. 验证 + +- [x] 4.1 受影响单元测试 +- [ ] 4.2 字段一致性/菜单说明回写工单(凭据就绪后) diff --git a/web-admin/src/api/lab-sample-processing.ts b/web-admin/src/api/lab-sample-processing.ts new file mode 100644 index 00000000..77bc7eee --- /dev/null +++ b/web-admin/src/api/lab-sample-processing.ts @@ -0,0 +1,74 @@ +import request from '@/utils/http' + +export type LabSampleDataset = 'hsc' | 'ubmc' | 'msc' + +export interface LabSampleFieldMeta { + key: string + label: string + dept: 'market' | 'production' | 'qc' + source: string +} + +export interface LabSampleAcl { + canList?: boolean + isManager?: boolean + editableDepts?: string[] + deptFlags?: Record +} + +export function fetchLabSampleMeta(dataset: LabSampleDataset) { + return request.get<{ + dataset: LabSampleDataset + label: string + fields: LabSampleFieldMeta[] + acl: LabSampleAcl + }>({ + url: `/web/admin/lab-sample-processing/meta/${dataset}` + }) +} + +export function fetchLabSampleList( + dataset: LabSampleDataset, + params: { + keyword?: string + dateFrom?: string + dateTo?: string + page?: number + pageSize?: number + } +) { + return request.get<{ + list: Record[] + total: number + page: number + pageSize: number + fields: LabSampleFieldMeta[] + acl: LabSampleAcl + }>({ + url: `/web/admin/lab-sample-processing/${dataset}/list`, + params + }) +} + +export function updateLabSampleRow( + dataset: LabSampleDataset, + sampleCode: string, + payload: Record +) { + return request.put>({ + url: `/web/admin/lab-sample-processing/${dataset}/${encodeURIComponent(sampleCode)}`, + data: { payload } + }) +} + +export function exportLabSampleUrl( + dataset: LabSampleDataset, + params: { keyword?: string; dateFrom?: string; dateTo?: string } +) { + const q = new URLSearchParams() + if (params.keyword) q.set('keyword', params.keyword) + if (params.dateFrom) q.set('dateFrom', params.dateFrom) + if (params.dateTo) q.set('dateTo', params.dateTo) + const qs = q.toString() + return `/api/web/admin/lab-sample-processing/${dataset}/export${qs ? `?${qs}` : ''}` +} diff --git a/web-admin/src/router/modules/sqxp.ts b/web-admin/src/router/modules/sqxp.ts index 32331f8e..aafee319 100644 --- a/web-admin/src/router/modules/sqxp.ts +++ b/web-admin/src/router/modules/sqxp.ts @@ -1313,6 +1313,59 @@ export const sqxpAdminRoutes: AppRouteRecord[] = [ ] }, + // ═══════════════════════════════════════════════════════════════════════════ + // 3b. 实验室样本处理数据 (menu:lab-sample-processing) + // ═══════════════════════════════════════════════════════════════════════════ + { + id: 'TOP0000000000000000000000000000L1', + name: 'LabSampleProcessing', + path: '/lab-sample-processing', + component: '/index/index', + meta: { + title: '实验室样本处理数据', + icon: 'ri:test-tube-line', + keepAlive: false, + isTopMenu: true, + menuCode: 'menu:lab-sample-processing', + roles: ['admin'] + }, + children: [ + { + id: 'LAB00000000000000000000000000001', + name: 'LabSampleProcessingHsc', + path: 'hsc', + component: '/lab-sample-processing/hsc/index', + meta: { + title: '造血干细胞数据', + keepAlive: true, + menuCode: 'lab:sample-processing:list' + } + }, + { + id: 'LAB00000000000000000000000000002', + name: 'LabSampleProcessingUbmc', + path: 'ubmc', + component: '/lab-sample-processing/ubmc/index', + meta: { + title: '单个核数据', + keepAlive: true, + menuCode: 'lab:sample-processing:list' + } + }, + { + id: 'LAB00000000000000000000000000003', + name: 'LabSampleProcessingMsc', + path: 'msc', + component: '/lab-sample-processing/msc/index', + meta: { + title: '脐带间充质干细胞数据', + keepAlive: true, + menuCode: 'lab:sample-processing:list' + } + } + ] + }, + // ═══════════════════════════════════════════════════════════════════════════ // 4. 运营支撑 (menu:operations) // 二级菜单:系统基础信息、运营支撑 diff --git a/web-admin/src/views/lab-sample-processing/LabSampleProcessingPage.vue b/web-admin/src/views/lab-sample-processing/LabSampleProcessingPage.vue new file mode 100644 index 00000000..9a63d259 --- /dev/null +++ b/web-admin/src/views/lab-sample-processing/LabSampleProcessingPage.vue @@ -0,0 +1,329 @@ + + + + + + diff --git a/web-admin/src/views/lab-sample-processing/hsc/index.vue b/web-admin/src/views/lab-sample-processing/hsc/index.vue new file mode 100644 index 00000000..5cb14013 --- /dev/null +++ b/web-admin/src/views/lab-sample-processing/hsc/index.vue @@ -0,0 +1,7 @@ + + + diff --git a/web-admin/src/views/lab-sample-processing/msc/index.vue b/web-admin/src/views/lab-sample-processing/msc/index.vue new file mode 100644 index 00000000..3ebcdc46 --- /dev/null +++ b/web-admin/src/views/lab-sample-processing/msc/index.vue @@ -0,0 +1,7 @@ + + + diff --git a/web-admin/src/views/lab-sample-processing/ubmc/index.vue b/web-admin/src/views/lab-sample-processing/ubmc/index.vue new file mode 100644 index 00000000..7b205b79 --- /dev/null +++ b/web-admin/src/views/lab-sample-processing/ubmc/index.vue @@ -0,0 +1,7 @@ + + +