协议样本、生产产出和盘点明细按行更新并保留原 id;补齐存储证明质检预填、离职人员显示名,以及样本采集通知模板字段。 Co-authored-by: Cursor <cursoragent@cursor.com>
1211 lines
44 KiB
Python
1211 lines
44 KiB
Python
"""中试输注计划管理 CRUD(输注计划列表、详情、状态变更、编辑)"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from sqlalchemy import and_, desc, func, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.utils import generate_uuid
|
|
from app.crud.child_row_sync import sync_child_rows
|
|
from app.models.models import (
|
|
BizChannelAgreementProductPrice,
|
|
BizChannelServiceOrder,
|
|
BizChannelServiceOrderProduct,
|
|
BizChannelServiceOrderTxn,
|
|
BizPilotApplication,
|
|
BizPilotInfusionPlan,
|
|
BizPilotInfusionSchedule,
|
|
BizPilotInfusionScheduleProduct,
|
|
BizPilotPatient,
|
|
BizProductionWorkOrder,
|
|
BizQcWorkOrder,
|
|
)
|
|
from app.crud.pilot_service import (
|
|
_get_or_create_channel_account,
|
|
_reduce_channel_product_quota,
|
|
_resolve_agreement_price_cell_type,
|
|
)
|
|
from app.crud import outbound_handover as handover_svc
|
|
from app.crud import outbound_release_review as orr_svc
|
|
from app.crud import pilot_infusion_confirm as confirm_svc
|
|
|
|
|
|
# ────────────────────────────────────────────
|
|
# Status transition map
|
|
# ────────────────────────────────────────────
|
|
|
|
VALID_TRANSITIONS: Dict[str, set] = {
|
|
"dtz": {"yrqr", "yqx"},
|
|
"yrqr": {"ywc", "yqx"},
|
|
}
|
|
|
|
QC_VALID_TRANSITIONS: Dict[str, set] = {
|
|
"dzc": {"yzj", "yqx"},
|
|
}
|
|
|
|
|
|
# ────────────────────────────────────────────
|
|
# Helpers: _row_to_dict
|
|
# ────────────────────────────────────────────
|
|
|
|
|
|
def _row_to_dict_schedule(
|
|
row,
|
|
application=None,
|
|
plan=None,
|
|
patient=None,
|
|
products=None,
|
|
service_order=None,
|
|
plan_meta: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Any]:
|
|
course = _resolve_course_display(row, plan, plan_meta)
|
|
d: Dict[str, Any] = {
|
|
"id": row.id,
|
|
"infusionPlanId": row.infusion_plan_id or "",
|
|
"sortNo": course.get("sortNo"),
|
|
"totalCourseCount": course.get("totalCourseCount"),
|
|
"cellType": row.cell_type or "",
|
|
"specText": row.spec_text or "",
|
|
"packageId": getattr(row, "package_id", None) or "",
|
|
"packageText": getattr(row, "package_text", None) or "",
|
|
"infusionTime": row.infusion_time.strftime("%Y-%m-%d %H:%M:%S") if row.infusion_time else "",
|
|
"infusionLocation": row.infusion_location or "",
|
|
"infusionMethod": row.infusion_method or "",
|
|
"scheduleStatus": row.schedule_status or "",
|
|
"infusionStatus": row.infusion_status or "",
|
|
"cancelReason": row.cancel_reason or "",
|
|
"qcStatus": row.qc_status or "",
|
|
"attachments": row.attachments or "",
|
|
"remark": row.remark or "",
|
|
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S") if row.create_time else "",
|
|
"updateTime": row.update_time.strftime("%Y-%m-%d %H:%M:%S") if row.update_time else "",
|
|
}
|
|
|
|
if application:
|
|
d["applicationNo"] = application.application_no or ""
|
|
d["applicationName"] = application.application_name or ""
|
|
d["applicationStatus"] = application.application_status or ""
|
|
d["channelName"] = application.channel_name or ""
|
|
d["channelId"] = application.channel_id or ""
|
|
d["pilotApplicationId"] = application.id or ""
|
|
d["channelServiceOrderId"] = application.channel_service_order_id or ""
|
|
d["projectTitle"] = application.project_title or ""
|
|
d["customerName"] = application.customer_name or ""
|
|
d["bizType"] = application.biz_type or "pilot_service"
|
|
else:
|
|
d["applicationNo"] = ""
|
|
d["applicationName"] = ""
|
|
d["applicationStatus"] = ""
|
|
d["channelName"] = ""
|
|
d["channelId"] = ""
|
|
d["pilotApplicationId"] = ""
|
|
d["channelServiceOrderId"] = ""
|
|
d["projectTitle"] = ""
|
|
d["customerName"] = ""
|
|
d["bizType"] = ""
|
|
|
|
if plan:
|
|
d["channelAgreementId"] = plan.channel_agreement_id or ""
|
|
else:
|
|
d["channelAgreementId"] = ""
|
|
|
|
if patient:
|
|
d["patientName"] = patient.patient_name or ""
|
|
d["patientSex"] = patient.sex or ""
|
|
d["patientAge"] = patient.age
|
|
else:
|
|
d["patientName"] = ""
|
|
d["patientSex"] = ""
|
|
d["patientAge"] = None
|
|
|
|
if service_order:
|
|
d["orderType"] = service_order.order_type or ""
|
|
d["channelServiceOrderName"] = service_order.order_name or ""
|
|
else:
|
|
d["orderType"] = ""
|
|
d["channelServiceOrderName"] = ""
|
|
|
|
if products is not None:
|
|
d["products"] = products
|
|
else:
|
|
d["products"] = []
|
|
|
|
return d
|
|
|
|
|
|
def _row_to_dict_product(row) -> Dict[str, Any]:
|
|
return {
|
|
"id": row.id,
|
|
"priceId": row.price_id or "",
|
|
"projectCode": row.project_code or "",
|
|
"projectName": row.project_name or "",
|
|
"cellTypeName": row.cell_type_name or "",
|
|
"specText": row.spec_text or "",
|
|
"packageId": getattr(row, "package_id", None) or "",
|
|
"packageText": getattr(row, "package_text", None) or "",
|
|
"agreementPrice": float(row.agreement_price) if row.agreement_price is not None else None,
|
|
"actualPrice": float(row.actual_price) if row.actual_price is not None else None,
|
|
"quantity": row.quantity if row.quantity is not None else 1,
|
|
"remark": row.remark or "",
|
|
"productStatus": row.product_status or "",
|
|
"status": row.product_status or "",
|
|
}
|
|
|
|
|
|
def _get_plan_schedule_course_meta(
|
|
db: Session, plan_ids: List[str], tenant_no: str
|
|
) -> Dict[str, Dict[str, Any]]:
|
|
"""按输注方案汇总:计划条数、各计划内每条 schedule 的疗程序号(1 起)。"""
|
|
unique_ids = list({pid for pid in plan_ids if pid})
|
|
if not unique_ids:
|
|
return {}
|
|
|
|
rows = (
|
|
db.query(BizPilotInfusionSchedule)
|
|
.filter(
|
|
BizPilotInfusionSchedule.infusion_plan_id.in_(unique_ids),
|
|
BizPilotInfusionSchedule.tenant_no == tenant_no,
|
|
BizPilotInfusionSchedule.del_flag == 0,
|
|
)
|
|
.order_by(
|
|
BizPilotInfusionSchedule.infusion_plan_id.asc(),
|
|
BizPilotInfusionSchedule.order_num.asc(),
|
|
BizPilotInfusionSchedule.create_time.asc(),
|
|
)
|
|
.all()
|
|
)
|
|
|
|
meta: Dict[str, Dict[str, Any]] = {}
|
|
by_plan: Dict[str, List[BizPilotInfusionSchedule]] = {}
|
|
for row in rows:
|
|
by_plan.setdefault(row.infusion_plan_id, []).append(row)
|
|
|
|
for plan_id, schedules in by_plan.items():
|
|
sort_no_by_id: Dict[str, int] = {}
|
|
for idx, sch in enumerate(schedules):
|
|
order_num = sch.order_num if sch.order_num is not None else 0
|
|
sort_no_by_id[sch.id] = order_num if order_num > 0 else idx + 1
|
|
meta[plan_id] = {
|
|
"scheduleCount": len(schedules),
|
|
"sortNoByScheduleId": sort_no_by_id,
|
|
}
|
|
return meta
|
|
|
|
|
|
def _resolve_course_display(
|
|
row: BizPilotInfusionSchedule,
|
|
plan: Optional[BizPilotInfusionPlan],
|
|
plan_meta: Optional[Dict[str, Any]] = None,
|
|
) -> Dict[str, Optional[int]]:
|
|
sort_no: Optional[int] = None
|
|
total: Optional[int] = None
|
|
|
|
if plan_meta:
|
|
sort_no = plan_meta.get("sortNoByScheduleId", {}).get(row.id)
|
|
plan_total = plan.total_course_count if plan else None
|
|
if plan_total is not None and plan_total > 0:
|
|
total = int(plan_total)
|
|
else:
|
|
total = int(plan_meta.get("scheduleCount") or 0) or None
|
|
elif plan and row.infusion_plan_id:
|
|
order_num = row.order_num if row.order_num is not None else 0
|
|
if order_num > 0:
|
|
sort_no = int(order_num)
|
|
plan_total = plan.total_course_count
|
|
if plan_total is not None and plan_total > 0:
|
|
total = int(plan_total)
|
|
|
|
if sort_no is None or sort_no <= 0:
|
|
order_num = row.order_num if row.order_num is not None else 0
|
|
sort_no = int(order_num) if order_num > 0 else None
|
|
|
|
return {"sortNo": sort_no, "totalCourseCount": total if total and total > 0 else None}
|
|
|
|
|
|
def _get_products_for_schedule(db: Session, schedule_id: str, tenant_no: str) -> List[Dict[str, Any]]:
|
|
rows = (
|
|
db.query(BizPilotInfusionScheduleProduct)
|
|
.filter(
|
|
BizPilotInfusionScheduleProduct.infusion_schedule_id == schedule_id,
|
|
BizPilotInfusionScheduleProduct.tenant_no == tenant_no,
|
|
BizPilotInfusionScheduleProduct.del_flag == 0,
|
|
)
|
|
.order_by(
|
|
BizPilotInfusionScheduleProduct.order_num.asc(),
|
|
BizPilotInfusionScheduleProduct.create_time.asc(),
|
|
)
|
|
.all()
|
|
)
|
|
return [_row_to_dict_product(r) for r in rows]
|
|
|
|
|
|
# ────────────────────────────────────────────
|
|
# List (Paginated, Multi-table JOIN)
|
|
# ────────────────────────────────────────────
|
|
|
|
|
|
def list_infusion_schedules_mgmt(
|
|
db: Session,
|
|
tenant_no: str,
|
|
page: int = 1,
|
|
page_size: int = 10,
|
|
keyword: Optional[str] = None,
|
|
infusion_status: Optional[str] = None,
|
|
qc_status: Optional[str] = None,
|
|
infusion_time_start: Optional[str] = None,
|
|
infusion_time_end: Optional[str] = None,
|
|
biz_type: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
sch = BizPilotInfusionSchedule
|
|
plan = BizPilotInfusionPlan
|
|
app = BizPilotApplication
|
|
pat = BizPilotPatient
|
|
prod = BizPilotInfusionScheduleProduct
|
|
|
|
q = (
|
|
db.query(sch)
|
|
.outerjoin(plan, and_(sch.infusion_plan_id == plan.id, plan.del_flag == 0))
|
|
.outerjoin(app, and_(plan.pilot_application_id == app.id, app.del_flag == 0))
|
|
.outerjoin(pat, and_(app.id == pat.pilot_application_id, pat.del_flag == 0))
|
|
.filter(
|
|
sch.tenant_no == tenant_no,
|
|
sch.del_flag == 0,
|
|
)
|
|
)
|
|
|
|
bt = (biz_type or "").strip()
|
|
if bt:
|
|
q = q.filter(app.biz_type == bt)
|
|
|
|
# keyword: application_no, channel_name, patient_name; basic_experiment also project_title/customer_name
|
|
if keyword:
|
|
kw = f"%{keyword}%"
|
|
q = q.filter(
|
|
or_(
|
|
app.application_no.like(kw),
|
|
app.channel_name.like(kw),
|
|
app.project_title.like(kw),
|
|
app.customer_name.like(kw),
|
|
pat.patient_name.like(kw),
|
|
)
|
|
)
|
|
|
|
if infusion_status:
|
|
statuses = [s.strip() for s in infusion_status.split(",") if s.strip()]
|
|
if statuses:
|
|
q = q.filter(sch.infusion_status.in_(statuses))
|
|
|
|
if qc_status:
|
|
qc_statuses = [s.strip() for s in qc_status.split(",") if s.strip()]
|
|
if qc_statuses:
|
|
q = q.filter(sch.qc_status.in_(qc_statuses))
|
|
|
|
time_bounds = []
|
|
if infusion_time_start:
|
|
try:
|
|
start_dt = datetime.strptime(infusion_time_start[:10], "%Y-%m-%d")
|
|
time_bounds.append(sch.infusion_time >= start_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
if infusion_time_end:
|
|
try:
|
|
end_dt = datetime.strptime(infusion_time_end[:10], "%Y-%m-%d")
|
|
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
|
time_bounds.append(sch.infusion_time <= end_dt)
|
|
except ValueError:
|
|
pass
|
|
|
|
# 未填写输注时间的计划(申请单新建时常见)在限定时间范围时仍应可见
|
|
if time_bounds:
|
|
q = q.filter(or_(and_(*time_bounds), sch.infusion_time.is_(None)))
|
|
|
|
total = q.order_by(None).with_entities(func.count(sch.id)).scalar() or 0
|
|
page = max(1, page)
|
|
page_size = min(max(1, page_size), 100)
|
|
|
|
# Use distinct to avoid duplicates from JOINs
|
|
# MySQL 5.7 不支持 NULLS LAST:先排「有输注时间」再按时间倒序,未填时间的排最后
|
|
schedule_rows = (
|
|
q.order_by(sch.infusion_time.is_(None), desc(sch.infusion_time), desc(sch.create_time))
|
|
.distinct()
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
.all()
|
|
)
|
|
|
|
plan_ids = [r.infusion_plan_id for r in schedule_rows if r.infusion_plan_id]
|
|
plan_course_meta = _get_plan_schedule_course_meta(db, plan_ids, tenant_no)
|
|
|
|
items = []
|
|
for row in schedule_rows:
|
|
# Fetch related data for each schedule
|
|
plan_row = (
|
|
db.query(BizPilotInfusionPlan)
|
|
.filter(
|
|
BizPilotInfusionPlan.id == row.infusion_plan_id,
|
|
BizPilotInfusionPlan.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
application = None
|
|
patient = None
|
|
if plan_row:
|
|
application = (
|
|
db.query(BizPilotApplication)
|
|
.filter(
|
|
BizPilotApplication.id == plan_row.pilot_application_id,
|
|
BizPilotApplication.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if application:
|
|
patient = (
|
|
db.query(BizPilotPatient)
|
|
.filter(
|
|
BizPilotPatient.pilot_application_id == application.id,
|
|
BizPilotPatient.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
service_order = None
|
|
if application and application.channel_service_order_id:
|
|
service_order = (
|
|
db.query(BizChannelServiceOrder)
|
|
.filter(
|
|
BizChannelServiceOrder.id == application.channel_service_order_id,
|
|
BizChannelServiceOrder.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
products = _get_products_for_schedule(db, row.id, tenant_no)
|
|
plan_meta = plan_course_meta.get(row.infusion_plan_id) if row.infusion_plan_id else None
|
|
items.append(
|
|
_row_to_dict_schedule(
|
|
row,
|
|
application,
|
|
plan_row,
|
|
patient,
|
|
products,
|
|
service_order,
|
|
plan_meta=plan_meta,
|
|
)
|
|
)
|
|
|
|
schedule_ids = [item["id"] for item in items if item.get("id")]
|
|
if schedule_ids:
|
|
from app.crud import clinical_payment
|
|
|
|
payment_map = clinical_payment.map_payments_by_schedule_ids(
|
|
db, schedule_ids=schedule_ids, tenant_no=tenant_no
|
|
)
|
|
for item in items:
|
|
payment = payment_map.get(item["id"])
|
|
if not payment:
|
|
continue
|
|
item["payment"] = payment
|
|
item["amount"] = payment.get("amount")
|
|
item["payStatus"] = payment.get("payStatus")
|
|
|
|
return {
|
|
"list": items,
|
|
"pagination": {"page": page, "pageSize": page_size, "total": total},
|
|
}
|
|
|
|
|
|
# ────────────────────────────────────────────
|
|
# Detail
|
|
# ────────────────────────────────────────────
|
|
|
|
|
|
def get_infusion_schedule_detail(
|
|
db: Session, schedule_id: str, tenant_no: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
row = (
|
|
db.query(BizPilotInfusionSchedule)
|
|
.filter(
|
|
BizPilotInfusionSchedule.id == schedule_id,
|
|
BizPilotInfusionSchedule.tenant_no == tenant_no,
|
|
BizPilotInfusionSchedule.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if not row:
|
|
return None
|
|
|
|
plan_row = (
|
|
db.query(BizPilotInfusionPlan)
|
|
.filter(
|
|
BizPilotInfusionPlan.id == row.infusion_plan_id,
|
|
BizPilotInfusionPlan.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
application = None
|
|
patient = None
|
|
if plan_row:
|
|
application = (
|
|
db.query(BizPilotApplication)
|
|
.filter(
|
|
BizPilotApplication.id == plan_row.pilot_application_id,
|
|
BizPilotApplication.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if application:
|
|
patient = (
|
|
db.query(BizPilotPatient)
|
|
.filter(
|
|
BizPilotPatient.pilot_application_id == application.id,
|
|
BizPilotPatient.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
service_order = None
|
|
if application and application.channel_service_order_id:
|
|
service_order = (
|
|
db.query(BizChannelServiceOrder)
|
|
.filter(
|
|
BizChannelServiceOrder.id == application.channel_service_order_id,
|
|
BizChannelServiceOrder.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
products = _get_products_for_schedule(db, row.id, tenant_no)
|
|
plan_meta = None
|
|
if row.infusion_plan_id:
|
|
batch_meta = _get_plan_schedule_course_meta(db, [row.infusion_plan_id], tenant_no)
|
|
plan_meta = batch_meta.get(row.infusion_plan_id)
|
|
flat = _row_to_dict_schedule(
|
|
row, application, plan_row, patient, products, service_order, plan_meta=plan_meta
|
|
)
|
|
|
|
aggregated = orr_svc.get_schedule_detail(db, schedule_id, tenant_no) or {}
|
|
|
|
app = dict(aggregated.get("application") or {})
|
|
app["applicationNo"] = app.get("applicationNo") or flat.get("applicationNo") or ""
|
|
app["applicationName"] = app.get("applicationName") or flat.get("applicationName") or ""
|
|
app["applicationStatus"] = app.get("applicationStatus") or flat.get("applicationStatus") or ""
|
|
app["channelName"] = app.get("channelName") or flat.get("channelName") or ""
|
|
app["orderType"] = flat.get("orderType") or ""
|
|
app["channelServiceOrderName"] = flat.get("channelServiceOrderName") or ""
|
|
app["channelServiceOrderId"] = flat.get("channelServiceOrderId") or ""
|
|
app["projectTitle"] = app.get("projectTitle") or flat.get("projectTitle") or ""
|
|
app["customerName"] = app.get("customerName") or flat.get("customerName") or ""
|
|
if application:
|
|
app["bizType"] = application.biz_type or "pilot_service"
|
|
if not app.get("projectTitle"):
|
|
app["projectTitle"] = application.project_title or ""
|
|
if not app.get("customerName"):
|
|
app["customerName"] = application.customer_name or ""
|
|
|
|
plan = dict(aggregated.get("infusionPlan") or {})
|
|
if plan_row:
|
|
plan.setdefault("cellType", plan_row.cell_type or "")
|
|
plan.setdefault("specText", plan_row.spec_text or "")
|
|
plan.setdefault("totalCourseCount", plan_row.total_course_count)
|
|
plan.setdefault("courseInterval", plan_row.course_interval or "")
|
|
plan.setdefault("infusionStartDate", plan_row.infusion_start_date or "")
|
|
|
|
sched = dict(aggregated.get("infusionSchedule") or {})
|
|
sched["sortNo"] = flat.get("sortNo")
|
|
sched["totalCourseCount"] = flat.get("totalCourseCount")
|
|
sched["cellType"] = flat.get("cellType") or sched.get("cellType") or ""
|
|
sched["specText"] = flat.get("specText") or sched.get("specText") or ""
|
|
sched["packageId"] = flat.get("packageId") or sched.get("packageId") or ""
|
|
sched["packageText"] = flat.get("packageText") or sched.get("packageText") or ""
|
|
sched["infusionTime"] = flat.get("infusionTime") or sched.get("infusionTime") or ""
|
|
sched["infusionLocation"] = flat.get("infusionLocation") or sched.get("infusionLocation") or ""
|
|
sched["infusionMethod"] = flat.get("infusionMethod") or sched.get("infusionMethod") or ""
|
|
sched["scheduleStatus"] = flat.get("scheduleStatus") or sched.get("scheduleStatus") or ""
|
|
sched["infusionStatus"] = flat.get("infusionStatus") or sched.get("infusionStatus") or ""
|
|
sched["qcStatus"] = flat.get("qcStatus") or sched.get("qcStatus") or ""
|
|
sched["cancelReason"] = flat.get("cancelReason") or sched.get("cancelReason") or ""
|
|
sched["remark"] = flat.get("remark") or ""
|
|
|
|
patient_data = dict(aggregated.get("patient") or {})
|
|
if patient:
|
|
patient_data.setdefault("patientName", patient.patient_name or "")
|
|
patient_data.setdefault("sex", patient.sex or "")
|
|
patient_data.setdefault("age", str(patient.age) if patient.age is not None else "")
|
|
patient_data.setdefault("phoneNo", patient.phone_no or "")
|
|
patient_data.setdefault("idCardNo", patient.id_card_no or "")
|
|
patient_data.setdefault("emergencyContact", patient.emergency_contact or "")
|
|
patient_data.setdefault("emergencyPhone", patient.emergency_phone or "")
|
|
patient_data.setdefault("clinicalDiagnosis", patient.clinical_diagnosis or "")
|
|
patient_data.setdefault("diagnosisAttachments", patient.diagnosis_attachments or "")
|
|
patient_data.setdefault("needCheckup", patient.need_checkup)
|
|
|
|
result: Dict[str, Any] = {
|
|
**aggregated,
|
|
**flat,
|
|
"application": app,
|
|
"patient": patient_data,
|
|
"infusionPlan": plan,
|
|
"infusionSchedule": sched,
|
|
"products": flat.get("products") or aggregated.get("products") or [],
|
|
"productionInfo": aggregated.get("productionInfo") or {},
|
|
"qcInfo": aggregated.get("qcInfo") or {},
|
|
"processQcInfoList": aggregated.get("processQcInfoList") or [],
|
|
"reviewInfo": aggregated.get("reviewInfo") or {},
|
|
"outboundMaterial": aggregated.get("outboundMaterial") or {},
|
|
"outboundHandover": handover_svc.get_outbound_handover_view(db, schedule_id, tenant_no),
|
|
"infusionConfirm": confirm_svc.get_confirm_by_schedule(db, schedule_id, tenant_no) or {},
|
|
}
|
|
if application and (application.biz_type or "") in ("clinical_application", "basic_experiment"):
|
|
from app.crud import clinical_payment
|
|
|
|
payment = clinical_payment.get_payment_by_schedule(
|
|
db, schedule_id=schedule_id, tenant_no=tenant_no
|
|
)
|
|
if payment:
|
|
result["payment"] = payment
|
|
return result
|
|
|
|
|
|
# ────────────────────────────────────────────
|
|
# Status update
|
|
# ────────────────────────────────────────────
|
|
|
|
|
|
def update_infusion_status(
|
|
db: Session,
|
|
schedule_id: str,
|
|
infusion_status: str,
|
|
cancel_reason: Optional[str],
|
|
tenant_no: str,
|
|
user_id: str,
|
|
) -> Dict[str, Any]:
|
|
row = (
|
|
db.query(BizPilotInfusionSchedule)
|
|
.filter(
|
|
BizPilotInfusionSchedule.id == schedule_id,
|
|
BizPilotInfusionSchedule.tenant_no == tenant_no,
|
|
BizPilotInfusionSchedule.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise ValueError("输注计划不存在")
|
|
|
|
current = row.infusion_status or ""
|
|
if current not in VALID_TRANSITIONS:
|
|
raise ValueError(f"当前状态 {current} 不允许变更")
|
|
if infusion_status not in VALID_TRANSITIONS.get(current, set()):
|
|
raise ValueError(f"不允许从 {current} 变更为 {infusion_status}")
|
|
|
|
# Cancel requires reason
|
|
if infusion_status == "yqx" and not cancel_reason:
|
|
raise ValueError("取消时需填写取消原因")
|
|
|
|
now = datetime.now()
|
|
row.infusion_status = infusion_status
|
|
row.update_by = user_id
|
|
row.update_time = now
|
|
|
|
if infusion_status == "yqx":
|
|
row.cancel_reason = cancel_reason or ""
|
|
row.schedule_status = "yqx"
|
|
row.qc_status = "yqx"
|
|
|
|
# 自动作废关联的未完成出库生产工单
|
|
outbound_wo = (
|
|
db.query(BizProductionWorkOrder)
|
|
.filter(
|
|
BizProductionWorkOrder.infusion_schedule_id == schedule_id,
|
|
BizProductionWorkOrder.tenant_no == tenant_no,
|
|
BizProductionWorkOrder.del_flag == 0,
|
|
BizProductionWorkOrder.work_order_type == "outbound",
|
|
BizProductionWorkOrder.work_order_status.notin_(["ywc", "yzf"]),
|
|
)
|
|
.first()
|
|
)
|
|
if outbound_wo:
|
|
outbound_wo.work_order_status = "yzf"
|
|
outbound_wo.update_by = user_id
|
|
outbound_wo.update_time = now
|
|
|
|
# 自动作废关联的未完成出库质检工单
|
|
pending_qc_orders = (
|
|
db.query(BizQcWorkOrder)
|
|
.filter(
|
|
BizQcWorkOrder.infusion_schedule_id == schedule_id,
|
|
BizQcWorkOrder.work_order_status.notin_(["ywc", "yzf"]),
|
|
BizQcWorkOrder.del_flag == 0,
|
|
)
|
|
.all()
|
|
)
|
|
for order in pending_qc_orders:
|
|
order.work_order_status = "yzf"
|
|
order.update_by = user_id
|
|
order.update_time = now
|
|
|
|
from app.crud.pilot_outbound_status_sync import sync_pilot_outbound_from_schedule
|
|
|
|
sync_pilot_outbound_from_schedule(db, row, user_id=user_id)
|
|
|
|
# Sync all related products' product_status
|
|
product_rows = (
|
|
db.query(BizPilotInfusionScheduleProduct)
|
|
.filter(
|
|
BizPilotInfusionScheduleProduct.infusion_schedule_id == schedule_id,
|
|
BizPilotInfusionScheduleProduct.tenant_no == tenant_no,
|
|
BizPilotInfusionScheduleProduct.del_flag == 0,
|
|
)
|
|
.all()
|
|
)
|
|
for p_row in product_rows:
|
|
p_row.product_status = infusion_status
|
|
p_row.update_by = user_id
|
|
p_row.update_time = now
|
|
|
|
db.flush()
|
|
|
|
# If completing, trigger fee deduction
|
|
if infusion_status == "ywc":
|
|
_deduct_fee_on_complete(db, schedule_id, tenant_no, user_id)
|
|
|
|
db.commit()
|
|
db.refresh(row)
|
|
return get_infusion_schedule_detail(db, row.id, tenant_no)
|
|
|
|
|
|
def update_qc_status(
|
|
db: Session,
|
|
schedule_id: str,
|
|
qc_status: str,
|
|
tenant_no: str,
|
|
user_id: str,
|
|
) -> Dict[str, Any]:
|
|
row = (
|
|
db.query(BizPilotInfusionSchedule)
|
|
.filter(
|
|
BizPilotInfusionSchedule.id == schedule_id,
|
|
BizPilotInfusionSchedule.tenant_no == tenant_no,
|
|
BizPilotInfusionSchedule.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise ValueError("输注计划不存在")
|
|
|
|
current = row.qc_status or ""
|
|
if current not in QC_VALID_TRANSITIONS:
|
|
raise ValueError(f"当前质检状态 {current} 不允许变更")
|
|
if qc_status not in QC_VALID_TRANSITIONS.get(current, set()):
|
|
raise ValueError(f"不允许从质检状态 {current} 变更为 {qc_status}")
|
|
|
|
now = datetime.now()
|
|
row.qc_status = qc_status
|
|
row.update_by = user_id
|
|
row.update_time = now
|
|
|
|
from app.crud.pilot_outbound_status_sync import sync_pilot_outbound_from_schedule
|
|
|
|
sync_pilot_outbound_from_schedule(db, row, user_id=user_id)
|
|
|
|
db.commit()
|
|
db.refresh(row)
|
|
return get_infusion_schedule_detail(db, row.id, tenant_no)
|
|
|
|
|
|
def _settlement_remark_marker(schedule_id: str) -> str:
|
|
"""幂等检索用标记(中文);兼容历史英文标记 infusionScheduleId="""
|
|
return f"输注计划ID={schedule_id}"
|
|
|
|
|
|
def _settlement_remark_markers_for_lookup(schedule_id: str) -> List[str]:
|
|
return [_settlement_remark_marker(schedule_id), f"infusionScheduleId={schedule_id}"]
|
|
|
|
|
|
def _find_existing_settlement_txn(
|
|
db: Session, order_id: str, schedule_id: str
|
|
) -> Optional[BizChannelServiceOrderTxn]:
|
|
marker_filters = [
|
|
BizChannelServiceOrderTxn.remark.like(f"%{m}%")
|
|
for m in _settlement_remark_markers_for_lookup(schedule_id)
|
|
]
|
|
return (
|
|
db.query(BizChannelServiceOrderTxn)
|
|
.filter(
|
|
BizChannelServiceOrderTxn.service_order_id == order_id,
|
|
BizChannelServiceOrderTxn.txn_type == "settlement",
|
|
or_(*marker_filters),
|
|
BizChannelServiceOrderTxn.txn_status != "yzf",
|
|
BizChannelServiceOrderTxn.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
|
|
def _resolve_yfk_unit_price(
|
|
db: Session, agreement_id: str, product: BizPilotInfusionScheduleProduct
|
|
) -> float:
|
|
spec = (product.spec_text or "").strip()
|
|
if agreement_id and (product.project_code or "").strip():
|
|
row = (
|
|
db.query(BizChannelAgreementProductPrice)
|
|
.filter(
|
|
BizChannelAgreementProductPrice.channel_agreement_id == agreement_id,
|
|
BizChannelAgreementProductPrice.project_code == (product.project_code or ""),
|
|
BizChannelAgreementProductPrice.spec_text == spec,
|
|
BizChannelAgreementProductPrice.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if row and row.agreement_price is not None:
|
|
return float(row.agreement_price)
|
|
|
|
cell_name = (product.cell_type_name or "").strip()
|
|
if agreement_id and cell_name:
|
|
row = (
|
|
db.query(BizChannelAgreementProductPrice)
|
|
.filter(
|
|
BizChannelAgreementProductPrice.channel_agreement_id == agreement_id,
|
|
BizChannelAgreementProductPrice.spec_text == spec,
|
|
BizChannelAgreementProductPrice.del_flag == 0,
|
|
or_(
|
|
BizChannelAgreementProductPrice.cell_type_code == cell_name,
|
|
BizChannelAgreementProductPrice.cell_type_name == cell_name,
|
|
),
|
|
)
|
|
.first()
|
|
)
|
|
if row and row.agreement_price is not None:
|
|
return float(row.agreement_price)
|
|
|
|
if product.actual_price is not None:
|
|
return float(product.actual_price)
|
|
if product.agreement_price is not None:
|
|
return float(product.agreement_price)
|
|
return 0.0
|
|
|
|
|
|
def _find_service_order_product(
|
|
db: Session, order_id: str, product: BizPilotInfusionScheduleProduct
|
|
) -> Optional[BizChannelServiceOrderProduct]:
|
|
project_code = (product.project_code or "").strip()
|
|
spec = (product.spec_text or "").strip()
|
|
cell_type = (product.cell_type_name or "").strip()
|
|
if project_code:
|
|
row = (
|
|
db.query(BizChannelServiceOrderProduct)
|
|
.filter(
|
|
BizChannelServiceOrderProduct.service_order_id == order_id,
|
|
BizChannelServiceOrderProduct.project_code == project_code,
|
|
BizChannelServiceOrderProduct.spec_text == spec,
|
|
BizChannelServiceOrderProduct.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if row:
|
|
return row
|
|
if cell_type:
|
|
return (
|
|
db.query(BizChannelServiceOrderProduct)
|
|
.filter(
|
|
BizChannelServiceOrderProduct.service_order_id == order_id,
|
|
BizChannelServiceOrderProduct.cell_type == cell_type,
|
|
BizChannelServiceOrderProduct.spec_text == spec,
|
|
BizChannelServiceOrderProduct.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
return None
|
|
|
|
|
|
def _schedule_product_to_txn_item(
|
|
product: BizPilotInfusionScheduleProduct, *, qty: int, amount: float
|
|
) -> Dict[str, Any]:
|
|
return {
|
|
"projectCode": product.project_code or "",
|
|
"projectName": product.project_name or "",
|
|
"cellType": product.cell_type_name or "",
|
|
"specText": product.spec_text or "",
|
|
"qty": qty,
|
|
"amount": round(amount, 2),
|
|
}
|
|
|
|
|
|
def _create_settlement_txn(
|
|
db: Session,
|
|
*,
|
|
order: BizChannelServiceOrder,
|
|
schedule_id: str,
|
|
application_no: str,
|
|
amount: float,
|
|
products_json: List[Dict[str, Any]],
|
|
balance_before: Optional[float],
|
|
balance_after: Optional[float],
|
|
tenant_no: str,
|
|
user_id: str,
|
|
) -> BizChannelServiceOrderTxn:
|
|
now = datetime.now()
|
|
remark_parts = [
|
|
"输注计划完成结算",
|
|
_settlement_remark_marker(schedule_id),
|
|
]
|
|
if application_no:
|
|
remark_parts.append(f"申请单号={application_no}")
|
|
txn = BizChannelServiceOrderTxn(
|
|
id=generate_uuid(),
|
|
service_order_id=order.id,
|
|
txn_type="settlement",
|
|
amount=round(amount, 2),
|
|
txn_status="yqr",
|
|
products_json=json.dumps(products_json, ensure_ascii=False) if products_json else None,
|
|
remark=" ".join(remark_parts),
|
|
balance_before=balance_before,
|
|
balance_after=balance_after,
|
|
finance_confirm_time=now,
|
|
finance_confirm_by=user_id,
|
|
finance_remark="输注计划完成自动结算",
|
|
tenant_no=tenant_no or order.tenant_no,
|
|
order_num=0,
|
|
create_by=user_id,
|
|
create_dept_id="",
|
|
create_time=now,
|
|
update_by=user_id,
|
|
update_time=now,
|
|
del_flag=0,
|
|
)
|
|
db.add(txn)
|
|
db.flush()
|
|
return txn
|
|
|
|
|
|
def _deduct_fee_on_complete(db: Session, schedule_id: str, tenant_no: str, user_id: str):
|
|
schedule = (
|
|
db.query(BizPilotInfusionSchedule)
|
|
.filter(
|
|
BizPilotInfusionSchedule.id == schedule_id,
|
|
BizPilotInfusionSchedule.tenant_no == tenant_no,
|
|
BizPilotInfusionSchedule.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if not schedule:
|
|
raise ValueError("输注计划不存在")
|
|
|
|
plan = (
|
|
db.query(BizPilotInfusionPlan)
|
|
.filter(
|
|
BizPilotInfusionPlan.id == schedule.infusion_plan_id,
|
|
BizPilotInfusionPlan.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if not plan:
|
|
raise ValueError("输注方案不存在")
|
|
|
|
application = (
|
|
db.query(BizPilotApplication)
|
|
.filter(
|
|
BizPilotApplication.id == plan.pilot_application_id,
|
|
BizPilotApplication.tenant_no == tenant_no,
|
|
BizPilotApplication.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if not application:
|
|
raise ValueError("中试申请单不存在")
|
|
|
|
# 临床应用/基础实验走缴费模块,不走服务订单扣费
|
|
if (application.biz_type or "") in ("clinical_application", "basic_experiment"):
|
|
return
|
|
|
|
if not application.channel_service_order_id:
|
|
raise ValueError("申请单未关联渠道服务订单,无法扣费")
|
|
|
|
order = (
|
|
db.query(BizChannelServiceOrder)
|
|
.filter(
|
|
BizChannelServiceOrder.id == application.channel_service_order_id,
|
|
BizChannelServiceOrder.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if not order:
|
|
raise ValueError("渠道服务订单不存在")
|
|
|
|
order_type = order.order_type or ""
|
|
application_no = application.application_no or ""
|
|
|
|
if _find_existing_settlement_txn(db, order.id, schedule_id):
|
|
return
|
|
|
|
products = (
|
|
db.query(BizPilotInfusionScheduleProduct)
|
|
.filter(
|
|
BizPilotInfusionScheduleProduct.infusion_schedule_id == schedule_id,
|
|
BizPilotInfusionScheduleProduct.tenant_no == tenant_no,
|
|
BizPilotInfusionScheduleProduct.del_flag == 0,
|
|
)
|
|
.order_by(
|
|
BizPilotInfusionScheduleProduct.order_num.asc(),
|
|
BizPilotInfusionScheduleProduct.create_time.asc(),
|
|
)
|
|
.all()
|
|
)
|
|
|
|
if not products:
|
|
raise ValueError("输注计划未配置细胞产品,无法完成结算,请先在编辑中维护产品明细")
|
|
|
|
now = datetime.now()
|
|
|
|
if order_type == "yfk":
|
|
agreement_id = order.channel_agreement_id
|
|
if not agreement_id:
|
|
raise ValueError("服务订单未关联渠道协议,无法确定扣费金额")
|
|
|
|
total_deduction = 0.0
|
|
txn_products: List[Dict[str, Any]] = []
|
|
for p in products:
|
|
qty = max(1, int(p.quantity or 1))
|
|
unit_price = _resolve_yfk_unit_price(db, agreement_id, p)
|
|
if unit_price <= 0:
|
|
label = p.project_name or p.cell_type_name or p.project_code or "未知产品"
|
|
raise ValueError(f"无法确定产品「{label}」的协议单价,请检查渠道协议价格或输注计划实际单价")
|
|
line_amount = unit_price * qty
|
|
total_deduction += line_amount
|
|
txn_products.append(
|
|
_schedule_product_to_txn_item(p, qty=qty, amount=line_amount)
|
|
)
|
|
|
|
account = _get_or_create_channel_account(db, order.channel_id, tenant_no, user_id)
|
|
balance_before = float(account.balance_amount) if account.balance_amount else 0
|
|
|
|
if balance_before < total_deduction:
|
|
raise ValueError(
|
|
f"渠道账户余额不足,当前余额 {balance_before} 元,需扣减 {total_deduction} 元"
|
|
)
|
|
|
|
account.balance_amount = balance_before - total_deduction
|
|
balance_after = float(account.balance_amount)
|
|
account.update_by = user_id
|
|
account.update_time = now
|
|
db.add(account)
|
|
db.flush()
|
|
|
|
order.current_balance = float(order.current_balance or 0) - total_deduction
|
|
order.balance_before = balance_before
|
|
order.balance_after = balance_after
|
|
order.update_by = user_id
|
|
order.update_time = now
|
|
db.add(order)
|
|
db.flush()
|
|
|
|
_create_settlement_txn(
|
|
db,
|
|
order=order,
|
|
schedule_id=schedule_id,
|
|
application_no=application_no,
|
|
amount=total_deduction,
|
|
products_json=txn_products,
|
|
balance_before=balance_before,
|
|
balance_after=balance_after,
|
|
tenant_no=tenant_no,
|
|
user_id=user_id,
|
|
)
|
|
|
|
elif order_type == "gmcp":
|
|
txn_products = []
|
|
total_amount = 0.0
|
|
for p in products:
|
|
order_product = _find_service_order_product(db, order.id, p)
|
|
if not order_product:
|
|
label = p.project_name or p.cell_type_name or p.project_code or "未知产品"
|
|
raise ValueError(f"服务订单中未找到匹配的产品持仓:{label}")
|
|
|
|
project_code = order_product.project_code or ""
|
|
spec_text = order_product.spec_text or ""
|
|
qty = max(1, int(p.quantity or 1))
|
|
|
|
cell_type_code, cell_type_name = _resolve_agreement_price_cell_type(
|
|
db,
|
|
order.channel_agreement_id,
|
|
project_code,
|
|
spec_text,
|
|
order_product.cell_type or p.cell_type_name or "",
|
|
)
|
|
if not cell_type_code:
|
|
cell_type_code = cell_type_name or (p.cell_type_name or "")
|
|
|
|
_reduce_channel_product_quota(
|
|
db,
|
|
channel_id=order.channel_id,
|
|
project_code=project_code,
|
|
cell_type_code=cell_type_code,
|
|
spec_text=spec_text,
|
|
reduce_qty=qty,
|
|
user_id=user_id,
|
|
)
|
|
|
|
current_qty = float(order_product.current_qty or 0)
|
|
if current_qty < qty:
|
|
raise ValueError(
|
|
f"订单产品「{order_product.project_name or project_code}」持仓不足,"
|
|
f"当前 {current_qty} 份,需扣减 {qty} 份"
|
|
)
|
|
order_product.current_qty = current_qty - qty
|
|
order_product.update_by = user_id
|
|
order_product.update_time = now
|
|
db.add(order_product)
|
|
db.flush()
|
|
|
|
unit_price = float(order_product.agreement_unit_price or 0)
|
|
if unit_price <= 0 and p.actual_price is not None:
|
|
unit_price = float(p.actual_price)
|
|
line_amount = unit_price * qty
|
|
total_amount += line_amount
|
|
txn_products.append(
|
|
_schedule_product_to_txn_item(p, qty=qty, amount=line_amount)
|
|
)
|
|
|
|
_create_settlement_txn(
|
|
db,
|
|
order=order,
|
|
schedule_id=schedule_id,
|
|
application_no=application_no,
|
|
amount=total_amount,
|
|
products_json=txn_products,
|
|
balance_before=None,
|
|
balance_after=None,
|
|
tenant_no=tenant_no,
|
|
user_id=user_id,
|
|
)
|
|
|
|
else:
|
|
raise ValueError(f"不支持的渠道服务订单类型:{order_type or '未知'}")
|
|
|
|
|
|
# ────────────────────────────────────────────
|
|
# Update schedule + products
|
|
# ────────────────────────────────────────────
|
|
|
|
|
|
def update_infusion_schedule_mgmt(
|
|
db: Session,
|
|
schedule_id: str,
|
|
data: dict,
|
|
tenant_no: str,
|
|
user_id: str,
|
|
) -> Dict[str, Any]:
|
|
row = (
|
|
db.query(BizPilotInfusionSchedule)
|
|
.filter(
|
|
BizPilotInfusionSchedule.id == schedule_id,
|
|
BizPilotInfusionSchedule.tenant_no == tenant_no,
|
|
BizPilotInfusionSchedule.del_flag == 0,
|
|
)
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise ValueError("输注计划不存在")
|
|
|
|
status = row.infusion_status or ""
|
|
if status not in ("dtz", "yrqr"):
|
|
raise ValueError("当前状态不允许编辑")
|
|
|
|
now = datetime.now()
|
|
|
|
fields = {
|
|
"cellType": "cell_type",
|
|
"specText": "spec_text",
|
|
"packageId": "package_id",
|
|
"packageText": "package_text",
|
|
"infusionLocation": "infusion_location",
|
|
"infusionMethod": "infusion_method",
|
|
}
|
|
for camel, snake in fields.items():
|
|
if camel in data and data[camel] is not None:
|
|
setattr(row, snake, data[camel])
|
|
|
|
if "infusionTime" in data and data["infusionTime"] is not None:
|
|
val = data["infusionTime"]
|
|
if isinstance(val, str) and val:
|
|
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
|
|
try:
|
|
row.infusion_time = datetime.strptime(val, fmt)
|
|
break
|
|
except ValueError:
|
|
continue
|
|
|
|
row.update_by = user_id
|
|
row.update_time = now
|
|
|
|
products_data = data.get("products")
|
|
if products_data is not None:
|
|
def _price_id(item):
|
|
return (item.get("priceId") or item.get("price_id") or "").strip()
|
|
|
|
def _product_key(item):
|
|
price_id = _price_id(item)
|
|
if price_id:
|
|
return "price:" + price_id
|
|
project_code = (item.get("projectCode") or item.get("project_code") or "").strip()
|
|
spec_text = (item.get("specText") or item.get("spec_text") or "").strip()
|
|
return f"proj:{project_code}|{spec_text}"
|
|
|
|
def build_product(row_id):
|
|
return BizPilotInfusionScheduleProduct(
|
|
id=row_id,
|
|
infusion_schedule_id=schedule_id,
|
|
tenant_no=tenant_no,
|
|
create_by=user_id,
|
|
create_dept_id="",
|
|
create_time=now,
|
|
)
|
|
|
|
def apply_product(row, item, idx, _now):
|
|
row.price_id = _price_id(item)
|
|
row.project_code = item.get("projectCode") or item.get("project_code") or ""
|
|
row.project_name = item.get("projectName") or item.get("project_name") or ""
|
|
row.cell_type_name = item.get("cellTypeName") or item.get("cell_type_name") or ""
|
|
row.spec_text = item.get("specText") or item.get("spec_text") or ""
|
|
row.package_id = item.get("packageId") or item.get("package_id") or ""
|
|
row.package_text = item.get("packageText") or item.get("package_text") or ""
|
|
row.agreement_price = item.get("agreementPrice") if item.get("agreementPrice") is not None else (
|
|
item.get("agreement_price") if item.get("agreement_price") is not None else None
|
|
)
|
|
row.actual_price = item.get("actualPrice") if item.get("actualPrice") is not None else (
|
|
item.get("actual_price") if item.get("actual_price") is not None else None
|
|
)
|
|
row.quantity = item.get("quantity") if item.get("quantity") is not None else 1
|
|
row.product_status = status
|
|
row.order_num = idx + 1
|
|
row.remark = item.get("remark") or ""
|
|
|
|
sync_child_rows(
|
|
db,
|
|
BizPilotInfusionScheduleProduct,
|
|
parent_attr="infusion_schedule_id",
|
|
parent_id=schedule_id,
|
|
tenant_no=tenant_no,
|
|
items=list(products_data or []),
|
|
user_id=user_id,
|
|
now=now,
|
|
build_row=build_product,
|
|
apply_fields=apply_product,
|
|
natural_key=_product_key,
|
|
row_natural_key=lambda row: (
|
|
"price:" + (row.price_id or "").strip()
|
|
if (row.price_id or "").strip()
|
|
else f"proj:{(row.project_code or '').strip()}|{(row.spec_text or '').strip()}"
|
|
),
|
|
duplicate_message="输注产品重复",
|
|
foreign_message="输注产品不属于当前计划",
|
|
)
|
|
|
|
db.commit()
|
|
db.refresh(row)
|
|
return get_infusion_schedule_detail(db, row.id, tenant_no)
|