When a cell-storage or research-donation protocol is voided, mark all related sample receptions as discarded; when every sample is discarded, auto-void the protocol as 实验室废弃 (sysfq). Covers issue #31. Co-authored-by: Cursor <cursoragent@cursor.com>
422 lines
12 KiB
Python
422 lines
12 KiB
Python
"""市场协议作废 ↔ 质检样本接收废弃双向联动。
|
|
|
|
规则(细胞存储 / 科研捐赠):
|
|
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,
|
|
)
|