Files
zkxw/docs/superpowers/plans/2026-05-19-unified-sample-reception.md
T
2026-05-19 23:12:56 +08:00

43 KiB
Raw Blame History

统一样本接收(支持科研捐赠来源)实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在现有样本接收模块中增加对科研捐赠来源样本的接收支持,统一入口、类型区分、展示完整运输信息、接收后触发质检工单。

Architecture: 在 biz_sample_reception 表新增 reception_source / donation_sample_id / research_donation_id 三个字段,后端 CRUD 新增捐赠样本查询路径并合并结果,前端列表增加来源类型列和运输信息列。

Tech Stack: Python 3.9 / FastAPI / SQLAlchemy / MySQL 5.7 / Vue 3 / Element Plus / TypeScript

Spec: docs/superpowers/specs/2026-05-19-unified-sample-reception-design.md


File Structure

操作 文件路径 职责
新建 docs/sql/sample_reception_add_donation_source.sql DDL 迁移脚本
修改 backend/app/models/models.py BizSampleReception 新增 3 字段、BizQcWorkOrder 新增 reception_source
修改 backend/app/crud/sample_reception.py 新增捐赠查询函数 + 修改 pending/received/create
修改 backend/app/crud/qc_work_order.py 适配捐赠来源的质检工单创建
修改 backend/app/api/sample_reception.py 修改 schema + 路由参数
修改 web-admin/src/api/sample-reception.ts 参数扩展
修改 web-admin/src/views/quality-inspection/sample-reception/index.vue 搜索/列表/弹窗 UI 变更

Task 1: SQL 迁移脚本

Files:

  • Create: docs/sql/sample_reception_add_donation_source.sql

  • Step 1: 创建迁移 SQL 文件

-- docs/sql/sample_reception_add_donation_source.sql
-- 统一样本接收:增加科研捐赠来源支持

-- 1) biz_sample_reception 新增来源字段
ALTER TABLE biz_sample_reception
  ADD COLUMN reception_source VARCHAR(20) NOT NULL DEFAULT 'cell_storage'
    COMMENT '来源类型 cell_storage细胞存储 research_donation科研捐赠'
    AFTER receive_attachments,
  ADD COLUMN donation_sample_id VARCHAR(64) NULL
    COMMENT '捐赠样本ID(biz_research_donation_sample.id, 仅科研捐赠来源)'
    AFTER reception_source,
  ADD COLUMN research_donation_id VARCHAR(64) NULL
    COMMENT '科研捐赠单ID(biz_research_donation.id, 仅科研捐赠来源)'
    AFTER donation_sample_id;

-- 2) biz_qc_work_order 新增来源类型(工单需要知道关联的是哪种协议)
ALTER TABLE biz_qc_work_order
  ADD COLUMN reception_source VARCHAR(20) NOT NULL DEFAULT 'cell_storage'
    COMMENT '来源类型 cell_storage/research_donation'
    AFTER sample_code;
  • Step 2: 在数据库执行该 SQL

Run: mysql -u root -p zkxw < docs/sql/sample_reception_add_donation_source.sql Expected: 执行成功,无错误

  • Step 3: Commit
git add docs/sql/sample_reception_add_donation_source.sql
git commit -m "feat(sql): add donation source fields to sample reception and qc work order tables"

Task 2: 后端 Model 变更

Files:

  • Modify: backend/app/models/models.py — BizSampleReception 类(约 line 1027-1058)和 BizQcWorkOrder 类(约 line 1136-1162)

  • Step 1: BizSampleReception 新增 3 个字段

在 BizSampleReception 类中 receive_attachments 字段之后、tenant_no 字段之前,新增:

    reception_source = Column(String(20), nullable=False, default="cell_storage", comment="来源类型 cell_storage细胞存储 research_donation科研捐赠")
    donation_sample_id = Column(String(64), nullable=True, comment="捐赠样本ID(仅科研捐赠)")
    research_donation_id = Column(String(64), nullable=True, comment="科研捐赠单ID(仅科研捐赠)")
  • Step 2: BizQcWorkOrder 新增 reception_source 字段

在 BizQcWorkOrder 类中 sample_code 字段之后、work_order_status 字段之前,新增:

    reception_source = Column(String(20), nullable=False, default="cell_storage", comment="来源类型 cell_storage/research_donation")
  • Step 3: Commit
git add backend/app/models/models.py
git commit -m "feat(model): add donation source fields to BizSampleReception and BizQcWorkOrder"

Task 3: 后端 CRUD — 捐赠样本查询函数

Files:

  • Modify: backend/app/crud/sample_reception.py — 新增函数(文件顶部 imports + 新增函数体)

  • Step 1: 新增捐赠相关 import

在文件顶部 import 区域(约 line 13-22),在现有 import 块中追加:

from app.models.research_donation import (
    BizResearchDonation,
    BizResearchDonationParty,
    BizResearchDonationSample,
    BizResearchDonationSampleTransport,
)
  • Step 2: 新增 _donation_sample_detail_rows_query 函数

在 _sample_detail_rows_query 函数(约 line 91)之后新增:

def _donation_sample_detail_rows_query(db: Session):
    """捐赠样本 + 捐赠单 + 当事人 + 运输(四表 JOIN)。"""
    return (
        db.query(
            BizResearchDonationSample,
            BizResearchDonation,
            BizResearchDonationParty,
            BizResearchDonationSampleTransport,
        )
        .select_from(BizResearchDonationSample)
        .join(
            BizResearchDonation,
            and_(
                BizResearchDonation.id == BizResearchDonationSample.research_donation_id,
                BizResearchDonation.del_flag == 0,
            ),
        )
        .outerjoin(
            BizResearchDonationParty,
            and_(
                BizResearchDonationParty.research_donation_id == BizResearchDonation.id,
                BizResearchDonationParty.del_flag == 0,
            ),
        )
        .outerjoin(
            BizResearchDonationSampleTransport,
            and_(
                BizResearchDonationSampleTransport.sample_id == BizResearchDonationSample.id,
                BizResearchDonationSampleTransport.del_flag == 0,
            ),
        )
    )
  • Step 3: 新增 _donation_sample_pending_query 函数

紧接上文新增:

def _donation_sample_pending_query(db: Session, tenant_no: str, keyword: Optional[str] = None):
    """待接收:捐赠样本且尚无有效接收记录。"""
    received_donation_ids = (
        db.query(BizSampleReception.donation_sample_id)
        .filter(
            BizSampleReception.tenant_no == tenant_no,
            BizSampleReception.del_flag == 0,
            BizSampleReception.reception_source == "research_donation",
        )
        .subquery()
    )
    q = _donation_sample_detail_rows_query(db).filter(
        BizResearchDonationSample.del_flag == 0,
        BizResearchDonationSample.tenant_no == tenant_no,
        BizResearchDonation.tenant_no == tenant_no,
        BizResearchDonation.donation_status.in_(["sxz", "ygq"]),
        ~BizResearchDonationSample.id.in_(received_donation_ids),
    )
    if keyword and keyword.strip():
        kw = f"%{keyword.strip()}%"
        q = q.filter(
            or_(
                BizResearchDonation.donation_no.like(kw),
                BizResearchDonationSample.sample_code.like(kw),
                BizResearchDonationParty.mother_name.like(kw),
                BizResearchDonationParty.newborn_name.like(kw),
            )
        )
    return q
  • Step 4: 新增 _donation_rows_to_sample_list_items 函数

紧接上文新增:

def _donation_rows_to_sample_list_items(
    db: Session,
    rows: list,
    tenant_no: str,
    *,
    reception_map: Optional[Dict[str, BizSampleReception]] = None,
) -> List[Dict[str, Any]]:
    """将捐赠样本行转为统一列表项字典(字段名与细胞存储保持一致)。"""
    type_ids = list({s.sample_type_id for s, *_ in rows if s.sample_type_id})
    type_map: Dict[str, str] = {}
    if type_ids:
        for t in (
            db.query(BizCellSampleType)
            .filter(
                BizCellSampleType.id.in_(type_ids),
                BizCellSampleType.del_flag == 0,
            )
            .all()
        ):
            type_map[t.id] = t.type_name or ""

    express_ids = list(
        {tr.express_company_id for *_, tr in rows if tr and tr.express_company_id}
    )
    express_map: Dict[str, str] = {}
    if express_ids:
        for e in (
            db.query(BizExpressCompany)
            .filter(BizExpressCompany.id.in_(express_ids), BizExpressCompany.del_flag == 0)
            .all()
        ):
            express_map[e.id] = e.express_name or ""

    reception_ids = (
        [reception_map[s.id].id for s, *_ in rows if s.id in reception_map]
        if reception_map
        else []
    )
    results_map = (
        check_result_svc.list_results_by_reception_ids(db, reception_ids, tenant_no)
        if reception_ids
        else {}
    )

    items: List[Dict[str, Any]] = []
    for sample, donation, party, transport in rows:
        item: Dict[str, Any] = {
            "protocolSampleId": sample.id,
            "collectionProtocolId": donation.id,
            "protocolNo": donation.donation_no or "",
            "sampleCode": sample.sample_code or "",
            "sampleTypeId": sample.sample_type_id or "",
            "sampleTypeName": type_map.get(sample.sample_type_id or "", ""),
            "collectAmount": _dec(sample.collect_amount),
            "motherName": (party.mother_name if party else "") or "",
            "newbornName": (party.newborn_name if party else "") or "",
            "sourceType": "research_donation",
            "sourceTypeLabel": "科研捐赠",
            "etaTime": _fmt_dt(transport.eta_time) if transport and transport.eta_time else "",
            "transportType": (transport.transport_type if transport else "") or "",
            "waybillNo": (transport.waybill_no if transport else "") or "",
            "expressCompanyId": (transport.express_company_id if transport else "") or "",
            "expressCompanyName": express_map.get(
                (transport.express_company_id if transport else "") or "", ""
            ),
            "carrierName": (transport.carrier_name if transport else "") or "",
            "carrierPhone": (transport.carrier_phone if transport else "") or "",
        }
        if reception_map and sample.id in reception_map:
            rec = reception_map[sample.id]
            cr = results_map.get(rec.id)
            if not cr:
                cr = check_result_svc.get_check_results_for_reception(db, rec, tenant_no)
            item.update(_reception_to_dict(rec, check_results=cr))
            item["receiveAttachmentFiles"] = urls_to_attachment_list(
                rec.receive_attachments, db, tenant_no
            )
        items.append(item)
    return items
  • Step 5: 修改 _rows_to_sample_list_items 增加来源标识和运输详情

在 _rows_to_sample_list_items 函数(约 line 122-198)中,item 字典构建部分,在 "carrierName" 行之后追加:

            "carrierPhone": "",
            "sourceType": "cell_storage",
            "sourceTypeLabel": "细胞存储",
  • Step 6: Commit
git add backend/app/crud/sample_reception.py
git commit -m "feat(crud): add donation sample query functions for unified sample reception"

Task 4: 后端 CRUD — 修改 pending/received 列表函数

Files:

  • Modify: backend/app/crud/sample_reception.py — list_pending_protocol_samples 和 list_received_protocol_samples

  • Step 1: 修改 list_pending_protocol_samples 函数

将现有函数(约 line 201-225)替换为:

def list_pending_protocol_samples(
    db: Session,
    *,
    page: int = 1,
    page_size: int = 10,
    tenant_no: Optional[str] = None,
    keyword: Optional[str] = None,
    source_type: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
    if not tenant_no:
        return [], 0

    # 收集两种来源的样本
    all_items: List[Dict[str, Any]] = []

    if not source_type or source_type == "cell_storage":
        q_base = _sample_pending_query(db, tenant_no, keyword)
        cell_total = q_base.order_by(None).with_entities(func.count(BizProtocolSample.id)).scalar() or 0
        cell_rows = (
            q_base.order_by(
                BizProtocolSampleTransport.eta_time.is_(None),
                BizProtocolSampleTransport.eta_time.asc(),
                BizProtocolSample.create_time.asc(),
            )
            .all()
        )
        all_items.extend(_rows_to_sample_list_items(db, cell_rows, tenant_no))
    else:
        cell_total = 0

    if not source_type or source_type == "research_donation":
        q_donation = _donation_sample_pending_query(db, tenant_no, keyword)
        donation_total = q_donation.order_by(None).with_entities(func.count(BizResearchDonationSample.id)).scalar() or 0
        donation_rows = (
            q_donation.order_by(
                BizResearchDonationSampleTransport.eta_time.is_(None),
                BizResearchDonationSampleTransport.eta_time.asc(),
                BizResearchDonationSample.create_time.asc(),
            )
            .all()
        )
        all_items.extend(_donation_rows_to_sample_list_items(db, donation_rows, tenant_no))
    else:
        donation_total = 0

    # 统一按 etaTime 排序(空值排后)
    all_items.sort(key=lambda x: (not x.get("etaTime"), x.get("etaTime") or ""))

    total = cell_total + donation_total
    page = max(1, page)
    page_size = min(max(1, page_size), 100)
    start = (page - 1) * page_size
    page_items = all_items[start:start + page_size]
    return page_items, int(total)
  • Step 2: 修改 list_received_protocol_samples 函数

将现有函数(约 line 228-292)替换为:

def list_received_protocol_samples(
    db: Session,
    *,
    page: int = 1,
    page_size: int = 10,
    tenant_no: Optional[str] = None,
    keyword: Optional[str] = None,
    source_type: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
    if not tenant_no:
        return [], 0

    # 查询所有接收记录,按 source_type 过滤
    q_rec = db.query(BizSampleReception).filter(
        BizSampleReception.tenant_no == tenant_no,
        BizSampleReception.del_flag == 0,
    )
    if source_type:
        q_rec = q_rec.filter(BizSampleReception.reception_source == source_type)

    # 关键词搜索
    if keyword and keyword.strip():
        kw = f"%{keyword.strip()}%"
        # 细胞存储样本关键词
        cell_sample_ids_kw = (
            _sample_detail_rows_query(db)
            .with_entities(BizProtocolSample.id)
            .filter(
                BizProtocolSample.del_flag == 0,
                BizProtocolSample.tenant_no == tenant_no,
                BizCollectionProtocol.tenant_no == tenant_no,
                or_(
                    BizCollectionProtocol.protocol_no.like(kw),
                    BizProtocolSample.sample_code.like(kw),
                    BizProtocolPartyCustomer.mother_name.like(kw),
                    BizProtocolPartyCustomer.newborn_name.like(kw),
                ),
            )
            .subquery()
        )
        # 捐赠样本关键词
        donation_sample_ids_kw = (
            _donation_sample_detail_rows_query(db)
            .with_entities(BizResearchDonationSample.id)
            .filter(
                BizResearchDonationSample.del_flag == 0,
                BizResearchDonationSample.tenant_no == tenant_no,
                BizResearchDonation.tenant_no == tenant_no,
                or_(
                    BizResearchDonation.donation_no.like(kw),
                    BizResearchDonationSample.sample_code.like(kw),
                    BizResearchDonationParty.mother_name.like(kw),
                    BizResearchDonationParty.newborn_name.like(kw),
                ),
            )
            .subquery()
        )
        q_rec = q_rec.filter(
            or_(
                BizSampleReception.handler_name.like(kw),
                BizSampleReception.protocol_sample_id.in_(cell_sample_ids_kw),
                BizSampleReception.donation_sample_id.in_(donation_sample_ids_kw),
            )
        )

    total = q_rec.count()
    page = max(1, page)
    page_size = min(max(1, page_size), 100)
    receptions = (
        q_rec.order_by(BizSampleReception.reception_time.desc())
        .offset((page - 1) * page_size)
        .limit(page_size)
        .all()
    )
    if not receptions:
        return [], int(total)

    # 按 reception_source 分组查询详情
    cell_receptions = [r for r in receptions if r.reception_source == "cell_storage"]
    donation_receptions = [r for r in receptions if r.reception_source == "research_donation"]

    items: List[Dict[str, Any]] = []

    if cell_receptions:
        cell_sample_ids = [r.protocol_sample_id for r in cell_receptions]
        cell_reception_map = {r.protocol_sample_id: r for r in cell_receptions}
        cell_rows = (
            _sample_detail_rows_query(db)
            .filter(BizProtocolSample.id.in_(cell_sample_ids))
            .all()
        )
        cell_row_by_sample = {row[0].id: row for row in cell_rows}
        cell_ordered = [cell_row_by_sample[sid] for sid in cell_sample_ids if sid in cell_row_by_sample]
        items.extend(_rows_to_sample_list_items(db, cell_ordered, tenant_no, reception_map=cell_reception_map))

    if donation_receptions:
        donation_sample_ids = [r.donation_sample_id for r in donation_receptions]
        donation_reception_map = {r.donation_sample_id: r for r in donation_receptions}
        donation_rows = (
            _donation_sample_detail_rows_query(db)
            .filter(BizResearchDonationSample.id.in_(donation_sample_ids))
            .all()
        )
        donation_row_by_sample = {row[0].id: row for row in donation_rows}
        donation_ordered = [donation_row_by_sample[sid] for sid in donation_sample_ids if sid in donation_row_by_sample]
        items.extend(_donation_rows_to_sample_list_items(db, donation_ordered, tenant_no, reception_map=donation_reception_map))

    # 按 reception_time DESC 排序
    items.sort(key=lambda x: x.get("receptionTime") or "", reverse=True)
    return items, int(total)
  • Step 3: Commit
git add backend/app/crud/sample_reception.py
git commit -m "feat(crud): merge cell storage and donation samples in pending/received lists"

Task 5: 后端 CRUD — 修改 create_sample_reception 支持捐赠来源

Files:

  • Modify: backend/app/crud/sample_reception.py — create_sample_reception 函数

  • Step 1: 修改 create_sample_reception 函数签名和逻辑

将现有函数(约 line 342-452)替换为:

def create_sample_reception(
    db: Session,
    *,
    reception_source: str = "cell_storage",
    protocol_sample_id: Optional[str] = None,
    donation_sample_id: Optional[str] = None,
    research_donation_id: Optional[str] = None,
    tenant_no: str,
    user_id: Optional[str],
    create_dept_id: str = "",
    reception_time: datetime,
    handler_user_id: Optional[str],
    handler_name: Optional[str],
    receive_temperature: Optional[str],
    volume_ml: Optional[float] = None,
    net_weight_g: Optional[float] = None,
    checks: List[Dict[str, Any]],
    accept_conclusion: str,
    receive_attachments: Optional[str],
    remark: Optional[str],
) -> BizSampleReception:
    source = (reception_source or "").strip()
    if source not in ("cell_storage", "research_donation"):
        raise ValueError("来源类型无效")

    # 按来源校验样本并获取关联 ID
    if source == "cell_storage":
        if not protocol_sample_id:
            raise ValueError("细胞存储来源必须传入 protocolSampleId")
        sample = (
            db.query(BizProtocolSample)
            .filter(
                BizProtocolSample.id == protocol_sample_id,
                BizProtocolSample.tenant_no == tenant_no,
                BizProtocolSample.del_flag == 0,
            )
            .first()
        )
        if not sample:
            raise ValueError("样本不存在")
        exists = (
            db.query(BizSampleReception)
            .filter(
                BizSampleReception.protocol_sample_id == protocol_sample_id,
                BizSampleReception.tenant_no == tenant_no,
                BizSampleReception.del_flag == 0,
            )
            .first()
        )
        if exists:
            raise ValueError("该样本已登记接收,请勿重复操作")
        collection_protocol_id = sample.collection_protocol_id
        # 写入时 donation_sample_id / research_donation_id 留空
        _donation_sample_id = None
        _research_donation_id = None
    else:
        # research_donation
        if not donation_sample_id:
            raise ValueError("科研捐赠来源必须传入 donationSampleId")
        dsample = (
            db.query(BizResearchDonationSample)
            .filter(
                BizResearchDonationSample.id == donation_sample_id,
                BizResearchDonationSample.tenant_no == tenant_no,
                BizResearchDonationSample.del_flag == 0,
            )
            .first()
        )
        if not dsample:
            raise ValueError("捐赠样本不存在")
        exists = (
            db.query(BizSampleReception)
            .filter(
                BizSampleReception.donation_sample_id == donation_sample_id,
                BizSampleReception.tenant_no == tenant_no,
                BizSampleReception.del_flag == 0,
            )
            .first()
        )
        if exists:
            raise ValueError("该捐赠样本已登记接收,请勿重复操作")
        collection_protocol_id = ""
        protocol_sample_id = ""
        _donation_sample_id = donation_sample_id
        _research_donation_id = dsample.research_donation_id

    conclusion = (accept_conclusion or "").strip()
    if conclusion not in ("js", "bj"):
        raise ValueError("接收结论无效")

    handler_display = (handler_name or "").strip()
    if handler_user_id and not handler_display:
        u = db.query(SysUser).filter(SysUser.id == handler_user_id).first()
        if u:
            handler_display = u.nick_name or ""

    obj = BizSampleReception(
        id=generate_uuid(),
        protocol_sample_id=protocol_sample_id or "",
        collection_protocol_id=collection_protocol_id or "",
        reception_source=source,
        donation_sample_id=_donation_sample_id,
        research_donation_id=_research_donation_id,
        reception_time=reception_time,
        handler_user_id=handler_user_id,
        handler_name=handler_display or None,
        receive_temperature=(receive_temperature or "").strip() or None,
        volume_ml=volume_ml,
        net_weight_g=net_weight_g,
        check_packaging=0,
        check_sealing=0,
        check_no_damage=0,
        check_no_leakage=0,
        check_color=0,
        check_tying_rope=0,
        check_no_stratification=0,
        accept_conclusion=conclusion,
        receive_attachments=(receive_attachments or "").strip() or None,
        tenant_no=tenant_no,
        remark=(remark or "").strip() or None,
        create_by=user_id,
        create_dept_id=create_dept_id or "",
        update_by=user_id,
    )
    db.add(obj)
    db.flush()
    check_result_svc.save_reception_check_results(
        db,
        reception_id=obj.id,
        tenant_no=tenant_no,
        user_id=user_id,
        create_dept_id=create_dept_id or "",
        checks=checks,
    )
    from app.models.models import BizSampleReceptionCheckResult

    saved_rows = (
        db.query(BizSampleReceptionCheckResult)
        .filter(
            BizSampleReceptionCheckResult.reception_id == obj.id,
            BizSampleReceptionCheckResult.del_flag == 0,
        )
        .all()
    )
    check_result_svc.sync_legacy_columns_from_results(db, obj, saved_rows)
    db.add(obj)
    if conclusion == "js":
        from app.crud import qc_work_order as qc_wo_svc

        qc_wo_svc.create_work_order_from_reception(
            db,
            reception=obj,
            tenant_no=tenant_no,
            user_id=user_id,
            create_dept_id=create_dept_id or "",
        )
    db.commit()
    db.refresh(obj)
    return obj
  • Step 2: Commit
git add backend/app/crud/sample_reception.py
git commit -m "feat(crud): support donation source in create_sample_reception"

Task 6: 后端 CRUD — 修改质检工单创建适配捐赠来源

Files:

  • Modify: backend/app/crud/qc_work_order.py — create_work_order_from_reception 函数

  • Step 1: 新增捐赠模型 import

在文件顶部(约 line 16-26)的 import 块中追加:

from app.models.research_donation import BizResearchDonation, BizResearchDonationSample
  • Step 2: 修改 create_work_order_from_reception 函数

将现有函数(约 line 74-136)替换为:

def create_work_order_from_reception(
    db: Session,
    *,
    reception: BizSampleReception,
    tenant_no: str,
    user_id: Optional[str] = None,
    create_dept_id: str = "",
) -> Optional[BizQcWorkOrder]:
    """接收结论为 js 时创建待配置工单(flush,不 commit)。"""
    if (reception.accept_conclusion or "").strip() != "js":
        return None
    exists = (
        db.query(BizQcWorkOrder)
        .filter(
            BizQcWorkOrder.reception_id == reception.id,
            BizQcWorkOrder.tenant_no == tenant_no,
            BizQcWorkOrder.del_flag == 0,
        )
        .first()
    )
    if exists:
        return exists

    source = getattr(reception, "reception_source", "cell_storage") or "cell_storage"

    if source == "research_donation":
        # 捐赠来源:查捐赠单获取编号,查捐赠样本获取编码
        protocol_no = ""
        d = (
            db.query(BizResearchDonation)
            .filter(
                BizResearchDonation.id == (reception.research_donation_id or ""),
                BizResearchDonation.del_flag == 0,
            )
            .first()
        )
        if d:
            protocol_no = d.donation_no or ""
        sample_code = ""
        ds = (
            db.query(BizResearchDonationSample)
            .filter(
                BizResearchDonationSample.id == (reception.donation_sample_id or ""),
                BizResearchDonationSample.del_flag == 0,
            )
            .first()
        )
        if ds:
            sample_code = ds.sample_code or ""
        wo = BizQcWorkOrder(
            id=generate_uuid(),
            work_order_no=_gen_daily_no(db, tenant_no, "ZJ"),
            reception_id=reception.id,
            protocol_sample_id=reception.donation_sample_id or "",
            collection_protocol_id=reception.research_donation_id or "",
            protocol_no=protocol_no or None,
            sample_code=sample_code or None,
            reception_source="research_donation",
            work_order_status="dpz",
            has_lab="0",
            has_third_party="0",
            tenant_no=tenant_no,
            create_by=user_id,
            create_dept_id=create_dept_id or "",
            update_by=user_id,
            create_time=datetime.now(),
            update_time=datetime.now(),
            del_flag=0,
        )
    else:
        # 细胞存储来源:保持原有逻辑
        protocol_no = ""
        p = (
            db.query(BizCollectionProtocol)
            .filter(
                BizCollectionProtocol.id == reception.collection_protocol_id,
                BizCollectionProtocol.del_flag == 0,
            )
            .first()
        )
        if p:
            protocol_no = p.protocol_no or ""
        sample_code = ""
        s = (
            db.query(BizProtocolSample)
            .filter(BizProtocolSample.id == reception.protocol_sample_id, BizProtocolSample.del_flag == 0)
            .first()
        )
        if s:
            sample_code = s.sample_code or ""
        wo = BizQcWorkOrder(
            id=generate_uuid(),
            work_order_no=_gen_daily_no(db, tenant_no, "ZJ"),
            reception_id=reception.id,
            protocol_sample_id=reception.protocol_sample_id,
            collection_protocol_id=reception.collection_protocol_id,
            protocol_no=protocol_no or None,
            sample_code=sample_code or None,
            reception_source="cell_storage",
            work_order_status="dpz",
            has_lab="0",
            has_third_party="0",
            tenant_no=tenant_no,
            create_by=user_id,
            create_dept_id=create_dept_id or "",
            update_by=user_id,
            create_time=datetime.now(),
            update_time=datetime.now(),
            del_flag=0,
        )
    db.add(wo)
    db.flush()
    return wo
  • Step 3: 修改 _wo_to_dict 增加 receptionSource 字段

在 _wo_to_dict 函数(约 line 53-71)的返回字典中,在 "sampleCode" 行之后追加:

        "receptionSource": getattr(wo, "reception_source", "cell_storage") or "cell_storage",
  • Step 4: Commit
git add backend/app/crud/qc_work_order.py
git commit -m "feat(crud): adapt qc work order creation for donation reception source"

Task 7: 后端 API — 修改 schema 和路由参数

Files:

  • Modify: backend/app/api/sample_reception.py — SampleReceptionCreateBody、list_pending_samples、list_received_samples

  • Step 1: SampleReceptionCreateBody 新增 sourceType 和 donationSampleId

修改 schema(约 line 270-283),在现有字段之后追加:

    source_type: Optional[str] = Field("cell_storage", alias="sourceType")
    donation_sample_id: Optional[str] = Field(None, alias="donationSampleId")
  • Step 2: 修改 post_sample_reception 路由传参

修改路由函数(约 line 286-327),将 sr_svc.create_sample_reception 调用改为:

        obj = sr_svc.create_sample_reception(
            db,
            reception_source=body.source_type or "cell_storage",
            protocol_sample_id=body.protocol_sample_id if body.source_type != "research_donation" else None,
            donation_sample_id=body.donation_sample_id if body.source_type == "research_donation" else None,
            tenant_no=tenant_no,
            user_id=user_id,
            create_dept_id=dept_id,
            reception_time=rt,
            handler_user_id=body.handler_user_id or user_id,
            handler_name=body.handler_name,
            receive_temperature=body.receive_temperature,
            volume_ml=body.volume_ml,
            net_weight_g=body.net_weight_g,
            checks=checks_payload,
            accept_conclusion=body.accept_conclusion,
            receive_attachments=body.receive_attachments,
            remark=body.remark,
        )
  • Step 3: 修改 list_pending_samples 路由增加 sourceType 参数

修改函数签名,增加 sourceType 查询参数,传给 CRUD:

@router.get("/sample-receptions/pending")
async def list_pending_samples(
    page: int = Query(1, ge=1),
    pageSize: int = Query(10, ge=1, le=100),
    keyword: Optional[str] = None,
    sourceType: Optional[str] = None,
    db: Session = Depends(get_db),
    user: dict = Depends(require_menu_code("biz:sampleReception:list")),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        items, total = sr_svc.list_pending_protocol_samples(
            db, page=page, page_size=pageSize, tenant_no=tenant_no, keyword=keyword,
            source_type=sourceType,
        )
        return success_response(_page_payload(items, total, page, pageSize))
    except PermissionError as e:
        return error_response(str(e), code=403)
    except Exception as e:
        return error_response(str(e))
  • Step 4: 修改 list_received_samples 路由增加 sourceType 参数

修改函数签名和逻辑,增加 sourceType 参数,传给 CRUD:

将 list_received_samples 函数(约 line 218-242)的内部实现替换为调用 sr_svc.list_received_protocol_samples,增加 source_type=sourceType 参数。函数签名增加 sourceType: Optional[str] = None。

@router.get("/sample-receptions/received")
async def list_received_samples(
    page: int = Query(1, ge=1),
    pageSize: int = Query(10, ge=1, le=100),
    keyword: Optional[str] = None,
    sourceType: Optional[str] = None,
    db: Session = Depends(get_db),
    user: dict = Depends(require_menu_code("biz:sampleReception:list")),
):
    try:
        tenant_no = _get_tenant_no(db, user)
        items, total = sr_svc.list_received_protocol_samples(
            db, page=page, page_size=pageSize, tenant_no=tenant_no, keyword=keyword,
            source_type=sourceType,
        )
        return success_response(_page_payload(items, total, page, pageSize))
    except PermissionError as e:
        return error_response(str(e), code=403)
    except Exception as e:
        return error_response(str(e))
  • Step 5: Commit
git add backend/app/api/sample_reception.py
git commit -m "feat(api): add sourceType filter and donation params to sample reception endpoints"

Task 8: 前端 API 变更

Files:

  • Modify: web-admin/src/api/sample-reception.ts

  • Step 1: 修改 fetchSampleReceptionPendingList 增加 sourceType 参数

export function fetchSampleReceptionPendingList(params?: {
  page?: number
  pageSize?: number
  keyword?: string
  sourceType?: string
}) {
  return request.get<PageList<Record<string, unknown>>>({
    url: '/web/admin/sample-receptions/pending',
    params
  })
}
  • Step 2: 修改 fetchSampleReceptionReceivedList 增加 sourceType 参数
export function fetchSampleReceptionReceivedList(params?: {
  page?: number
  pageSize?: number
  keyword?: string
  sourceType?: string
}) {
  return request.get<PageList<Record<string, unknown>>>({
    url: '/web/admin/sample-receptions/received',
    params
  })
}
  • Step 3: 修改 fetchSampleReceptionSubmit 增加 sourceType 和 donationSampleId
export function fetchSampleReceptionSubmit(data: {
  protocolSampleId?: string
  sourceType?: 'cell_storage' | 'research_donation'
  donationSampleId?: string
  receptionTime: string
  handlerUserId?: string
  handlerName?: string
  receiveTemperature?: string
  volumeMl?: number
  netWeightG?: number
  checks: Array<{ checkItemId: string; checked: boolean }>
  acceptConclusion: 'js' | 'bj'
  receiveAttachments?: string
  remark?: string
}) {
  return request.post({
    url: '/web/admin/sample-receptions',
    data
  })
}
  • Step 4: Commit
git add web-admin/src/api/sample-reception.ts
git commit -m "feat(api): add sourceType and donationSampleId params to sample reception API"

Task 9: 前端页面 — 搜索区和表格列变更

Files:

  • Modify: web-admin/src/views/quality-inspection/sample-reception/index.vue

  • Step 1: 搜索表单增加 sourceType

在 searchForm reactive 中增加字段:

const searchForm = reactive({ keyword: '', sourceType: '' })

在 filter-grid div(约 line 35-43)中,关键词输入框后增加:

<el-form-item label="来源类型">
  <el-select v-model="searchForm.sourceType" placeholder="全部" clearable>
    <el-option label="细胞存储" value="cell_storage" />
    <el-option label="科研捐赠" value="research_donation" />
  </el-select>
</el-form-item>

修改 loadData 函数的 params,增加 sourceType:

const params = {
  page: pagination.page,
  pageSize: pagination.pageSize,
  keyword: searchForm.keyword || undefined,
  sourceType: searchForm.sourceType || undefined,
}

修改 handleReset:

function handleReset() {
  searchForm.keyword = ''
  searchForm.sourceType = ''
  pagination.page = 1
  loadData()
}
  • Step 2: 表格增加来源类型列

在序号列(约 line 93-100)之后、协议号列之前,新增:

<el-table-column prop="sourceTypeLabel" label="来源类型" width="100" align="center" header-align="center">
  <template #default="{ row }">
    <el-tag :type="row.sourceType === 'research_donation' ? 'warning' : 'success'" size="small">
      {{ row.sourceTypeLabel || '细胞存储' }}
    </el-tag>
  </template>
</el-table-column>
  • Step 3: 表格增加运输详情列

在快递单号列(约 line 117-119)之后,新增:

<el-table-column prop="transportType" label="运输方式" width="100" align="center" header-align="center">
  <template #default="{ row }">{{ row.transportType === 'kdys' ? '快递运输' : row.transportType === 'ryxd' ? '人员携带' : '-' }}</template>
</el-table-column>
<el-table-column prop="expressCompanyName" label="快递公司" min-width="120" align="center" header-align="center">
  <template #default="{ row }">{{ row.expressCompanyName || '-' }}</template>
</el-table-column>
<el-table-column prop="carrierName" label="携带人" min-width="100" align="center" header-align="center">
  <template #default="{ row }">{{ row.carrierName || '-' }}</template>
</el-table-column>
  • Step 4: Commit
git add web-admin/src/views/quality-inspection/sample-reception/index.vue
git commit -m "feat(ui): add source type filter, source column, and transport columns to sample reception list"

Task 10: 前端页面 — 接收/查看弹窗增加运输信息

Files:

  • Modify: web-admin/src/views/quality-inspection/sample-reception/index.vue

  • Step 1: 单个接收弹窗增加运输信息区块

在接收弹窗(receiveDialogVisible)的"样本信息"区块(约 line 187-198)之后、form-section-divider 之前,新增:

<div v-if="currentRow" class="form-section-divider" />
<h4 class="detail-section-title">运输信息</h4>
<div class="readonly-grid form-grid-2">
  <div class="readonly-item"><span class="label">运输方式</span>{{ currentRow.transportType === 'kdys' ? '快递运输' : currentRow.transportType === 'ryxd' ? '人员携带' : '-' }}</div>
  <div class="readonly-item"><span class="label">快递公司</span>{{ currentRow.expressCompanyName || '-' }}</div>
  <div class="readonly-item"><span class="label">快递单号</span>{{ currentRow.waybillNo || '-' }}</div>
  <div class="readonly-item"><span class="label">预计到达</span>{{ currentRow.etaTime || '-' }}</div>
  <div class="readonly-item"><span class="label">携带人</span>{{ currentRow.carrierName || '-' }}</div>
  <div class="readonly-item"><span class="label">携带人电话</span>{{ currentRow.carrierPhone || '-' }}</div>
</div>
  • Step 2: 查看详情弹窗增加运输信息区块

在查看弹窗(viewDialogVisible)的"样本信息"区块(约 line 303-309)之后、form-section-divider 之前,新增同样的运输信息区块:

<div class="form-section-divider" />
<h4 class="detail-section-title">运输信息</h4>
<div class="readonly-grid form-grid-2">
  <div class="readonly-item"><span class="label">运输方式</span>{{ viewRow.transportType === 'kdys' ? '快递运输' : viewRow.transportType === 'ryxd' ? '人员携带' : '-' }}</div>
  <div class="readonly-item"><span class="label">快递公司</span>{{ viewRow.expressCompanyName || '-' }}</div>
  <div class="readonly-item"><span class="label">快递单号</span>{{ viewRow.waybillNo || '-' }}</div>
  <div class="readonly-item"><span class="label">预计到达</span>{{ viewRow.etaTime || '-' }}</div>
  <div class="readonly-item"><span class="label">携带人</span>{{ viewRow.carrierName || '-' }}</div>
  <div class="readonly-item"><span class="label">携带人电话</span>{{ viewRow.carrierPhone || '-' }}</div>
</div>
  • Step 3: 修改 submitReceive 传递 sourceType

修改 submitReceive 函数中 fetchSampleReceptionSubmit 调用,增加:

        sourceType: (currentRow.value.sourceType as string) || 'cell_storage',
        donationSampleId: currentRow.value.sourceType === 'research_donation'
          ? String(currentRow.value.protocolSampleId)
          : undefined,
        protocolSampleId: currentRow.value.sourceType !== 'research_donation'
          ? String(currentRow.value.protocolSampleId)
          : undefined,
  • Step 4: Commit
git add web-admin/src/views/quality-inspection/sample-reception/index.vue
git commit -m "feat(ui): add transport info section to receive and view dialogs"

Task 11: 前端页面 — 批量接收弹窗适配

Files:

  • Modify: web-admin/src/views/quality-inspection/sample-reception/index.vue

  • Step 1: BatchReceiveEntry 类型增加 sourceType

修改 BatchReceiveEntry 类型(约 line 525-543),增加:

  sourceType: 'cell_storage' | 'research_donation'
  • Step 2: 修改 createBatchEntry 增加 sourceType

在 createBatchEntry 函数(约 line 678-699)中增加字段:

    sourceType: (row.sourceType as 'cell_storage' | 'research_donation') || 'cell_storage',
  • Step 3: 修改批量接收弹窗卡片展示来源标签

在 batch-receive-card__meta 区域(约 line 374-381),增加来源类型展示:

<div class="readonly-item">
  <span class="label">来源</span>
  <el-tag :type="entry.sourceType === 'research_donation' ? 'warning' : 'success'" size="small">
    {{ entry.sourceType === 'research_donation' ? '科研捐赠' : '细胞存储' }}
  </el-tag>
</div>
<div class="readonly-item"><span class="label">运输方式</span>{{ entry.transportType === 'kdys' ? '快递运输' : entry.transportType === 'ryxd' ? '人员携带' : '-' }}</div>
  • Step 4: 修改 submitBatchReceive 传递 sourceType

在 submitBatchReceive 函数的 fetchSampleReceptionSubmit 调用中增加:

            sourceType: entry.sourceType,
            donationSampleId: entry.sourceType === 'research_donation'
              ? entry.protocolSampleId
              : undefined,
            protocolSampleId: entry.sourceType !== 'research_donation'
              ? entry.protocolSampleId
              : undefined,
  • Step 5: Commit
git add web-admin/src/views/quality-inspection/sample-reception/index.vue
git commit -m "feat(ui): adapt batch receive dialog for donation source type"

Task 12: 端到端验证

  • Step 1: 后端启动验证

Run: cd backend && source venv/bin/activate && python -c "from app.models.models import BizSampleReception, BizQcWorkOrder; print('Model OK')" Expected: Model OK

  • Step 2: 后端 API 启动验证

Run: cd backend && source venv/bin/activate && python -c "from app.api.sample_reception import router; print('API OK')" Expected: API OK

  • Step 3: 前端构建验证

Run: cd web-admin && pnpm build Expected: 构建成功,无 TypeScript 错误

  • Step 4: 后端诊断检查

Run: lsp_diagnostics on backend/app/crud/sample_reception.py Expected: 无错误

Run: lsp_diagnostics on backend/app/api/sample_reception.py Expected: 无错误

Run: lsp_diagnostics on web-admin/src/views/quality-inspection/sample-reception/index.vue Expected: 无错误