Просмотр исходного кода

feat(backend): persist monthly verify no and expose research list fields

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 недель назад
Родитель
Сommit
bee2c0d193
2 измененных файлов с 485 добавлено и 14 удалено
  1. 39 7
      backend/app/crud/research_donation.py
  2. 446 7
      backend/app/services/market_protocol.py

+ 39 - 7
backend/app/crud/research_donation.py

@@ -144,8 +144,6 @@ def next_donation_sample_code(
     return f"{prefix}{str(max_seq + 1).zfill(_DONATION_SAMPLE_CODE_SEQ_LEN)}"
 
 
-
-
 def allocate_monthly_verify_no(
     db: Session,
     *,
@@ -299,6 +297,7 @@ def _donation_to_dict(
     return {
         "id": d.id,
         "donationNo": d.donation_no or "",
+        "monthlyVerifyNo": d.monthly_verify_no or "",
         "donationStatus": d.donation_status or "",
         "donationDate": (d.donation_date or "").strip() if d.donation_date else "",
         "sourceType": d.source_type or "direct",
@@ -778,6 +777,7 @@ def list_donations(
             d["newbornSex"] = pc.get("newbornSex", "")
             d["deliveryMode"] = pc.get("deliveryMode", "")
             d["newbornWeightGram"] = pc.get("newbornWeightGram")
+            d["birthOrder"] = pc.get("birthOrder")
         else:
             d["motherName"] = ""
             d["motherPhone"] = ""
@@ -789,6 +789,7 @@ def list_donations(
             d["newbornSex"] = ""
             d["deliveryMode"] = ""
             d["newbornWeightGram"] = None
+            d["birthOrder"] = None
         ci = collection_map.get(r.id)
         if ci:
             d["regionName"] = region_name_map.get(ci.get("regionId", ""), "")
@@ -798,12 +799,14 @@ def list_donations(
                 ci.get("regionManagerUserId", ""), ""
             )
             d["sampleCollectTime"] = ci.get("sampleCollectTime", "")
+            d["maternalBloodCollectTime"] = ci.get("maternalBloodCollectTime", "")
         else:
             d["regionName"] = ""
             d["hospitalName"] = ""
             d["salesUserName"] = ""
             d["regionManagerUserName"] = ""
             d["sampleCollectTime"] = ""
+            d["maternalBloodCollectTime"] = ""
         donation_samples = sample_map.get(r.id, [])
         type_names: List[str] = []
         codes: List[str] = []
@@ -817,7 +820,9 @@ def list_donations(
                 codes.append(code)
             transport_obj = transport_by_sample_id.get(s.id)
             transport_dict: Optional[Dict[str, Any]] = None
-            if transport_obj and (transport_obj.transport_type or "").strip():
+            if transport_obj and (
+                (transport_obj.transport_type or "").strip() or transport_obj.eta_time
+            ):
                 dest_site_id = transport_obj.dest_site_id or ""
                 express_company_id = transport_obj.express_company_id or ""
                 transport_dict = {
@@ -1036,9 +1041,13 @@ def create_donation(
     )
 
     now = datetime.now()
+    monthly_verify_no = allocate_monthly_verify_no(
+        db, tenant_no=tenant_no, create_time=now
+    )
     obj = BizResearchDonation(
         id=generate_uuid(),
         donation_no=donation_no,
+        monthly_verify_no=monthly_verify_no,
         donation_status=(raw.get("donation_status") or "dsx").strip(),
         donation_date=(raw.get("donation_date") or "").strip() or None,
         source_type=(raw.get("source_type") or "direct").strip(),
@@ -1634,11 +1643,15 @@ def convert_from_storage(
 
     now = datetime.now()
     donation_no = pn_src
+    monthly_verify_no = allocate_monthly_verify_no(
+        db, tenant_no=tenant_no, create_time=now
+    )
 
     # 创建主记录
     donation = BizResearchDonation(
         id=generate_uuid(),
         donation_no=donation_no,
+        monthly_verify_no=monthly_verify_no,
         donation_status="dsx",
         donation_date=now.strftime("%Y%m%d"),
         source_type="from_storage",
@@ -2288,7 +2301,26 @@ def _create_samples(
         db.flush()
         result.append(obj)
 
-        # 本阶段不再写入旧运输明细;运费由 apply_freight_on_protocol_save 挂钩
+        # 预计到达等仍写运输表;运费由 apply_freight_on_protocol_save 挂钩
+        transport_raw = raw.get("transport") if isinstance(raw.get("transport"), dict) else {}
+        if not transport_raw and raw.get("eta_time"):
+            transport_raw = {"eta_time": raw.get("eta_time")}
+        elif isinstance(transport_raw, dict) and raw.get("eta_time") and not (
+            transport_raw.get("eta_time") or transport_raw.get("etaTime")
+        ):
+            transport_raw = {**transport_raw, "eta_time": raw.get("eta_time")}
+        if transport_raw:
+            _create_transport(
+                db,
+                donation_id,
+                obj.id,
+                tenant_no,
+                user_id,
+                transport_raw,
+                idx,
+                now,
+                sample_code=obj.sample_code,
+            )
 
     return result
 
@@ -2304,14 +2336,14 @@ def _create_transport(
     now: datetime,
     sample_code: Optional[str] = None,
 ) -> Optional[BizResearchDonationSampleTransport]:
+    """写回捐赠样本运输;可仅有预计到达时间(运费批次改造后)。"""
     tnorm = _normalize_fields(raw)
     transport_type = (tnorm.get("transport_type") or "").strip()
-    if not transport_type:
-        return None
-
     eta_time = tnorm.get("eta_time")
     if isinstance(eta_time, str):
         eta_time = parse_datetime(eta_time)
+    if not transport_type and not eta_time:
+        return None
 
     express_fee = tnorm.get("express_fee")
     if express_fee is not None and express_fee != "":

+ 446 - 7
backend/app/services/market_protocol.py

@@ -22,6 +22,7 @@ from app.models.models import (
     BizProtocolCollectionInfo,
     BizProtocolPartyCustomer,
     BizProtocolSample,
+    BizProtocolSampleTransport,
     BizSampleCode,
     SysUser,
 )
@@ -30,6 +31,7 @@ from app.models.research_donation import (
     BizResearchDonationCollection,
     BizResearchDonationParty,
     BizResearchDonationSample,
+    BizResearchDonationSampleTransport,
 )
 from app.schemas.market_mobile import MarketProtocolBody
 from app.services.market_field_scope import (
@@ -39,7 +41,7 @@ from app.services.market_field_scope import (
     assert_cell_protocol_access,
     assert_donation_access,
 )
-from app.services.market_support import validate_attachment_refs
+from app.services.market_support import allowed_actions, validate_attachment_refs
 
 SOURCE_CELL = 'cell_storage'
 SOURCE_DONATION = 'research_donation'
@@ -405,6 +407,203 @@ def _party_name_donation(db: Session, ids: List[str], tenant_no: str) -> dict:
     return {row.research_donation_id: row.mother_name or '' for row in rows}
 
 
+def _enrich_protocol_cards(
+    db: Session, items: List[dict], tenant_no: str
+) -> None:
+    """批量补齐与 Web 列表一致的移动卡片字段,避免逐行查询。"""
+    cell_ids = [item['id'] for item in items if item['source'] == SOURCE_CELL]
+    donation_ids = [
+        item['id'] for item in items if item['source'] == SOURCE_DONATION
+    ]
+    collection_map = {}
+    sample_map = {}
+    party_map = {}
+    if cell_ids:
+        for row in db.query(BizProtocolCollectionInfo).filter(
+            BizProtocolCollectionInfo.collection_protocol_id.in_(cell_ids),
+            BizProtocolCollectionInfo.tenant_no == tenant_no,
+            BizProtocolCollectionInfo.del_flag == 0,
+        ).order_by(BizProtocolCollectionInfo.create_time.desc()).all():
+            collection_map.setdefault((SOURCE_CELL, row.collection_protocol_id), row)
+        for row in db.query(BizProtocolSample).filter(
+            BizProtocolSample.collection_protocol_id.in_(cell_ids),
+            BizProtocolSample.tenant_no == tenant_no,
+            BizProtocolSample.del_flag == 0,
+        ).order_by(BizProtocolSample.create_time.asc()).all():
+            sample_map.setdefault((SOURCE_CELL, row.collection_protocol_id), row)
+        for row in db.query(BizProtocolPartyCustomer).filter(
+            BizProtocolPartyCustomer.collection_protocol_id.in_(cell_ids),
+            BizProtocolPartyCustomer.tenant_no == tenant_no,
+            BizProtocolPartyCustomer.del_flag == 0,
+        ).order_by(BizProtocolPartyCustomer.create_time.desc()).all():
+            party_map.setdefault((SOURCE_CELL, row.collection_protocol_id), row)
+    if donation_ids:
+        for row in db.query(BizResearchDonationCollection).filter(
+            BizResearchDonationCollection.research_donation_id.in_(donation_ids),
+            BizResearchDonationCollection.tenant_no == tenant_no,
+            BizResearchDonationCollection.del_flag == 0,
+        ).order_by(BizResearchDonationCollection.create_time.desc()).all():
+            collection_map.setdefault((SOURCE_DONATION, row.research_donation_id), row)
+        for row in db.query(BizResearchDonationSample).filter(
+            BizResearchDonationSample.research_donation_id.in_(donation_ids),
+            BizResearchDonationSample.tenant_no == tenant_no,
+            BizResearchDonationSample.del_flag == 0,
+        ).order_by(BizResearchDonationSample.create_time.asc()).all():
+            sample_map.setdefault((SOURCE_DONATION, row.research_donation_id), row)
+        for row in db.query(BizResearchDonationParty).filter(
+            BizResearchDonationParty.research_donation_id.in_(donation_ids),
+            BizResearchDonationParty.tenant_no == tenant_no,
+            BizResearchDonationParty.del_flag == 0,
+        ).order_by(BizResearchDonationParty.create_time.desc()).all():
+            party_map.setdefault((SOURCE_DONATION, row.research_donation_id), row)
+    region_ids = {
+        row.region_id for row in collection_map.values() if row.region_id
+    }
+    hospital_ids = {
+        row.hospital_id for row in collection_map.values() if row.hospital_id
+    }
+    user_ids = {
+        value
+        for row in collection_map.values()
+        for value in (row.sales_user_id, row.region_manager_user_id)
+        if value
+    }
+    user_ids.update(
+        item.get('collectorUserId')
+        for item in items
+        if item.get('collectorUserId')
+    )
+    type_ids = {
+        row.sample_type_id for row in sample_map.values() if row.sample_type_id
+    }
+    region_names = {
+        row.id: row.region_name or ''
+        for row in db.query(BizCollectionRegion).filter(
+            BizCollectionRegion.id.in_(region_ids or ['']),
+            BizCollectionRegion.tenant_no == tenant_no,
+            BizCollectionRegion.del_flag == 0,
+        )
+    }
+    hospital_names = {
+        row.id: row.hospital_name or ''
+        for row in db.query(BizCollectionHospital).filter(
+            BizCollectionHospital.id.in_(hospital_ids or ['']),
+            BizCollectionHospital.tenant_no == tenant_no,
+            BizCollectionHospital.del_flag == 0,
+        )
+    }
+    user_names = {
+        row.id: row.nick_name or ''
+        for row in db.query(SysUser).filter(
+            SysUser.id.in_(user_ids or ['']),
+            SysUser.tenant_no == tenant_no,
+        )
+    }
+    type_names = {
+        row.id: row.type_name or ''
+        for row in db.query(BizCellSampleType).filter(
+            BizCellSampleType.id.in_(type_ids or ['']),
+            BizCellSampleType.del_flag == 0,
+            or_(
+                BizCellSampleType.tenant_no == tenant_no,
+                BizCellSampleType.tenant_no == '',
+            ),
+        )
+    }
+    sample_ids = [row.id for row in sample_map.values()]
+    transport_by_sample = {}
+    if sample_ids:
+        for row in db.query(BizProtocolSampleTransport).filter(
+            BizProtocolSampleTransport.sample_id.in_(sample_ids),
+            BizProtocolSampleTransport.tenant_no == tenant_no,
+            BizProtocolSampleTransport.del_flag == 0,
+        ).all():
+            transport_by_sample.setdefault(row.sample_id, row)
+        for row in db.query(BizResearchDonationSampleTransport).filter(
+            BizResearchDonationSampleTransport.sample_id.in_(sample_ids),
+            BizResearchDonationSampleTransport.tenant_no == tenant_no,
+            BizResearchDonationSampleTransport.del_flag == 0,
+        ).all():
+            transport_by_sample.setdefault(row.sample_id, row)
+    lab_by_key = {}
+    from app.crud import sample_reception as reception_crud
+
+    for source in SOURCES:
+        codes = [
+            row.sample_code
+            for (item_source, _), row in sample_map.items()
+            if item_source == source and row.sample_code
+        ]
+        for code, lab_code in reception_crud.get_lab_storage_code_map_by_sample_codes(
+            db, tenant_no, codes, reception_source=source
+        ).items():
+            lab_by_key[(source, code)] = lab_code
+    payment_summaries = {}
+    if cell_ids:
+        from app.services.qc_work_order_fee_summary import (
+            build_protocol_payment_summaries,
+        )
+
+        payment_summaries = build_protocol_payment_summaries(
+            db, cell_ids, tenant_no
+        )
+    transport_labels = {'kdys': '快递运输', 'ryxd': '人员携带'}
+    for item in items:
+        key = (item['source'], item['id'])
+        collection = collection_map.get(key)
+        sample = sample_map.get(key)
+        party = party_map.get(key)
+        payment_summary = payment_summaries.get(item['id']) or {}
+        transport = transport_by_sample.get(getattr(sample, 'id', ''))
+        sample_code = getattr(sample, 'sample_code', '') or ''
+        region_manager_name = user_names.get(
+            getattr(collection, 'region_manager_user_id', ''), ''
+        )
+        item.update(
+            motherName=item.get('customerName', ''),
+            motherPhone=getattr(party, 'mother_phone', '') or '',
+            motherIdCard=getattr(party, 'mother_id_card', '') or '',
+            fatherName=getattr(party, 'father_name', '') or '',
+            newbornSex=getattr(party, 'newborn_sex', '') or '',
+            deliveryMode=getattr(party, 'delivery_mode', '') or '',
+            newbornWeightGram=getattr(party, 'newborn_weight_gram', None),
+            birthOrder=getattr(party, 'birth_order', None),
+            newbornDeliveryTime=_fmt_dt(
+                getattr(party, 'newborn_delivery_time', None)
+            ),
+            protocolStatus=item.get('status', ''),
+            donationStatus=item.get('status', ''),
+            regionName=region_names.get(getattr(collection, 'region_id', ''), ''),
+            hospitalName=hospital_names.get(
+                getattr(collection, 'hospital_id', ''), ''
+            ),
+            sampleCollectTime=_fmt_dt(
+                getattr(collection, 'sample_collect_time', None)
+            ),
+            maternalBloodCollectTime=_fmt_dt(
+                getattr(collection, 'maternal_blood_collect_time', None)
+            ),
+            salesUserName=user_names.get(
+                getattr(collection, 'sales_user_id', ''), ''
+            ),
+            collectorUserName=user_names.get(item.get('collectorUserId', ''), ''),
+            managerName=region_manager_name,
+            regionManagerUserName=region_manager_name,
+            sampleCode=sample_code,
+            sampleTypeName=type_names.get(
+                getattr(sample, 'sample_type_id', ''), ''
+            ),
+            coStored=bool(getattr(sample, 'is_special', 0)),
+            labStorageCode=lab_by_key.get((item['source'], sample_code), ''),
+            payMode=payment_summary.get('payMode', ''),
+            firstPayTime=payment_summary.get('firstPayTransferTime', ''),
+            transportMode=getattr(transport, 'transport_type', '') or '',
+            transportModeLabel=transport_labels.get(
+                getattr(transport, 'transport_type', '') or '', ''
+            ),
+        )
+
+
 def list_protocols(
     db: Session,
     *,
@@ -426,7 +625,26 @@ def list_protocols(
             db.query(BizCollectionProtocol), db, ctx, user_info=user_info
         )
         if search:
-            query = query.filter(BizCollectionProtocol.protocol_no.like(f'%{search}%'))
+            like = f'%{search}%'
+            party_ids = db.query(
+                BizProtocolPartyCustomer.collection_protocol_id
+            ).filter(
+                BizProtocolPartyCustomer.tenant_no == ctx.tenant_no,
+                BizProtocolPartyCustomer.del_flag == 0,
+                BizProtocolPartyCustomer.mother_name.like(like),
+            )
+            sample_ids = db.query(BizProtocolSample.collection_protocol_id).filter(
+                BizProtocolSample.tenant_no == ctx.tenant_no,
+                BizProtocolSample.del_flag == 0,
+                BizProtocolSample.sample_code.like(like),
+            )
+            query = query.filter(
+                or_(
+                    BizCollectionProtocol.protocol_no.like(like),
+                    BizCollectionProtocol.id.in_(party_ids),
+                    BizCollectionProtocol.id.in_(sample_ids),
+                )
+            )
         total += int(
             query.order_by(None)
             .with_entities(func.count(BizCollectionProtocol.id))
@@ -451,6 +669,10 @@ def list_protocols(
                 'code': row.protocol_no or '',
                 'protocolNo': row.protocol_no or '',
                 'status': row.protocol_status or '',
+                'collectDate': row.register_date or '',
+                'registerDate': row.register_date or '',
+                'protocolTotalAmount': str(row.protocol_total_amount or ''),
+                'collectorUserId': row.collector_user_id or '',
                 'customerName': names.get(row.id, ''),
                 'createTime': _fmt_dt(row.create_time),
                 '_created': row.create_time or datetime.min,
@@ -462,7 +684,28 @@ def list_protocols(
             db.query(BizResearchDonation), db, ctx, user_info=user_info
         )
         if search:
-            query = query.filter(BizResearchDonation.donation_no.like(f'%{search}%'))
+            like = f'%{search}%'
+            party_ids = db.query(
+                BizResearchDonationParty.research_donation_id
+            ).filter(
+                BizResearchDonationParty.tenant_no == ctx.tenant_no,
+                BizResearchDonationParty.del_flag == 0,
+                BizResearchDonationParty.mother_name.like(like),
+            )
+            sample_ids = db.query(
+                BizResearchDonationSample.research_donation_id
+            ).filter(
+                BizResearchDonationSample.tenant_no == ctx.tenant_no,
+                BizResearchDonationSample.del_flag == 0,
+                BizResearchDonationSample.sample_code.like(like),
+            )
+            query = query.filter(
+                or_(
+                    BizResearchDonation.donation_no.like(like),
+                    BizResearchDonation.id.in_(party_ids),
+                    BizResearchDonation.id.in_(sample_ids),
+                )
+            )
         total += int(
             query.order_by(None)
             .with_entities(func.count(BizResearchDonation.id))
@@ -486,9 +729,19 @@ def list_protocols(
                 'source': SOURCE_DONATION,
                 'code': row.donation_no or '',
                 'protocolNo': row.donation_no or '',
+                'donationNo': row.donation_no or '',
                 'status': row.donation_status or '',
+                'collectDate': row.donation_date or '',
+                'donationDate': row.donation_date or '',
                 'customerName': names.get(row.id, ''),
+                'collectorUserId': row.collector_user_id or '',
                 'createTime': _fmt_dt(row.create_time),
+                'monthlyVerifyNo': row.monthly_verify_no or '',
+                'voidDesc': row.void_desc or '',
+                'consentFormUrls': row.consent_form_urls or '',
+                'infectiousDiseaseReportUrls': row.infectious_disease_report_urls or '',
+                'attachmentUrls': row.attachment_urls or '',
+                'remark': row.remark or '',
                 '_created': row.create_time or datetime.min,
             }
             for row in donation_rows
@@ -501,22 +754,42 @@ def list_protocols(
     page_rows = rows[(safe_page - 1) * safe_size : safe_page * safe_size]
     for item in page_rows:
         item.pop('_created', None)
+    _enrich_protocol_cards(db, page_rows, ctx.tenant_no)
     return page_rows, total
 
 
+def _attach_sample_eta(
+    sample_dicts: List[dict],
+    transport_by_sample_id: dict,
+) -> List[dict]:
+    for item in sample_dicts:
+        transport = transport_by_sample_id.get(str(item.get('id') or ''))
+        eta = ''
+        if transport is not None:
+            eta = cell_crud._fmt_dt(getattr(transport, 'eta_time', None))
+        item['etaTime'] = eta
+        item['transport'] = {'etaTime': eta} if eta else None
+    return sample_dicts
+
+
 def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dict:
     party = cell_crud.get_party_customer(db, row.id, tenant_no)
     collection = cell_crud.get_collection_info(db, row.id, tenant_no)
     samples = cell_crud.list_samples(db, row.id, tenant_no)
     payment = cell_crud.get_payment_by_protocol_id(db, row.id, tenant_no)
-    return {
+    transports = cell_crud.list_sample_transports(db, row.id, tenant_no)
+    transport_by_sample_id = {str(t.sample_id): t for t in transports}
+    detail = {
         'source': SOURCE_CELL,
         'basic': cell_crud._protocol_to_dict(row),
         'collection': cell_crud._collection_info_to_dict(collection)
         if collection
         else None,
         'customer': cell_crud._party_customer_to_dict(party) if party else None,
-        'samples': [cell_crud._sample_to_dict(item) for item in samples],
+        'samples': _attach_sample_eta(
+            [cell_crud._sample_to_dict(item) for item in samples],
+            transport_by_sample_id,
+        ),
         'payment': cell_crud._payment_to_dict(payment) if payment else None,
         'attachments': {
             'protocolAttachments': row.protocol_attachments or '',
@@ -524,6 +797,8 @@ def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dic
             'healthSurveyFormUrls': row.health_survey_form_urls or '',
         },
     }
+    _enrich_protocol_detail_labels(db, detail, tenant_no)
+    return detail
 
 
 def _donation_detail(
@@ -532,14 +807,19 @@ def _donation_detail(
     party = donation_crud.get_party(db, row.id, tenant_no)
     collection = donation_crud.get_collection(db, row.id, tenant_no)
     samples = donation_crud.list_samples(db, row.id, tenant_no)
-    return {
+    transports = donation_crud.list_sample_transports(db, row.id, tenant_no)
+    transport_by_sample_id = {str(t.sample_id): t for t in transports}
+    detail = {
         'source': SOURCE_DONATION,
         'basic': donation_crud._donation_to_dict(row),
         'collection': donation_crud._collection_to_dict(collection)
         if collection
         else None,
         'customer': donation_crud._party_to_dict(party) if party else None,
-        'samples': [donation_crud._sample_to_dict(item) for item in samples],
+        'samples': _attach_sample_eta(
+            [donation_crud._sample_to_dict(item) for item in samples],
+            transport_by_sample_id,
+        ),
         'attachments': {
             'consentFormUrls': row.consent_form_urls or '',
             'attachmentUrls': row.attachment_urls or '',
@@ -547,6 +827,99 @@ def _donation_detail(
             or '',
         },
     }
+    _enrich_protocol_detail_labels(db, detail, tenant_no)
+    return detail
+
+
+def _enrich_protocol_detail_labels(
+    db: Session, detail: dict, tenant_no: str
+) -> None:
+    """补齐 Web 详情使用的主数据名称,移动端不直接展示内部 ID。"""
+    basic = detail.get('basic') or {}
+    collection = detail.get('collection') or {}
+    samples = detail.get('samples') or []
+
+    region_id = str(collection.get('regionId') or '').strip()
+    hospital_id = str(collection.get('hospitalId') or '').strip()
+    if region_id:
+        region = (
+            db.query(BizCollectionRegion)
+            .filter(
+                BizCollectionRegion.id == region_id,
+                BizCollectionRegion.tenant_no == tenant_no,
+            )
+            .first()
+        )
+        collection['regionName'] = region.region_name if region else ''
+    if hospital_id:
+        hospital = (
+            db.query(BizCollectionHospital)
+            .filter(
+                BizCollectionHospital.id == hospital_id,
+                BizCollectionHospital.tenant_no == tenant_no,
+            )
+            .first()
+        )
+        collection['hospitalName'] = hospital.hospital_name if hospital else ''
+
+    user_ids = {
+        str(value or '').strip()
+        for value in (
+            basic.get('collectorUserId'),
+            collection.get('salesUserId'),
+            collection.get('regionManagerUserId'),
+        )
+        if str(value or '').strip()
+    }
+    user_names = {
+        row.id: row.nick_name or ''
+        for row in db.query(SysUser).filter(
+            SysUser.id.in_(user_ids or ['']),
+            SysUser.tenant_no == tenant_no,
+        )
+    }
+    basic['collectorUserName'] = user_names.get(
+        str(basic.get('collectorUserId') or ''), ''
+    )
+    collection['salesUserName'] = user_names.get(
+        str(collection.get('salesUserId') or ''), ''
+    )
+    collection['regionManagerUserName'] = user_names.get(
+        str(collection.get('regionManagerUserId') or ''), ''
+    )
+
+    type_ids = {
+        str(item.get('sampleTypeId') or '').strip()
+        for item in samples
+        if str(item.get('sampleTypeId') or '').strip()
+    }
+    type_names = {
+        row.id: row.type_name or ''
+        for row in db.query(BizCellSampleType).filter(
+            BizCellSampleType.id.in_(type_ids or ['']),
+            or_(
+                BizCellSampleType.tenant_no == tenant_no,
+                BizCellSampleType.tenant_no == '',
+            ),
+        )
+    }
+    unit_ids = {
+        str(item.get('unitId') or '').strip()
+        for item in samples
+        if str(item.get('unitId') or '').strip()
+    }
+    unit_names = {
+        row.id: row.unit_name or ''
+        for row in db.query(BizItemUnit).filter(
+            BizItemUnit.id.in_(unit_ids or ['']),
+            BizItemUnit.tenant_no == tenant_no,
+        )
+    }
+    for item in samples:
+        item['sampleTypeName'] = type_names.get(
+            str(item.get('sampleTypeId') or ''), ''
+        )
+        item['unitName'] = unit_names.get(str(item.get('unitId') or ''), '')
 
 
 def get_protocol(
@@ -588,6 +961,72 @@ def get_protocol(
     raise ValueError('不支持的协议来源')
 
 
+def audit_protocol(
+    db: Session,
+    *,
+    source: str,
+    protocol_id: str,
+    audit_status: str,
+    audit_remark: Optional[str],
+    ctx: MarketFieldContext,
+    user_info: dict,
+) -> dict:
+    if source == SOURCE_CELL:
+        row = assert_cell_protocol_access(db, protocol_id, ctx, user_info=user_info)
+        if 'audit' not in allowed_actions('protocol', status=row.protocol_status or ''):
+            raise ValueError('当前状态不可审核')
+        cell_crud.create_audit(
+            db,
+            collection_protocol_id=protocol_id,
+            audit_status=audit_status,
+            audit_remark=audit_remark,
+            tenant_no=ctx.tenant_no,
+            user_id=ctx.user_id,
+        )
+        return {'id': protocol_id, 'source': source, 'protocolNo': row.protocol_no or ''}
+    if source == SOURCE_DONATION:
+        row = assert_donation_access(db, protocol_id, ctx, user_info=user_info)
+        if 'audit' not in allowed_actions('protocol', status=row.donation_status or ''):
+            raise ValueError('当前状态不可审核')
+        donation_crud.submit_audit(
+            db,
+            protocol_id,
+            audit_status=audit_status,
+            audit_remark=audit_remark,
+            tenant_no=ctx.tenant_no,
+            user_id=ctx.user_id,
+        )
+        return {'id': protocol_id, 'source': source, 'donationNo': row.donation_no or ''}
+    raise ValueError('不支持的协议来源')
+
+
+def delete_protocol(
+    db: Session,
+    *,
+    source: str,
+    protocol_id: str,
+    ctx: MarketFieldContext,
+    user_info: dict,
+) -> None:
+    if source == SOURCE_CELL:
+        row = assert_cell_protocol_access(db, protocol_id, ctx, user_info=user_info)
+        if 'delete' not in allowed_actions('protocol', status=row.protocol_status or ''):
+            raise ValueError('当前状态不可删除')
+        cell_crud.soft_delete_collection_protocol(
+            db, protocol_id, ctx.tenant_no, user_id=ctx.user_id
+        )
+        return
+    if source == SOURCE_DONATION:
+        row = assert_donation_access(db, protocol_id, ctx, user_info=user_info)
+        if 'delete' not in allowed_actions('protocol', status=row.donation_status or ''):
+            raise ValueError('当前状态不可删除')
+        donation_crud.delete_donation(
+            db, protocol_id, ctx.tenant_no, user_id=ctx.user_id
+        )
+        return
+    raise ValueError('不支持的协议来源')
+
+
 def create_protocol(
     db: Session,
     *,