"""市场协议作废 ↔ 质检样本接收废弃双向联动。 规则(细胞存储 / 科研捐赠): 1. 协议变为已作废(yzf)时,协议下全部样本接收结论自动为废弃(bj)。 2. 协议下全部有效样本均已废弃/拒收(bj)时,协议自动已作废,作废类型为「实验室废弃」。 """ from __future__ import annotations from datetime import datetime from typing import List, Optional from sqlalchemy.orm import Session from app.core.utils import generate_uuid from app.models.models import ( BizCollectionProtocol, BizProtocolSample, BizSampleReception, SysDictData, ) from app.models.research_donation import BizResearchDonation, BizResearchDonationSample LAB_VOID_TYPE_CODE = "sysfq" LAB_VOID_TYPE_LABEL = "实验室废弃" VOID_TYPE_DICT = "xyzflx" _AUTO_DISCARD_REMARK = "协议作废自动废弃" _AUTO_VOID_DESC = "全部样本接收废弃/拒收,系统自动作废" def ensure_lab_void_type(db: Session, *, tenant_no: str) -> str: """确保租户字典 xyzflx 存在「实验室废弃」,返回 dict_value。""" tenant = (tenant_no or "").strip() if not tenant: raise ValueError("租户编号不能为空") by_value = ( db.query(SysDictData) .filter( SysDictData.dict_type == VOID_TYPE_DICT, SysDictData.dict_value == LAB_VOID_TYPE_CODE, SysDictData.tenant_no == tenant, SysDictData.del_flag == 0, ) .first() ) if by_value: return LAB_VOID_TYPE_CODE by_label = ( db.query(SysDictData) .filter( SysDictData.dict_type == VOID_TYPE_DICT, SysDictData.dict_label == LAB_VOID_TYPE_LABEL, SysDictData.tenant_no == tenant, SysDictData.del_flag == 0, ) .first() ) if by_label and (by_label.dict_value or "").strip(): return (by_label.dict_value or "").strip() now = datetime.now() db.add( SysDictData( id=generate_uuid(), dict_label=LAB_VOID_TYPE_LABEL, dict_value=LAB_VOID_TYPE_CODE, dict_type=VOID_TYPE_DICT, tenant_no=tenant, order_num=90, remark="issue#31 实验室废弃自动作废类型", create_dept_id="", create_time=now, update_time=now, del_flag=0, ) ) db.flush() return LAB_VOID_TYPE_CODE def _list_cell_samples(db: Session, *, protocol_id: str, tenant_no: str) -> List[BizProtocolSample]: return ( db.query(BizProtocolSample) .filter( BizProtocolSample.collection_protocol_id == protocol_id, BizProtocolSample.tenant_no == tenant_no, BizProtocolSample.del_flag == 0, ) .order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc()) .all() ) def _list_donation_samples( db: Session, *, donation_id: str, tenant_no: str ) -> List[BizResearchDonationSample]: return ( db.query(BizResearchDonationSample) .filter( BizResearchDonationSample.research_donation_id == donation_id, BizResearchDonationSample.tenant_no == tenant_no, BizResearchDonationSample.del_flag == 0, ) .order_by( BizResearchDonationSample.order_num.asc(), BizResearchDonationSample.create_time.asc(), ) .all() ) def _find_reception( db: Session, *, tenant_no: str, source: str, sample_code: str, ) -> Optional[BizSampleReception]: code = (sample_code or "").strip() if not code: return None return ( db.query(BizSampleReception) .filter( BizSampleReception.tenant_no == tenant_no, BizSampleReception.del_flag == 0, BizSampleReception.reception_source == source, BizSampleReception.sample_code == code, ) .first() ) def _upsert_discard_reception( db: Session, *, source: str, tenant_no: str, user_id: Optional[str], sample_code: str, protocol_sample_id: str = "", collection_protocol_id: str = "", donation_sample_id: Optional[str] = None, research_donation_id: Optional[str] = None, now: Optional[datetime] = None, ) -> None: code = (sample_code or "").strip() if not code: return ts = now or datetime.now() rec = _find_reception(db, tenant_no=tenant_no, source=source, sample_code=code) if rec: if (rec.accept_conclusion or "").strip() == "bj": return rec.accept_conclusion = "bj" rec.update_by = user_id rec.update_time = ts if not (rec.remark or "").strip(): rec.remark = _AUTO_DISCARD_REMARK db.add(rec) return obj = BizSampleReception( id=generate_uuid(), protocol_sample_id=protocol_sample_id or "", collection_protocol_id=collection_protocol_id or "", reception_time=ts, handler_user_id=user_id, handler_name=None, receive_temperature=None, accept_conclusion="bj", receive_attachments=None, reception_source=source, donation_sample_id=donation_sample_id, research_donation_id=research_donation_id, sample_code=code, lab_storage_code=None, tenant_no=tenant_no, remark=_AUTO_DISCARD_REMARK, create_by=user_id, create_dept_id="", update_by=user_id, create_time=ts, update_time=ts, del_flag=0, 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, ) db.add(obj) def discard_receptions_for_voided_protocol( db: Session, *, source: str, protocol_id: str, tenant_no: str, user_id: Optional[str] = None, ) -> int: """协议已作废后,将其下样本接收全部置为废弃。返回处理样本数。""" src = (source or "").strip() pid = (protocol_id or "").strip() tenant = (tenant_no or "").strip() if src not in ("cell_storage", "research_donation") or not pid or not tenant: return 0 now = datetime.now() if src == "cell_storage": samples = _list_cell_samples(db, protocol_id=pid, tenant_no=tenant) for sample in samples: _upsert_discard_reception( db, source=src, tenant_no=tenant, user_id=user_id, sample_code=sample.sample_code or "", protocol_sample_id=sample.id, collection_protocol_id=pid, now=now, ) else: samples = _list_donation_samples(db, donation_id=pid, tenant_no=tenant) for sample in samples: _upsert_discard_reception( db, source=src, tenant_no=tenant, user_id=user_id, sample_code=sample.sample_code or "", donation_sample_id=sample.id, research_donation_id=pid, now=now, ) db.flush() return len(samples) def _all_samples_discarded( db: Session, *, source: str, protocol_id: str, tenant_no: str, ) -> bool: src = (source or "").strip() if src == "cell_storage": samples = _list_cell_samples(db, protocol_id=protocol_id, tenant_no=tenant_no) else: samples = _list_donation_samples(db, donation_id=protocol_id, tenant_no=tenant_no) if not samples: return False for sample in samples: code = (sample.sample_code or "").strip() if not code: return False rec = _find_reception(db, tenant_no=tenant_no, source=src, sample_code=code) if not rec or (rec.accept_conclusion or "").strip() != "bj": return False return True def maybe_void_protocol_when_all_samples_discarded( db: Session, *, source: str, protocol_id: str, tenant_no: str, user_id: Optional[str] = None, ) -> bool: """若协议下样本全部废弃,则自动作废协议(作废类型=实验室废弃)。返回是否执行了作废。""" src = (source or "").strip() pid = (protocol_id or "").strip() tenant = (tenant_no or "").strip() if src not in ("cell_storage", "research_donation") or not pid or not tenant: return False if not _all_samples_discarded(db, source=src, protocol_id=pid, tenant_no=tenant): return False void_type = ensure_lab_void_type(db, tenant_no=tenant) now = datetime.now() if src == "cell_storage": obj = ( db.query(BizCollectionProtocol) .filter( BizCollectionProtocol.id == pid, BizCollectionProtocol.tenant_no == tenant, BizCollectionProtocol.del_flag == 0, ) .first() ) if not obj: return False status = (obj.protocol_status or "").strip() if status in ("yzf", "sdfq"): return False from app.core.protocol_status_display import assert_protocol_status_transition from app.crud.collection_protocol import update_collection_protocol try: assert_protocol_status_transition(status, "yzf", kind="protocol") except ValueError: return False update_collection_protocol( db, pid, tenant_no=tenant, user_id=user_id, protocol_status="yzf", void_time=now, void_desc=_AUTO_VOID_DESC, void_type=void_type, commit=False, sync_reception_discard=False, ) return True obj = ( db.query(BizResearchDonation) .filter( BizResearchDonation.id == pid, BizResearchDonation.tenant_no == tenant, BizResearchDonation.del_flag == 0, ) .first() ) if not obj: return False status = (obj.donation_status or "").strip() if status in ("yzf", "sdfq"): return False # 捐赠 change_status 默认 commit;此处内联等价逻辑避免提前提交。 from app.core.protocol_status_display import assert_protocol_status_transition from app.crud.dict_data import normalize_void_type from app.crud.research_donation import ( _DONATION_STATUS_CHANGE_TYPE, append_donation_status_change, ) current = obj.donation_status or "" try: assert_protocol_status_transition(current, "yzf", kind="donation") except ValueError: return False normalized = normalize_void_type(db, value=void_type, tenant_no=tenant) obj.donation_status = "yzf" obj.void_time = now obj.void_desc = _AUTO_VOID_DESC obj.void_type = normalized obj.update_by = user_id obj.update_time = now change_type = _DONATION_STATUS_CHANGE_TYPE.get("yzf") if change_type: append_donation_status_change( db, research_donation_id=obj.id, change_type=change_type, from_status=current, to_status="yzf", change_time=now, change_desc=_AUTO_VOID_DESC, void_type=normalized, tenant_no=tenant, user_id=user_id, ) db.add(obj) db.flush() return True def after_sample_reception_saved( db: Session, *, reception: BizSampleReception, tenant_no: str, user_id: Optional[str] = None, ) -> None: """样本接收保存后:若结论为废弃,尝试自动作废所属协议。""" if (reception.accept_conclusion or "").strip() != "bj": return source = (reception.reception_source or "cell_storage").strip() or "cell_storage" if source == "cell_storage": protocol_id = (reception.collection_protocol_id or "").strip() else: protocol_id = (reception.research_donation_id or "").strip() if not protocol_id: return maybe_void_protocol_when_all_samples_discarded( db, source=source, protocol_id=protocol_id, tenant_no=tenant_no, user_id=user_id, ) def after_protocol_voided( db: Session, *, source: str, protocol_id: str, tenant_no: str, user_id: Optional[str] = None, ) -> None: """协议作废后:同步废弃其下全部样本接收。""" discard_receptions_for_voided_protocol( db, source=source, protocol_id=protocol_id, tenant_no=tenant_no, user_id=user_id, )