Files
zkxw/backend/app/services/market_protocol.py
T
zlwuandCursor be22c37196 feat(backend): persist and return samplePayments for market protocols
Mobile protocol create/update saves v2 per-sample payments; detail
includes samplePayments for edit hydration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 15:23:24 +08:00

1420 lines
50 KiB
Python

"""移动市场协议服务:双来源统一展示,写入保持来源内原子性。"""
from __future__ import annotations
from datetime import datetime
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.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.models.models import (
BizCellSampleType,
BizCollectionHospital,
BizCollectionProtocol,
BizCollectionRegion,
BizCollectionRegionStaff,
BizCustomer,
BizItemUnit,
BizProtocolCollectionInfo,
BizProtocolPartyCustomer,
BizProtocolSample,
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.market_support import allowed_actions, validate_attachment_refs
SOURCE_CELL = 'cell_storage'
SOURCE_DONATION = 'research_donation'
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(
customer: dict, *, current_party=None, creating: bool
) -> None:
"""移动端不创建/改挂客户主档,只保留单据原有客户关联。"""
for key in _CUSTOMER_LINK_FIELDS:
if key not in customer:
continue
supplied = str(customer.get(key) or '').strip()
if creating:
if supplied:
raise ValueError('移动端创建协议不允许指定已有客户')
continue
current = str(getattr(current_party, key, None) or '').strip()
if supplied != current:
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:
if str(region_manager_user_id or '').strip() == ctx.user_id:
return True
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 _normalize_write(
db: Session,
*,
body: MarketProtocolBody,
ctx: MarketFieldContext,
current_collection=None,
) -> Tuple[dict, dict, List[dict], dict]:
customer = _dump(body.customer)
collection = _dump(body.collection)
samples = [_dump(item) for item in (body.samples or [])]
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:
_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
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 _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': '人员携带'}
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,
*,
ctx: MarketFieldContext,
user_info: dict,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
source: Optional[str] = None,
) -> Tuple[List[dict], int]:
if source and source not in SOURCES:
raise ValueError('不支持的协议来源')
search = str(keyword 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 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))
.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 ''),
'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):
query = apply_donation_scope(
db.query(BizResearchDonation), db, ctx, user_info=user_info
)
if 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))
.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 '',
'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 _persist_sample_payments_if_any(
db: Session,
*,
row: BizCollectionProtocol,
body: MarketProtocolBody,
ctx: MarketFieldContext,
) -> None:
"""细胞存储:将 body.sample_payments 写入 v2 按样本缴费方案。"""
if body.source != SOURCE_CELL:
if body.sample_payments:
raise ValueError('科研捐赠不支持样本缴费方案')
return
if body.sample_payments is None:
return
version = int(getattr(row, 'payment_model_version', None) or 1)
if version < 2:
row.payment_model_version = 2
db.add(row)
db.flush()
raw = [
p.model_dump(by_alias=True, exclude_none=False) for p in body.sample_payments
]
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}
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
)
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': _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,
'paymentModelVersion': version,
'samplePayments': sample_payments,
'attachments': {
'protocolAttachments': row.protocol_attachments or '',
'consentFormUrls': row.consent_form_urls or '',
'healthSurveyFormUrls': row.health_survey_form_urls or '',
},
}
_enrich_protocol_detail_labels(db, detail, tenant_no)
return detail
def _donation_detail(
db: Session, row: BizResearchDonation, tenant_no: str
) -> dict:
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)
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': _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 '',
'infectiousDiseaseReportUrls': row.infectious_disease_report_urls
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(
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 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,
*,
body: MarketProtocolBody,
ctx: MarketFieldContext,
user_info: dict,
) -> dict:
customer, collection, samples, attachments = _normalize_write(
db, body=body, ctx=ctx
)
_assert_customer_link_policy(customer, creating=True)
dept_id = _current_dept_id(user_info)
try:
if body.source == SOURCE_CELL:
row = cell_crud.create_collection_protocol(
db,
protocol_no=body.protocol_no or '',
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,
)
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)
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 '',
'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('请求来源与路径来源不一致')
dept_id = _current_dept_id(user_info)
try:
if source == SOURCE_CELL:
row = assert_cell_protocol_access(
db, protocol_id, ctx, user_info=user_info
)
if (row.protocol_status or '').strip() not in (
'',
'draft',
'dsx',
'dqr',
'sxz',
'ygq',
):
raise ValueError('当前状态的协议不可修改')
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
)
customer, collection, samples, attachments = _normalize_write(
db,
body=body,
ctx=ctx,
current_collection=current_collection,
)
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(
customer, current_party=current_party, creating=False
)
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,
)
if body.samples is not None:
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)
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 = assert_donation_access(db, protocol_id, ctx, user_info=user_info)
if (row.donation_status or '').strip() not in (
'',
'draft',
'dsx',
'dqr',
'sxz',
'ygq',
):
raise ValueError('当前状态的协议不可修改')
current_collection = donation_crud.get_collection(
db, row.id, ctx.tenant_no
)
current_party = donation_crud.get_party(db, row.id, ctx.tenant_no)
customer, collection, samples, attachments = _normalize_write(
db,
body=body,
ctx=ctx,
current_collection=current_collection,
)
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(
customer, current_party=current_party, creating=False
)
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