"""移动市场协议服务:双来源统一展示,写入保持来源内原子性。""" from __future__ import annotations from datetime import datetime, timedelta from decimal import Decimal from typing import List, Optional, Tuple from fastapi import HTTPException from sqlalchemy import func, or_ from sqlalchemy.orm import Session from app.core.utils import parse_datetime from app.core.protocol_status_display import ( build_void_type_label_map, resolve_void_type_label, ) from app.crud import collection_protocol as cell_crud from app.crud import protocol_sample_payment as psp_crud from app.crud import research_donation as donation_crud from app.crud import sample_freight as sample_freight_crud from app.models.models import ( BizCellSampleType, BizCollectionHospital, BizCollectionProtocol, BizCollectionRegion, BizCollectionRegionStaff, BizCustomer, BizItemUnit, BizProtocolCollectionInfo, BizProtocolPartyCustomer, BizProtocolPayment, BizProtocolPaymentDetail, BizProtocolPaymentSchedule, BizProtocolPaymentTransfer, BizProtocolSample, BizProtocolSamplePayment, BizProtocolSampleTransport, BizSampleCode, SysUser, ) from app.models.research_donation import ( BizResearchDonation, BizResearchDonationCollection, BizResearchDonationParty, BizResearchDonationSample, BizResearchDonationSampleTransport, ) from app.schemas.market_mobile import MarketProtocolBody from app.services.market_field_scope import ( MarketFieldContext, apply_cell_protocol_scope, apply_donation_scope, assert_cell_protocol_access, assert_donation_access, ) from app.services import market_freight from app.services.market_support import ( _dict_options, allowed_actions, validate_attachment_refs, ) SOURCE_CELL = 'cell_storage' SOURCE_DONATION = 'research_donation' def _fixed_money(value): if value is None: return None return format(Decimal(str(value)).quantize(Decimal('0.01')), 'f') SOURCES = (SOURCE_CELL, SOURCE_DONATION) _CUSTOMER_LINK_FIELDS = ( 'customer_id', 'father_customer_id', 'newborn_customer_id', ) def _current_dept_id(user_info: dict) -> str: if not isinstance(user_info, dict): return '' return str( user_info.get('dept_id') or user_info.get('deptId') or user_info.get('region_id') or '' ).strip() def _dump(value) -> dict: if value is None: return {} if hasattr(value, 'model_dump'): return value.model_dump(by_alias=False, exclude_unset=True) return dict(value) def _assert_customer_link_policy( db: Session, customer: dict, *, ctx: MarketFieldContext, user_info: dict, ) -> None: """允许绑定/改绑本租户数据权限内的客户主档;空值表示清空;禁止无效 id。""" from app.core.data_scope import apply_data_scope for key in _CUSTOMER_LINK_FIELDS: if key not in customer: continue supplied = str(customer.get(key) or '').strip() if not supplied: continue query = db.query(BizCustomer.id).filter( BizCustomer.id == supplied, BizCustomer.tenant_no == ctx.tenant_no, BizCustomer.del_flag == 0, ) query = apply_data_scope(query, BizCustomer, user_info, db, ctx.tenant_no) if query.first() is None: raise ValueError('客户不存在或不在权限范围内') def _fmt_dt(value) -> str: if value is None: return '' if isinstance(value, str): return value return value.strftime('%Y-%m-%d %H:%M:%S') def _manager_assignment_allowed( db: Session, ctx: MarketFieldContext, *, region_id: Optional[str], region_manager_user_id: Optional[str], ) -> bool: region = str(region_id or '').strip() if not region: return False return ( db.query(BizCollectionRegionStaff.id) .filter( BizCollectionRegionStaff.tenant_no == ctx.tenant_no, BizCollectionRegionStaff.del_flag == 0, BizCollectionRegionStaff.user_id == ctx.user_id, BizCollectionRegionStaff.is_leader == 1, BizCollectionRegionStaff.region_id == region, ) .first() is not None ) def _ensure_manager_assignment( db: Session, ctx: MarketFieldContext, *, region_id: Optional[str], region_manager_user_id: Optional[str], ) -> None: if not _manager_assignment_allowed( db, ctx, region_id=region_id, region_manager_user_id=region_manager_user_id, ): raise ValueError('协议归属不在当前地区经理范围内') def _active_user_ids(db: Session, ctx: MarketFieldContext, ids: set) -> set: if not ids: return set() return { str(row[0]) for row in db.query(SysUser.id) .filter( SysUser.id.in_(ids), SysUser.tenant_no == ctx.tenant_no, SysUser.del_flag == 0, SysUser.status == '1', ) .all() } def _validate_references( db: Session, ctx: MarketFieldContext, *, customer: dict, collection: dict, samples: List[dict], extra_user_ids: Optional[set] = None, ) -> None: region_id = str(collection.get('region_id') or '').strip() hospital_id = str(collection.get('hospital_id') or '').strip() if region_id: exists = ( db.query(BizCollectionRegion.id) .filter( BizCollectionRegion.id == region_id, BizCollectionRegion.tenant_no == ctx.tenant_no, BizCollectionRegion.del_flag == 0, ) .first() ) if not exists: raise ValueError('采集地区不存在或不属于当前租户') if hospital_id: exists = ( db.query(BizCollectionHospital.id) .filter( BizCollectionHospital.id == hospital_id, BizCollectionHospital.region_id == region_id, BizCollectionHospital.tenant_no == ctx.tenant_no, BizCollectionHospital.del_flag == 0, ) .first() ) if not exists: raise ValueError('医院不存在、不属于当前租户或与采集地区不匹配') user_ids = { str(value).strip() for value in ( customer.get('collector_user_id'), collection.get('sales_user_id'), collection.get('region_manager_user_id'), ) if str(value or '').strip() } user_ids.update( str(value).strip() for value in (extra_user_ids or set()) if str(value or '').strip() ) if _active_user_ids(db, ctx, user_ids) != user_ids: raise ValueError('引用用户不存在、已停用或不属于当前租户') customer_ids = { str(customer.get(key) or '').strip() for key in ('customer_id', 'father_customer_id', 'newborn_customer_id') if str(customer.get(key) or '').strip() } if customer_ids: existing = { str(row[0]) for row in db.query(BizCustomer.id) .filter( BizCustomer.id.in_(customer_ids), BizCustomer.tenant_no == ctx.tenant_no, BizCustomer.del_flag == 0, ) .all() } if existing != customer_ids: raise ValueError('客户不存在或不属于当前租户') type_ids = { str(item.get('sample_type_id') or '').strip() for item in samples if str(item.get('sample_type_id') or '').strip() } if type_ids: existing = { str(row[0]) for row in db.query(BizCellSampleType.id) .filter( BizCellSampleType.id.in_(type_ids), BizCellSampleType.tenant_no == ctx.tenant_no, BizCellSampleType.del_flag == 0, ) .all() } if existing != type_ids: raise ValueError('样本类型不存在或不属于当前租户') unit_ids = { str(item.get('unit_id') or '').strip() for item in samples if str(item.get('unit_id') or '').strip() } if unit_ids: existing = { str(row[0]) for row in db.query(BizItemUnit.id) .filter( BizItemUnit.id.in_(unit_ids), BizItemUnit.tenant_no == ctx.tenant_no, BizItemUnit.del_flag == 0, ) .all() } if existing != unit_ids: raise ValueError('计量单位不存在或不属于当前租户') sample_code_ids = { str(item.get('sample_code_id') or '').strip() for item in samples if str(item.get('sample_code_id') or '').strip() } sample_code_map = {} if sample_code_ids: sample_code_map = { str(row.id): str(row.sample_code or '').strip() for row in db.query(BizSampleCode) .filter( BizSampleCode.id.in_(sample_code_ids), BizSampleCode.tenant_no == ctx.tenant_no, BizSampleCode.del_flag == 0, ) .all() } if set(sample_code_map) != sample_code_ids: raise ValueError('样本编码不存在、已删除或不属于当前租户') for item in samples: sample_code_id = str(item.get('sample_code_id') or '').strip() if not sample_code_id: continue expected_code = sample_code_map[sample_code_id] supplied_code = str(item.get('sample_code') or '').strip() if supplied_code and supplied_code != expected_code: raise ValueError('样本编码ID与样本编码不一致') item['sample_code'] = expected_code def _ensure_donation_sample_codes( db: Session, samples: List[dict], *, tenant_no: str ) -> None: """科研捐赠:空样本编码时按 Web 规则自动生成。""" reserved: List[str] = [] for item in samples: existing = str(item.get('sample_code') or '').strip() if existing: reserved.append(existing) continue code = donation_crud.next_donation_sample_code( db, tenant_no=tenant_no, extra_codes=reserved ) item['sample_code'] = code reserved.append(code) def _normalize_write( db: Session, *, body: MarketProtocolBody, ctx: MarketFieldContext, current_collection=None, current_samples: Optional[List] = None, ) -> Tuple[dict, dict, List[dict], dict]: customer = _dump(body.customer) collection = _dump(body.collection) samples = [_dump(item) for item in (body.samples or [])] _validate_unique_sample_identity(samples) if body.samples is not None and current_samples is not None: samples = _merge_existing_sample_fields(current_samples, samples) elif any(str(item.get('id') or '').strip() for item in samples): raise ValueError('样本不存在或不属于当前协议') if body.source == SOURCE_DONATION: _ensure_donation_sample_codes(db, samples, tenant_no=ctx.tenant_no) # freight_draft 可能仍是嵌套 model;统一成 dict 供运费挂钩 for item in samples: draft = item.get('freight_draft') if draft is not None and hasattr(draft, 'model_dump'): item['freight_draft'] = draft.model_dump( by_alias=False, exclude_unset=True ) attachments = _dump(body.attachments) effective_collection = { key: getattr(current_collection, key, None) for key in ( 'region_id', 'hospital_id', 'sales_user_id', 'region_manager_user_id', ) } effective_collection.update(collection) if ctx.scope_kind == 'specialist': customer['collector_user_id'] = ctx.user_id collection['sales_user_id'] = ctx.user_id effective_collection['sales_user_id'] = ctx.user_id else: unchanged_direct_assignment = bool( current_collection is not None and str(getattr(current_collection, 'region_id', None) or '').strip() == str(effective_collection.get('region_id') or '').strip() and str( getattr(current_collection, 'region_manager_user_id', None) or '' ).strip() == ctx.user_id and str(effective_collection.get('region_manager_user_id') or '').strip() == ctx.user_id ) if not unchanged_direct_assignment: _ensure_manager_assignment( db, ctx, region_id=effective_collection.get('region_id'), region_manager_user_id=effective_collection.get( 'region_manager_user_id' ), ) for key in ('sample_collect_time', 'maternal_blood_collect_time'): if key in collection and isinstance(collection[key], str): collection[key] = parse_datetime(collection[key]) if 'newborn_delivery_time' in customer and isinstance( customer['newborn_delivery_time'], str ): customer['newborn_delivery_time'] = parse_datetime( customer['newborn_delivery_time'] ) _validate_references( db, ctx, customer=customer, collection=effective_collection, samples=samples, extra_user_ids={body.collector_user_id} if ctx.scope_kind == 'manager' and body.collector_user_id else set(), ) return customer, collection, samples, attachments _SAMPLE_PRESERVED_FIELDS = ( 'sample_type_id', 'sample_code_id', 'sample_code', 'collect_amount', 'unit_id', 'price', 'original_price', 'storage_years', 'sample_status', 'storage_in_date', 'detect_start_date', 'void_date', 'storage_expire_date', 'is_special', 'special_remark', 'remark', ) def _validate_unique_sample_identity(samples: List[dict]) -> None: for field_name, api_name in ( ('id', 'sampleId'), ('sample_code', 'sampleCode'), ('sample_code_id', 'sampleCodeId'), ): values = [ str(item.get(field_name) or '').strip() for item in samples if str(item.get(field_name) or '').strip() ] if len(values) != len(set(values)): raise ValueError(f'{api_name} 不能重复') def _unique_sample_map(current_samples: List, field_name: str) -> dict: grouped = {} for item in current_samples: value = str(getattr(item, field_name, None) or '').strip() if value: grouped.setdefault(value, []).append(item) return { value: rows[0] for value, rows in grouped.items() if len(rows) == 1 } def _merge_existing_sample_fields(current_samples: List, samples: List[dict]) -> List[dict]: """按稳定身份沿用未展示字段;未匹配项一律按新样本处理。""" current_by_id = { str(getattr(item, 'id', None) or '').strip(): item for item in current_samples if str(getattr(item, 'id', None) or '').strip() } current_by_code_id = _unique_sample_map(current_samples, 'sample_code_id') current_by_code = _unique_sample_map(current_samples, 'sample_code') merged = [] for incoming in samples: sample_id = str(incoming.get('id') or '').strip() code = str(incoming.get('sample_code') or '').strip() code_id = str(incoming.get('sample_code_id') or '').strip() if sample_id: current = current_by_id.get(sample_id) if current is None: raise ValueError('样本不存在或不属于当前协议') else: current = current_by_code_id.get(code_id) or current_by_code.get(code) base = ( { key: getattr(current, key, None) for key in _SAMPLE_PRESERVED_FIELDS } if current is not None else {} ) base.update(incoming) base.pop('id', None) merged.append(base) return merged def _validate_mobile_attachments( db: Session, attachments: dict, *, ctx: MarketFieldContext, creating: bool, existing: Optional[dict] = None, business_id: Optional[str] = None, ) -> dict: current = existing or {} normalized = dict(attachments) for key, refs in attachments.items(): if refs is None: continue normalized[key] = validate_attachment_refs( db, refs, module='protocol', ctx=ctx, creating=creating, existing_refs=current.get(key), business_id=business_id, ) return normalized def _validate_v1_payment_detail_attachments( db: Session, details: Optional[List], *, row: BizCollectionProtocol, ctx: MarketFieldContext, ) -> Optional[List[dict]]: """Validate and bind legacy detail receipts through the protocol boundary.""" if details is None: return None payment = ( db.query(BizProtocolPayment) .filter( BizProtocolPayment.collection_protocol_id == row.id, BizProtocolPayment.tenant_no == ctx.tenant_no, BizProtocolPayment.del_flag == 0, ) .populate_existing() .with_for_update() .first() ) existing_rows = ( db.query(BizProtocolPaymentDetail) .filter( BizProtocolPaymentDetail.payment_id == payment.id, BizProtocolPaymentDetail.tenant_no == ctx.tenant_no, BizProtocolPaymentDetail.del_flag == 0, ) .populate_existing() .with_for_update() .all() if payment is not None else [] ) existing_by_id = {item.id: item for item in existing_rows} normalized: List[dict] = [] for item in details: raw = item.model_dump(by_alias=False, exclude_unset=True, mode='json') if 'transfer_attachments' not in raw: normalized.append(raw) continue current = existing_by_id.get(str(raw.get('id') or '').strip()) current_refs = current.transfer_attachments if current is not None else None if current is not None and current.pay_status != 'dfj': if str(raw.get('transfer_attachments') or '').strip() != str( current_refs or '' ).strip(): raise ValueError('锁定缴费明细的附件不可修改') raw['transfer_attachments'] = current_refs or '' else: raw['transfer_attachments'] = validate_attachment_refs( db, raw.get('transfer_attachments'), module='protocol', ctx=ctx, creating=current is None, existing_refs=current_refs, business_id=row.id, ) normalized.append(raw) return normalized def _party_name_cell(db: Session, ids: List[str], tenant_no: str) -> dict: if not ids: return {} rows = ( db.query(BizProtocolPartyCustomer) .filter( BizProtocolPartyCustomer.collection_protocol_id.in_(ids), BizProtocolPartyCustomer.tenant_no == tenant_no, BizProtocolPartyCustomer.del_flag == 0, ) .all() ) return {row.collection_protocol_id: row.mother_name or '' for row in rows} def _party_name_donation(db: Session, ids: List[str], tenant_no: str) -> dict: if not ids: return {} rows = ( db.query(BizResearchDonationParty) .filter( BizResearchDonationParty.research_donation_id.in_(ids), BizResearchDonationParty.tenant_no == tenant_no, BizResearchDonationParty.del_flag == 0, ) .all() ) 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': '人员携带'} void_type_labels = build_void_type_label_map( db, tenant_no, [str(item.get('voidType') or '') for item in items], ) 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 '', '' ), voidTypeLabel=void_type_labels.get( str(item.get('voidType') or ''), '' ), ) def list_protocols( db: Session, *, ctx: MarketFieldContext, user_info: dict, page: int = 1, page_size: int = 10, keyword: Optional[str] = None, source: Optional[str] = None, protocol_status: Optional[str] = None, create_time_start: Optional[str] = None, create_time_end: Optional[str] = None, sample_collect_time_start: Optional[str] = None, sample_collect_time_end: Optional[str] = None, ) -> Tuple[List[dict], int]: if source and source not in SOURCES: raise ValueError('不支持的协议来源') search = str(keyword or '').strip() def date_value(raw: Optional[str], *, end: bool = False) -> Optional[datetime]: text = str(raw or '').strip() if not text: return None try: parsed = datetime.strptime(text, '%Y-%m-%d') except ValueError as exc: raise ValueError('日期格式无效,应为 YYYY-MM-DD') from exc return parsed + timedelta(days=1) if end else parsed created_from = date_value(create_time_start) created_until = date_value(create_time_end, end=True) collected_from = date_value(sample_collect_time_start) collected_until = date_value(sample_collect_time_end, end=True) status = str(protocol_status or '').strip() rows = [] fetch_limit = max(1, page) * min(max(1, page_size), 100) total = 0 if source in (None, SOURCE_CELL): query = apply_cell_protocol_scope( db.query(BizCollectionProtocol), db, ctx, user_info=user_info ) if status: query = query.filter(BizCollectionProtocol.protocol_status == status) if created_from: query = query.filter(BizCollectionProtocol.create_time >= created_from) if created_until: query = query.filter(BizCollectionProtocol.create_time < created_until) if collected_from or collected_until: collection_conditions = [ BizProtocolCollectionInfo.collection_protocol_id == BizCollectionProtocol.id, BizProtocolCollectionInfo.tenant_no == ctx.tenant_no, BizProtocolCollectionInfo.del_flag == 0, ] if collected_from: collection_conditions.append( BizProtocolCollectionInfo.sample_collect_time >= collected_from ) if collected_until: collection_conditions.append( BizProtocolCollectionInfo.sample_collect_time < collected_until ) query = query.filter( db.query(BizProtocolCollectionInfo.id) .filter(*collection_conditions) .exists() .correlate(BizCollectionProtocol) ) if search: like = f'%{search}%' party_ids = db.query( BizProtocolPartyCustomer.collection_protocol_id ).filter( BizProtocolPartyCustomer.tenant_no == ctx.tenant_no, BizProtocolPartyCustomer.del_flag == 0, or_( BizProtocolPartyCustomer.mother_name.like(like), BizProtocolPartyCustomer.mother_id_card.like(like), BizProtocolPartyCustomer.mother_phone.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)) .scalar() or 0 ) cell_rows = ( query.order_by( BizCollectionProtocol.create_time.desc(), BizCollectionProtocol.id.desc(), ) .limit(fetch_limit) .all() ) names = _party_name_cell( db, [row.id for row in cell_rows], ctx.tenant_no ) rows.extend( { 'id': row.id, 'source': SOURCE_CELL, '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 ''), 'paymentModelVersion': int( getattr(row, 'payment_model_version', None) or 1 ), 'voidType': row.void_type 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, } for row in cell_rows ) if source in (None, SOURCE_DONATION): if status or collected_from or collected_until: donation_enabled = False else: donation_enabled = True if not donation_enabled: query = None else: query = apply_donation_scope( db.query(BizResearchDonation), db, ctx, user_info=user_info ) if query is None: donation_rows = [] else: if created_from: query = query.filter(BizResearchDonation.create_time >= created_from) if created_until: query = query.filter(BizResearchDonation.create_time < created_until) if query is not None and search: like = f'%{search}%' party_ids = db.query( BizResearchDonationParty.research_donation_id ).filter( BizResearchDonationParty.tenant_no == ctx.tenant_no, BizResearchDonationParty.del_flag == 0, or_( BizResearchDonationParty.mother_name.like(like), BizResearchDonationParty.mother_id_card.like(like), BizResearchDonationParty.mother_phone.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), ) ) if query is not None: total += int( query.order_by(None) .with_entities(func.count(BizResearchDonation.id)) .scalar() or 0 ) donation_rows = ( query.order_by( BizResearchDonation.create_time.desc(), BizResearchDonation.id.desc(), ) .limit(fetch_limit) .all() ) names = _party_name_donation( db, [row.id for row in donation_rows], ctx.tenant_no ) rows.extend( { 'id': row.id, '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 '', 'voidType': row.void_type 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 ) rows.sort( key=lambda item: (item['_created'], item['id']), reverse=True ) safe_page = max(1, page) safe_size = min(max(1, page_size), 100) 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 _attach_sample_freight( db: Session, sample_dicts: List[dict], *, tenant_no: str, sample_source: str, ) -> List[dict]: from app.crud import sample_freight as sample_freight_svc ids = [str(item.get('id') or '').strip() for item in sample_dicts if item.get('id')] freight_map = sample_freight_svc.freight_map_for_samples( db, tenant_no=tenant_no, sample_source=sample_source, sample_ids=ids, money_as_string=True, ) for item in sample_dicts: sid = str(item.get('id') or '').strip() freight = freight_map.get(sid) if freight: item['freight'] = freight item['freightId'] = freight.get('id') or '' else: item['freight'] = None item['freightId'] = '' return sample_dicts def _assert_sample_freight_targets( db: Session, *, protocol_id: Optional[str], samples: List[dict], ctx: MarketFieldContext, user_info: dict, ) -> None: """协议保存只允许保留原批次,或关联当前市场范围可访问的批次。""" existing_samples = ( cell_crud.list_samples(db, protocol_id, ctx.tenant_no) if protocol_id else [] ) freight_by_sample = sample_freight_crud.freight_map_for_samples( db, tenant_no=ctx.tenant_no, sample_source=SOURCE_CELL, sample_ids=[row.id for row in existing_samples], ) current_by_code = { str(row.sample_code or '').strip(): str( (freight_by_sample.get(row.id) or {}).get('id') or '' ) for row in existing_samples if str(row.sample_code or '').strip() } checked_targets = set() for sample in samples: code = str(sample.get('sample_code') or sample.get('sampleCode') or '').strip() target_id = str( sample.get('freight_id') or sample.get('freightId') or '' ).strip() draft = sample.get('freight_draft') or sample.get('freightDraft') current_id = current_by_code.get(code, '') if current_id and (draft or (target_id and target_id != current_id)): raise ValueError('样本已关联运费批次,请先通过运费功能取消关联') if not target_id or target_id == current_id or target_id in checked_targets: continue market_freight.assert_link_target_access( db, target_id, ctx=ctx, user_info=user_info, ) checked_targets.add(target_id) def _market_sample_payment_payload( db: Session, *, row: BizCollectionProtocol, body: MarketProtocolBody, ctx: MarketFieldContext, saved_samples: List[BizProtocolSample], ) -> List[dict]: """忽略客户端嵌套转账和样本 ID,只同步当前协议的缴费方案与期次。""" raw_payments = [ item.model_dump(by_alias=True, exclude_none=False) for item in (body.sample_payments or []) ] sample_by_code = { str(sample.sample_code or '').strip(): sample for sample in saved_samples if str(sample.sample_code or '').strip() } current_payments = ( db.query(BizProtocolSamplePayment) .filter( BizProtocolSamplePayment.collection_protocol_id == row.id, BizProtocolSamplePayment.tenant_no == ctx.tenant_no, BizProtocolSamplePayment.del_flag == 0, ) .all() ) payment_by_id = {payment.id: payment for payment in current_payments} payment_by_code = { str(payment.sample_code or '').strip(): payment for payment in current_payments } payment_ids = [payment.id for payment in current_payments] schedules = ( db.query(BizProtocolPaymentSchedule) .filter( BizProtocolPaymentSchedule.sample_payment_id.in_(payment_ids or ['']), BizProtocolPaymentSchedule.tenant_no == ctx.tenant_no, BizProtocolPaymentSchedule.del_flag == 0, ) .all() ) schedule_by_payment_no = { (schedule.sample_payment_id, int(schedule.schedule_no or 0)): schedule for schedule in schedules } schedule_ids = [schedule.id for schedule in schedules] transfers = ( db.query(BizProtocolPaymentTransfer) .filter( BizProtocolPaymentTransfer.schedule_id.in_(schedule_ids or ['']), BizProtocolPaymentTransfer.tenant_no == ctx.tenant_no, BizProtocolPaymentTransfer.del_flag == 0, ) .all() ) transfers_by_schedule = {} for transfer in transfers: transfers_by_schedule.setdefault(transfer.schedule_id, []).append(transfer) seen_codes = set() result = [] for raw in raw_payments: code = str(raw.get('sampleCode') or '').strip() if not code or code not in sample_by_code: raise ValueError('样本缴费方案不属于当前协议样本') if code in seen_codes: raise ValueError('同一样本不能重复配置缴费方案') seen_codes.add(code) supplied_id = str(raw.get('id') or '').strip() current = payment_by_id.get(supplied_id) if supplied_id else payment_by_code.get(code) if supplied_id and ( current is None or str(current.sample_code or '').strip() != code ): raise ValueError('样本缴费方案不存在或不属于当前协议样本') if current and payment_by_code.get(code) not in (None, current): raise ValueError('样本缴费方案与当前协议样本不一致') if current: raw['id'] = current.id else: raw.pop('id', None) raw['protocolSampleId'] = sample_by_code[code].id incoming_schedules = raw.get('schedules') or [] incoming_nos = { int(schedule.get('scheduleNo') or index + 1) for index, schedule in enumerate(incoming_schedules) } current_nos = { number for payment_id, number in schedule_by_payment_no if current and payment_id == current.id } current_transfers = [ transfer for schedule in schedules if current and schedule.sample_payment_id == current.id for transfer in transfers_by_schedule.get(schedule.id, []) ] if current and incoming_nos != current_nos and current_transfers: raise ValueError('存在转账记录时不可调整缴费期次结构') sanitized_schedules = [] for index, schedule_raw in enumerate(incoming_schedules): schedule = dict(schedule_raw) number = int(schedule.get('scheduleNo') or index + 1) existing_schedule = ( schedule_by_payment_no.get((current.id, number)) if current else None ) schedule.pop('id', None) schedule['scheduleStatus'] = ( existing_schedule.schedule_status if existing_schedule else 'open' ) schedule['transfers'] = ( [ {'id': transfer.id} for transfer in transfers_by_schedule.get(existing_schedule.id, []) ] if existing_schedule else [] ) sanitized_schedules.append(schedule) raw['schedules'] = sanitized_schedules result.append(raw) return result def _persist_sample_payments_if_any( db: Session, *, row: BizCollectionProtocol, body: MarketProtocolBody, ctx: MarketFieldContext, saved_samples: List[BizProtocolSample], ) -> None: """按数据库版本互斥保存 v1 整单缴费或 v2 按样本缴费方案。""" if body.source != SOURCE_CELL: if ( body.sample_payments is not None or body.payment is not None or body.payment_details is not None ): raise ValueError('科研捐赠不支持缴费方案') return version = int(getattr(row, 'payment_model_version', None) or 1) if version < 2: if body.sample_payments is not None: raise ValueError('旧版 v1 协议不支持 samplePayments 样本缴费方案') if body.payment is None and body.payment_details is None: return if body.payment is None and body.payment_details is not None: existing_payment = cell_crud.get_payment_by_protocol_id( db, row.id, ctx.tenant_no ) if existing_payment is None: raise ValueError('提交缴费明细前必须先提交缴费主表方案') payment_details = _validate_v1_payment_detail_attachments( db, body.payment_details, row=row, ctx=ctx, ) cell_crud.save_payment_with_details( db, row.id, ctx.tenant_no, ctx.user_id, payment=body.payment.model_dump( by_alias=False, exclude_unset=True, mode='json' ) if body.payment is not None else None, payment_details=payment_details, commit=False, ) return if body.payment is not None or body.payment_details is not None: raise ValueError('新版 v2 协议不支持 payment/paymentDetails 整单缴费方案') if body.sample_payments is None: return raw = _market_sample_payment_payload( db, row=row, body=body, ctx=ctx, saved_samples=saved_samples, ) psp_crud.save_sample_payments_for_protocol( db, collection_protocol_id=row.id, tenant_no=ctx.tenant_no, user_id=ctx.user_id, sample_payments=raw, ) 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) transports = cell_crud.list_sample_transports(db, row.id, tenant_no) transport_by_sample_id = {str(t.sample_id): t for t in transports} # qc_task 先于 qc_work_order 初始化,避免两者历史互相导入时拿到半初始化模块。 from app.crud import qc_task as _qc_task_svc # noqa: F401 web_bundle = cell_crud.get_protocol_detail_bundle(db, row.id, tenant_no) or {} web_samples = web_bundle.get('samples') or [] web_sample_by_id = { str(item.get('id') or ''): item for item in web_samples if item.get('id') } web_sample_by_code = { str(item.get('sampleCode') or ''): item for item in web_samples if item.get('sampleCode') } sample_dicts = _attach_sample_freight( db, _attach_sample_eta( [cell_crud._sample_to_dict(item) for item in samples], transport_by_sample_id, ), tenant_no=tenant_no, sample_source=SOURCE_CELL, ) lifecycle_keys = ( 'sampleReception', 'productionReception', 'qcInfo', 'processQcInfoList', 'productionInfo', 'storageReleaseInfo', ) for item in sample_dicts: web_sample = web_sample_by_id.get(str(item.get('id') or '')) or web_sample_by_code.get( str(item.get('sampleCode') or '') ) if not web_sample: continue for key in lifecycle_keys: item[key] = web_sample.get(key) version = int(getattr(row, 'payment_model_version', None) or 1) sample_payments = [] if version >= 2: sample_payments = psp_crud.get_sample_payments_bundle( db, collection_protocol_id=row.id, tenant_no=tenant_no, money_as_string=True, ) source_presign_id = getattr(row, 'source_presign_order_id', None) storage_cert = cell_crud.get_storage_cert(db, row.id, tenant_no) storage_cert_dict = ( cell_crud._storage_cert_to_dict(storage_cert) if storage_cert else None ) if storage_cert_dict: hid = str(storage_cert_dict.get('handlerUserId') or '').strip() if hid: hu = ( db.query(SysUser) .filter( SysUser.id == hid, SysUser.tenant_no == tenant_no, SysUser.del_flag == 0, ) .first() ) storage_cert_dict['handlerUserName'] = ( (hu.nick_name or '') if hu else '' ) else: storage_cert_dict['handlerUserName'] = '' delivery_dt = getattr(party, 'newborn_delivery_time', None) if party else None fullmoon = cell_crud.get_fullmoon_todo(db, row.id, tenant_no) fullmoon_dict = cell_crud._fullmoon_todo_detail_dict( fullmoon, newborn_delivery_time=delivery_dt ) hid2 = str(fullmoon_dict.get('handlerUserId') or '').strip() if hid2: hu2 = ( db.query(SysUser) .filter( SysUser.id == hid2, SysUser.tenant_no == tenant_no, SysUser.del_flag == 0, ) .first() ) fullmoon_dict['handlerUserName'] = (hu2.nick_name or '') if hu2 else '' else: fullmoon_dict['handlerUserName'] = '' audits, _ = cell_crud.list_audits( db, page=1, page_size=100, collection_protocol_id=row.id, tenant_no=tenant_no, ) basic_dict = cell_crud._protocol_to_dict(row) basic_dict['protocolTotalAmount'] = _fixed_money(row.protocol_total_amount) payment_dict = cell_crud._payment_to_dict(payment) if payment else None if payment_dict is not None: payment_dict['contractAmount'] = _fixed_money(payment.contract_amount) payment_details = list(web_bundle.get('paymentDetails') or []) if payment: detail_models = cell_crud.list_payment_details(db, payment.id, tenant_no) model_by_id = {str(item.id): item for item in detail_models} for item in payment_details: model = model_by_id.get(str(item.get('id') or '')) if model: for key, attr in ( ('dueAmount', 'due_amount'), ('transferAmount', 'transfer_amount'), ('refundAmount', 'refund_amount'), ): item[key] = _fixed_money(getattr(model, attr, None)) sample_model_by_id = {str(item.id): item for item in samples} for item in sample_dicts: model = sample_model_by_id.get(str(item.get('id') or '')) if model: item['price'] = _fixed_money(model.price) item['originalPrice'] = _fixed_money(model.original_price) detail = { 'source': SOURCE_CELL, 'basic': basic_dict, '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': sample_dicts, 'payment': payment_dict, 'paymentModelVersion': version, 'samplePayments': sample_payments, 'paymentDetails': payment_details, 'depositTransfers': psp_crud.list_deposit_transfers_for_protocol( db, source_presign_order_id=source_presign_id, tenant_no=tenant_no, money_as_string=True, ), 'presignDepositHint': psp_crud.get_presign_deposit_hint( db, source_presign_order_id=source_presign_id, tenant_no=tenant_no, money_as_string=True, ), 'storageCert': storage_cert_dict, 'fullmoonTodo': fullmoon_dict, 'audits': audits, 'statusChanges': cell_crud.list_protocol_status_changes( db, collection_protocol_id=row.id, tenant_no=tenant_no, ), 'attachments': { 'protocolAttachments': row.protocol_attachments or '', 'consentFormUrls': row.consent_form_urls or '', 'healthSurveyFormUrls': row.health_survey_form_urls or '', }, } _enrich_transfer_type_labels(db, detail, tenant_no) _enrich_protocol_detail_labels(db, detail, tenant_no) return detail def _donation_detail( db: Session, row: BizResearchDonation, tenant_no: str ) -> dict: # 与 Web 查看弹窗共用同一份详情 DTO,避免移动端再次拼装时遗漏生命周期字段。 from app.crud import qc_task as _qc_task_svc # noqa: F401 web_bundle = donation_crud.get_donation_detail(db, row.id, tenant_no) or {} basic = dict(web_bundle.get('donation') or donation_crud._donation_to_dict(row)) basic['convertTime'] = basic.get('convertTime') or basic.get('toStorageTime') or '' basic['convertDesc'] = basic.get('convertDesc') or basic.get('toStorageDesc') or '' audits = donation_crud.list_audits( db, tenant_no=tenant_no, page=1, page_size=100, research_donation_id=row.id, ) detail = { 'source': SOURCE_DONATION, 'basic': basic, 'collection': web_bundle.get('collection'), 'customer': web_bundle.get('party'), 'samples': list(web_bundle.get('samples') or []), 'audits': list(audits.get('list') or []), 'statusChanges': donation_crud.list_donation_status_changes( db, research_donation_id=row.id, tenant_no=tenant_no, ), 'attachments': { 'consentFormUrls': basic.get('consentFormUrls') or '', 'attachmentUrls': basic.get('attachmentUrls') or '', 'infectiousDiseaseReportUrls': basic.get('infectiousDiseaseReportUrls') or '', }, } _enrich_protocol_detail_labels(db, detail, tenant_no) return detail def _enrich_transfer_type_labels( db: Session, detail: dict, tenant_no: str ) -> None: """批量解析协议内所有转账方式;未知字典值保留原编码。""" label_map = { str(item.get('value') or ''): str(item.get('label') or '') for item in _dict_options(db, tenant_no, 'zzfs') } def apply(row: dict) -> None: code = str(row.get('transferType') or '').strip() if code: row['transferTypeLabel'] = ( label_map.get(code) or str(row.get('transferTypeLabel') or '').strip() or code ) for row in detail.get('depositTransfers') or []: apply(row) for row in detail.get('paymentDetails') or []: apply(row) for payment in detail.get('samplePayments') or []: for schedule in payment.get('schedules') or []: for row in schedule.get('transfers') or []: apply(row) 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 [] basic['voidTypeLabel'] = resolve_void_type_label( db, tenant_no, str(basic.get('voidType') 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 ''), str(item.get('sampleTypeName') or ''), ) item['unitName'] = unit_names.get( str(item.get('unitId') or ''), str(item.get('unitName') or ''), ) def get_protocol( db: Session, *, source: Optional[str], protocol_id: str, ctx: MarketFieldContext, user_info: dict, ) -> dict: if source == SOURCE_CELL: row = assert_cell_protocol_access( db, protocol_id, ctx, user_info=user_info ) return _cell_detail(db, row, ctx.tenant_no) if source == SOURCE_DONATION: row = assert_donation_access(db, protocol_id, ctx, user_info=user_info) return _donation_detail(db, row, ctx.tenant_no) if source is None: cell = ( apply_cell_protocol_scope( db.query(BizCollectionProtocol), db, ctx, user_info=user_info ) .filter(BizCollectionProtocol.id == protocol_id) .first() ) if cell is not None: return _cell_detail(db, cell, ctx.tenant_no) donation = ( apply_donation_scope( db.query(BizResearchDonation), db, ctx, user_info=user_info ) .filter(BizResearchDonation.id == protocol_id) .first() ) if donation is not None: return _donation_detail(db, donation, ctx.tenant_no) raise HTTPException(status_code=404, detail='数据不存在或无权访问') raise ValueError('不支持的协议来源') def _lock_cell_protocol_access( db: Session, protocol_id: str, ctx: MarketFieldContext, *, user_info: dict, ) -> BizCollectionProtocol: row = ( apply_cell_protocol_scope( db.query(BizCollectionProtocol), db, ctx, user_info=user_info ) .filter(BizCollectionProtocol.id == protocol_id) .populate_existing() .with_for_update() .first() ) if row is None: raise HTTPException(status_code=404, detail='数据不存在或无权访问') return row def _lock_donation_access( db: Session, protocol_id: str, ctx: MarketFieldContext, *, user_info: dict, ) -> BizResearchDonation: row = ( apply_donation_scope( db.query(BizResearchDonation), db, ctx, user_info=user_info ) .filter(BizResearchDonation.id == protocol_id) .populate_existing() .with_for_update() .first() ) if row is None: raise HTTPException(status_code=404, detail='数据不存在或无权访问') return row 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: _lock_cell_protocol_access( db, protocol_id, ctx, user_info=user_info ) cell_crud.soft_delete_collection_protocol( db, protocol_id, ctx.tenant_no, user_id=ctx.user_id ) return if source == SOURCE_DONATION: _lock_donation_access( db, protocol_id, ctx, user_info=user_info ) donation_crud.delete_donation( db, protocol_id, ctx.tenant_no, user_id=ctx.user_id ) return raise ValueError('不支持的协议来源') def create_protocol( db: Session, *, body: MarketProtocolBody, ctx: MarketFieldContext, user_info: dict, ) -> dict: if body.source == SOURCE_DONATION and ( body.sample_payments is not None or body.payment is not None or body.payment_details is not None ): raise ValueError('科研捐赠不支持缴费方案') customer, collection, samples, attachments = _normalize_write( db, body=body, ctx=ctx ) _assert_customer_link_policy(db, customer, ctx=ctx, user_info=user_info) dept_id = _current_dept_id(user_info) try: if body.source == SOURCE_CELL: _assert_sample_freight_targets( db, protocol_id=None, samples=samples, ctx=ctx, user_info=user_info, ) row = cell_crud.create_collection_protocol( db, protocol_no=body.protocol_no or '', # 对齐 Web 新增弹窗:默认生效中,移动端不接收客户端改状态 protocol_status='sxz', protocol_attachments=attachments.get('protocol_attachments'), consent_form_urls=attachments.get('consent_form_urls'), health_survey_form_urls=attachments.get( 'health_survey_form_urls' ), register_date=body.register_date, collector_user_id=ctx.user_id if ctx.scope_kind == 'specialist' else body.collector_user_id, protocol_total_amount=body.protocol_total_amount, remark=body.remark, tenant_no=ctx.tenant_no, user_id=ctx.user_id, create_dept_id=dept_id, commit=False, ) attachments = _validate_mobile_attachments( db, attachments, ctx=ctx, creating=True, business_id=row.id, ) row.protocol_attachments = attachments.get('protocol_attachments') row.consent_form_urls = attachments.get('consent_form_urls') row.health_survey_form_urls = attachments.get( 'health_survey_form_urls' ) db.add(row) cell_crud.upsert_party_customer( db, row.id, ctx.tenant_no, ctx.user_id, commit=False, create_dept_id=dept_id, **customer, ) cell_crud.upsert_collection_info( db, row.id, ctx.tenant_no, ctx.user_id, commit=False, create_dept_id=dept_id, **collection, ) saved_samples = cell_crud.save_samples( db, row.id, ctx.tenant_no, ctx.user_id, samples, create_dept_id=dept_id, commit=False, ) _persist_sample_payments_if_any( db, row=row, body=body, ctx=ctx, saved_samples=saved_samples, ) assert_cell_protocol_access(db, row.id, ctx, user_info=user_info) db.commit() db.refresh(row) return _cell_detail(db, row, ctx.tenant_no) if body.sample_payments: raise ValueError('科研捐赠不支持样本缴费方案') data = { 'donation_no': body.donation_no or '', # 对齐 Web 新增弹窗:默认生效中,移动端不接收客户端改状态 'donation_status': 'sxz', 'donation_date': body.donation_date, 'collector_user_id': ctx.user_id if ctx.scope_kind == 'specialist' else body.collector_user_id, 'remark': body.remark, 'party': customer, 'collection': collection, 'samples': samples, 'consent_form_urls': attachments.get('consent_form_urls'), 'attachment_urls': attachments.get('attachment_urls'), 'infectious_disease_report_urls': attachments.get( 'infectious_disease_report_urls' ), } row = donation_crud.create_donation( db, data=data, tenant_no=ctx.tenant_no, user_id=ctx.user_id, create_dept_id=dept_id, commit=False, sync_customers=False, ) attachments = _validate_mobile_attachments( db, attachments, ctx=ctx, creating=True, business_id=row.id, ) row.consent_form_urls = attachments.get('consent_form_urls') row.attachment_urls = attachments.get('attachment_urls') row.infectious_disease_report_urls = attachments.get( 'infectious_disease_report_urls' ) db.add(row) assert_donation_access(db, row.id, ctx, user_info=user_info) db.commit() db.refresh(row) return _donation_detail(db, row, ctx.tenant_no) except Exception: db.rollback() raise def update_protocol( db: Session, *, source: str, protocol_id: str, body: MarketProtocolBody, ctx: MarketFieldContext, user_info: dict, ) -> dict: if body.source != source: raise ValueError('请求来源与路径来源不一致') if source == SOURCE_DONATION and ( body.sample_payments is not None or body.payment is not None or body.payment_details is not None ): raise ValueError('科研捐赠不支持缴费方案') dept_id = _current_dept_id(user_info) try: if source == SOURCE_CELL: row = _lock_cell_protocol_access( db, protocol_id, ctx, user_info=user_info ) current_collection = cell_crud.get_collection_info( db, row.id, ctx.tenant_no ) current_party = cell_crud.get_party_customer( db, row.id, ctx.tenant_no ) current_samples = cell_crud.list_samples( db, row.id, ctx.tenant_no ) customer, collection, samples, attachments = _normalize_write( db, body=body, ctx=ctx, current_collection=current_collection, current_samples=current_samples, ) if body.samples is not None: _assert_sample_freight_targets( db, protocol_id=row.id, samples=samples, ctx=ctx, user_info=user_info, ) attachments = _validate_mobile_attachments( db, attachments, ctx=ctx, creating=False, existing={ 'protocol_attachments': row.protocol_attachments, 'consent_form_urls': row.consent_form_urls, 'health_survey_form_urls': row.health_survey_form_urls, }, business_id=row.id, ) _assert_customer_link_policy( db, customer, ctx=ctx, user_info=user_info ) cell_crud.update_collection_protocol( db, row.id, tenant_no=ctx.tenant_no, user_id=ctx.user_id, protocol_no=body.protocol_no, protocol_attachments=attachments.get('protocol_attachments'), consent_form_urls=attachments.get('consent_form_urls'), health_survey_form_urls=attachments.get( 'health_survey_form_urls' ), register_date=body.register_date, collector_user_id=ctx.user_id if ctx.scope_kind == 'specialist' else body.collector_user_id, protocol_total_amount=body.protocol_total_amount, remark=body.remark, commit=False, ) if body.customer is not None: if current_party is not None: customer = { **cell_crud._normalize_fields( cell_crud._party_customer_to_dict(current_party) ), **customer, } for metadata_key in ( 'id', 'collection_protocol_id', 'tenant_no', 'order_num', 'create_time', 'update_time', ): customer.pop(metadata_key, None) newborn_delivery_time = customer.get( 'newborn_delivery_time' ) if isinstance(newborn_delivery_time, str): if newborn_delivery_time.strip(): customer['newborn_delivery_time'] = parse_datetime( newborn_delivery_time ) else: customer.pop('newborn_delivery_time', None) cell_crud.upsert_party_customer( db, row.id, ctx.tenant_no, ctx.user_id, commit=False, create_dept_id=dept_id, **customer, ) if body.collection is not None: cell_crud.upsert_collection_info( db, row.id, ctx.tenant_no, ctx.user_id, commit=False, create_dept_id=dept_id, **collection, ) saved_samples = current_samples if body.samples is not None: saved_samples = cell_crud.save_samples( db, row.id, ctx.tenant_no, ctx.user_id, samples, create_dept_id=dept_id, commit=False, ) _persist_sample_payments_if_any( db, row=row, body=body, ctx=ctx, saved_samples=saved_samples, ) row = assert_cell_protocol_access( db, row.id, ctx, user_info=user_info ) db.commit() db.refresh(row) return _cell_detail(db, row, ctx.tenant_no) row = _lock_donation_access( db, protocol_id, ctx, user_info=user_info ) current_collection = donation_crud.get_collection( db, row.id, ctx.tenant_no ) current_party = donation_crud.get_party(db, row.id, ctx.tenant_no) current_samples = donation_crud.list_samples( db, row.id, ctx.tenant_no ) customer, collection, samples, attachments = _normalize_write( db, body=body, ctx=ctx, current_collection=current_collection, current_samples=current_samples, ) attachments = _validate_mobile_attachments( db, attachments, ctx=ctx, creating=False, existing={ 'consent_form_urls': row.consent_form_urls, 'attachment_urls': row.attachment_urls, 'infectious_disease_report_urls': row.infectious_disease_report_urls, }, business_id=row.id, ) _assert_customer_link_policy( db, customer, ctx=ctx, user_info=user_info ) if body.customer is not None and current_party is not None: customer = { **donation_crud._normalize_fields( donation_crud._party_to_dict(current_party) ), **customer, } if body.collection is not None and current_collection is not None: collection = { **donation_crud._normalize_fields( donation_crud._collection_to_dict(current_collection) ), **collection, } data = { 'donation_no': body.donation_no, 'donation_date': body.donation_date, 'collector_user_id': ctx.user_id if ctx.scope_kind == 'specialist' else body.collector_user_id, 'remark': body.remark, 'consent_form_urls': attachments.get('consent_form_urls'), 'attachment_urls': attachments.get('attachment_urls'), 'infectious_disease_report_urls': attachments.get( 'infectious_disease_report_urls' ), } if body.customer is not None: data['party'] = customer if body.collection is not None: data['collection'] = collection if body.samples is not None: data['samples'] = samples data = {key: value for key, value in data.items() if value is not None} row = donation_crud.update_donation( db, row.id, data=data, tenant_no=ctx.tenant_no, user_id=ctx.user_id, create_dept_id=dept_id, commit=False, sync_customers=False, ) row = assert_donation_access(db, row.id, ctx, user_info=user_info) db.commit() db.refresh(row) return _donation_detail(db, row, ctx.tenant_no) except Exception: db.rollback() raise