锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
862 lines
30 KiB
Python
862 lines
30 KiB
Python
"""出库放行审核 CRUD"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from collections import defaultdict
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from sqlalchemy import and_, exists, func, or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db_collation import eq_unicode
|
||
from app.core.utils import generate_uuid
|
||
from app.models.models import (
|
||
BizOutboundReleaseReview,
|
||
BizPilotApplication,
|
||
BizPilotInfusionPlan,
|
||
BizPilotInfusionSchedule,
|
||
BizPilotInfusionScheduleProduct,
|
||
BizPilotPatient,
|
||
BizProductionTask,
|
||
BizProductionWorkOrder,
|
||
BizQcTask,
|
||
BizQcWorkOrder,
|
||
SysUser,
|
||
)
|
||
from app.models.qc_param import BizQcTaskResult
|
||
|
||
_REVIEW_CONCLUSION_LABELS = {"tg": "通过", "btg": "不通过"}
|
||
_SCHEDULE_STATUS_LABELS = {
|
||
"dsc": "待生产",
|
||
"ysc": "已生产",
|
||
"yjf": "已交付",
|
||
"yqx": "已取消",
|
||
}
|
||
_QC_STATUS_LABELS = {
|
||
"dzc": "待质检",
|
||
"yzj": "已质检",
|
||
"yqx": "已取消",
|
||
}
|
||
_BIZ_TYPE_LABELS = {
|
||
"pilot_service": "中试服务",
|
||
"clinical_application": "临床应用",
|
||
"basic_experiment": "基础实验",
|
||
}
|
||
|
||
|
||
# ────────────────────────────────────────────
|
||
# Helpers
|
||
# ────────────────────────────────────────────
|
||
|
||
|
||
def _fmt_dt(t: Optional[datetime]) -> str:
|
||
return t.strftime("%Y-%m-%d %H:%M:%S") if t else ""
|
||
|
||
|
||
def _dec(v: Any) -> Optional[float]:
|
||
if v is None:
|
||
return None
|
||
try:
|
||
return float(v)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _total_pages(total: int, page_size: int) -> int:
|
||
if total <= 0:
|
||
return 0
|
||
return int(math.ceil(total / float(page_size)))
|
||
|
||
|
||
def normalize_outbound_review_phase(review_phase: Optional[str]) -> Optional[str]:
|
||
p = (review_phase or "").strip().lower()
|
||
if p in ("pending", "reviewed"):
|
||
return p
|
||
return None
|
||
|
||
|
||
def normalize_outbound_ready_for_review_only(raw) -> Optional[bool]:
|
||
"""Return True when ready-only filter should apply; None means no filter."""
|
||
if raw is None:
|
||
return None
|
||
if isinstance(raw, bool):
|
||
return True if raw else None
|
||
s = str(raw).strip().lower()
|
||
if s in ("true", "1", "yes"):
|
||
return True
|
||
return None
|
||
|
||
|
||
def outbound_work_orders_all_void(statuses) -> bool:
|
||
"""True when ≥1 non-empty status and every one equals yzf."""
|
||
cleaned = [(s or "").strip() for s in (statuses or [])]
|
||
if not cleaned or not any(s for s in cleaned):
|
||
return False
|
||
return all(s.lower() == "yzf" for s in cleaned)
|
||
|
||
|
||
def _outbound_qc_unfinished_exists_for_schedule(tenant_no: str):
|
||
"""Any unfinished outbound schedule QC (dpz/ypz) for this infusion schedule."""
|
||
return (
|
||
exists()
|
||
.where(
|
||
and_(
|
||
BizQcWorkOrder.tenant_no == tenant_no,
|
||
BizQcWorkOrder.del_flag == 0,
|
||
BizQcWorkOrder.qc_biz_type == "outbound",
|
||
BizQcWorkOrder.trigger_source == "schedule",
|
||
# 跨表 id 排序规则可能不同(general_ci vs unicode_ci)
|
||
eq_unicode(
|
||
BizQcWorkOrder.infusion_schedule_id, BizPilotInfusionSchedule.id
|
||
),
|
||
BizQcWorkOrder.work_order_status.in_(("dpz", "ypz")),
|
||
)
|
||
)
|
||
.correlate(BizPilotInfusionSchedule)
|
||
)
|
||
|
||
|
||
def _outbound_production_unfinished_exists_for_schedule(tenant_no: str):
|
||
return (
|
||
exists()
|
||
.where(
|
||
and_(
|
||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||
BizProductionWorkOrder.del_flag == 0,
|
||
BizProductionWorkOrder.work_order_type == "outbound",
|
||
eq_unicode(
|
||
BizProductionWorkOrder.infusion_schedule_id,
|
||
BizPilotInfusionSchedule.id,
|
||
),
|
||
BizProductionWorkOrder.work_order_status.in_(("dpz", "jxz")),
|
||
)
|
||
)
|
||
.correlate(BizPilotInfusionSchedule)
|
||
)
|
||
|
||
|
||
def _outbound_qc_all_void_for_schedule(tenant_no: str):
|
||
base = [
|
||
BizQcWorkOrder.tenant_no == tenant_no,
|
||
BizQcWorkOrder.del_flag == 0,
|
||
BizQcWorkOrder.qc_biz_type == "outbound",
|
||
BizQcWorkOrder.trigger_source == "schedule",
|
||
eq_unicode(BizQcWorkOrder.infusion_schedule_id, BizPilotInfusionSchedule.id),
|
||
]
|
||
any_wo = exists().where(and_(*base)).correlate(BizPilotInfusionSchedule)
|
||
any_non_void = (
|
||
exists()
|
||
.where(
|
||
and_(
|
||
*base,
|
||
or_(
|
||
BizQcWorkOrder.work_order_status.is_(None),
|
||
BizQcWorkOrder.work_order_status != "yzf",
|
||
),
|
||
)
|
||
)
|
||
.correlate(BizPilotInfusionSchedule)
|
||
)
|
||
return and_(any_wo, ~any_non_void)
|
||
|
||
|
||
def _outbound_production_all_void_for_schedule(tenant_no: str):
|
||
base = [
|
||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||
BizProductionWorkOrder.del_flag == 0,
|
||
BizProductionWorkOrder.work_order_type == "outbound",
|
||
eq_unicode(
|
||
BizProductionWorkOrder.infusion_schedule_id, BizPilotInfusionSchedule.id
|
||
),
|
||
]
|
||
any_wo = exists().where(and_(*base)).correlate(BizPilotInfusionSchedule)
|
||
any_non_void = (
|
||
exists()
|
||
.where(
|
||
and_(
|
||
*base,
|
||
or_(
|
||
BizProductionWorkOrder.work_order_status.is_(None),
|
||
BizProductionWorkOrder.work_order_status != "yzf",
|
||
),
|
||
)
|
||
)
|
||
.correlate(BizPilotInfusionSchedule)
|
||
)
|
||
return and_(any_wo, ~any_non_void)
|
||
|
||
|
||
def _get_user_name(db: Session, user_id: str) -> str:
|
||
u = db.query(SysUser).filter(SysUser.id == user_id).first()
|
||
return (u.nick_name or "") if u else ""
|
||
|
||
|
||
def _resolve_schedule_biz_type(
|
||
app: Optional[BizPilotApplication],
|
||
qwo: Optional[BizQcWorkOrder],
|
||
pwo: Optional[BizProductionWorkOrder],
|
||
) -> str:
|
||
"""输注计划业务类型:优先出库工单 biz_type,否则取申请单 biz_type。"""
|
||
if qwo and (qwo.biz_type or "").strip():
|
||
return (qwo.biz_type or "").strip()
|
||
if pwo and (pwo.biz_type or "").strip():
|
||
return (pwo.biz_type or "").strip()
|
||
if app and (app.biz_type or "").strip():
|
||
return (app.biz_type or "").strip()
|
||
if app:
|
||
return "pilot_service"
|
||
return ""
|
||
|
||
|
||
def _row_to_dict(row) -> Dict[str, Any]:
|
||
"""将 BizOutboundReleaseReview ORM 对象转为前端字典。"""
|
||
return {
|
||
"id": row.id,
|
||
"infusionScheduleId": row.infusion_schedule_id or "",
|
||
"productionWorkOrderId": row.production_work_order_id or "",
|
||
"qcWorkOrderId": row.qc_work_order_id or "",
|
||
"reviewConclusion": row.review_conclusion or "",
|
||
"reviewConclusionLabel": _REVIEW_CONCLUSION_LABELS.get(
|
||
row.review_conclusion or "", row.review_conclusion or ""
|
||
),
|
||
"reviewerUserId": row.reviewer_user_id or "",
|
||
"reviewByName": row.review_by_name or "",
|
||
"reviewAttachments": row.review_attachments or "",
|
||
"reviewRemark": row.review_remark or "",
|
||
"reviewTime": _fmt_dt(row.review_time),
|
||
"tenantNo": row.tenant_no or "",
|
||
"orderNum": row.order_num if row.order_num is not None else 0,
|
||
"remark": row.remark or "",
|
||
"createBy": row.create_by or "",
|
||
"createTime": _fmt_dt(row.create_time),
|
||
"updateBy": row.update_by or "",
|
||
"updateTime": _fmt_dt(row.update_time),
|
||
}
|
||
|
||
|
||
# ────────────────────────────────────────────
|
||
# Paginated list
|
||
# ────────────────────────────────────────────
|
||
|
||
|
||
def list_reviews_paginated(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
keyword: Optional[str] = None,
|
||
review_conclusion: Optional[str] = None,
|
||
schedule_status: Optional[str] = None,
|
||
qc_status: Optional[str] = None,
|
||
biz_type: Optional[str] = None,
|
||
infusion_time_start: Optional[str] = None,
|
||
infusion_time_end: Optional[str] = None,
|
||
review_phase: Optional[str] = None,
|
||
ready_for_review_only=None,
|
||
page: int = 1,
|
||
page_size: int = 10,
|
||
user_info=None,
|
||
) -> Dict[str, Any]:
|
||
"""分页列出出库放行审核数据,以输注计划为主表 LEFT JOIN 审核/生产/质检工单。"""
|
||
# ── 基础查询:输注计划 ──
|
||
q0 = db.query(BizPilotInfusionSchedule).filter(
|
||
BizPilotInfusionSchedule.tenant_no == tenant_no,
|
||
BizPilotInfusionSchedule.del_flag == 0,
|
||
)
|
||
|
||
bt_filter = (biz_type or "").strip()
|
||
if bt_filter:
|
||
app_ids_for_biz = (
|
||
db.query(BizPilotApplication.id)
|
||
.filter(
|
||
BizPilotApplication.tenant_no == tenant_no,
|
||
BizPilotApplication.del_flag == 0,
|
||
BizPilotApplication.biz_type == bt_filter,
|
||
)
|
||
.subquery()
|
||
)
|
||
plan_ids_for_biz = (
|
||
db.query(BizPilotInfusionPlan.id)
|
||
.filter(
|
||
BizPilotInfusionPlan.tenant_no == tenant_no,
|
||
BizPilotInfusionPlan.del_flag == 0,
|
||
BizPilotInfusionPlan.pilot_application_id.in_(app_ids_for_biz),
|
||
)
|
||
.subquery()
|
||
)
|
||
q0 = q0.filter(BizPilotInfusionSchedule.infusion_plan_id.in_(plan_ids_for_biz))
|
||
|
||
# ── 关键字过滤(application_no, patient_name)需要子查询 ──
|
||
kw = (keyword or "").strip()
|
||
if kw:
|
||
like = f"%{kw}%"
|
||
# 通过 infusion_plan_id 关联 plan → application → patient
|
||
plan_ids = (
|
||
db.query(BizPilotInfusionPlan.id)
|
||
.filter(
|
||
BizPilotInfusionPlan.tenant_no == tenant_no,
|
||
BizPilotInfusionPlan.del_flag == 0,
|
||
)
|
||
.subquery()
|
||
)
|
||
app_ids = (
|
||
db.query(BizPilotApplication.id)
|
||
.filter(
|
||
BizPilotApplication.tenant_no == tenant_no,
|
||
BizPilotApplication.del_flag == 0,
|
||
or_(
|
||
BizPilotApplication.application_no.like(like),
|
||
BizPilotApplication.application_name.like(like),
|
||
BizPilotApplication.project_title.like(like),
|
||
BizPilotApplication.customer_name.like(like),
|
||
),
|
||
)
|
||
.subquery()
|
||
)
|
||
patient_app_ids = (
|
||
db.query(BizPilotPatient.pilot_application_id)
|
||
.filter(
|
||
BizPilotPatient.tenant_no == tenant_no,
|
||
BizPilotPatient.del_flag == 0,
|
||
BizPilotPatient.patient_name.like(like),
|
||
)
|
||
.subquery()
|
||
)
|
||
# 找出满足条件的 infusion_plan_id
|
||
matched_plan_ids = (
|
||
db.query(BizPilotInfusionPlan.id)
|
||
.filter(
|
||
BizPilotInfusionPlan.tenant_no == tenant_no,
|
||
BizPilotInfusionPlan.del_flag == 0,
|
||
or_(
|
||
BizPilotInfusionPlan.pilot_application_id.in_(app_ids),
|
||
BizPilotInfusionPlan.pilot_application_id.in_(patient_app_ids),
|
||
),
|
||
)
|
||
.subquery()
|
||
)
|
||
q0 = q0.filter(
|
||
BizPilotInfusionSchedule.infusion_plan_id.in_(matched_plan_ids)
|
||
)
|
||
|
||
# ── schedule_status 过滤 ──
|
||
ss = (schedule_status or "").strip()
|
||
if ss:
|
||
q0 = q0.filter(BizPilotInfusionSchedule.schedule_status == ss)
|
||
|
||
# ── qc_status 过滤 ──
|
||
qs = (qc_status or "").strip()
|
||
if qs:
|
||
q0 = q0.filter(BizPilotInfusionSchedule.qc_status == qs)
|
||
|
||
# ── 输注时间范围 ──
|
||
time_bounds = []
|
||
if (infusion_time_start or "").strip():
|
||
try:
|
||
start_dt = datetime.strptime(infusion_time_start.strip()[:10], "%Y-%m-%d")
|
||
time_bounds.append(BizPilotInfusionSchedule.infusion_time >= start_dt)
|
||
except ValueError:
|
||
pass
|
||
if (infusion_time_end or "").strip():
|
||
try:
|
||
end_dt = datetime.strptime(infusion_time_end.strip()[:10], "%Y-%m-%d")
|
||
end_dt = end_dt.replace(hour=23, minute=59, second=59)
|
||
time_bounds.append(BizPilotInfusionSchedule.infusion_time <= end_dt)
|
||
except ValueError:
|
||
pass
|
||
if time_bounds:
|
||
q0 = q0.filter(or_(and_(*time_bounds), BizPilotInfusionSchedule.infusion_time.is_(None)))
|
||
|
||
# ── reviewPhase / review_conclusion 过滤 ──
|
||
phase = normalize_outbound_review_phase(review_phase)
|
||
rc = (review_conclusion or "").strip()
|
||
if phase == "pending" or (phase is None and rc == "pending"):
|
||
reviewed_schedule_ids = (
|
||
db.query(BizOutboundReleaseReview.infusion_schedule_id)
|
||
.filter(
|
||
BizOutboundReleaseReview.tenant_no == tenant_no,
|
||
BizOutboundReleaseReview.del_flag == 0,
|
||
BizOutboundReleaseReview.review_conclusion.isnot(None),
|
||
BizOutboundReleaseReview.review_conclusion != "",
|
||
)
|
||
.subquery()
|
||
)
|
||
q0 = q0.filter(~BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids))
|
||
elif phase == "reviewed":
|
||
reviewed_schedule_ids = (
|
||
db.query(BizOutboundReleaseReview.infusion_schedule_id)
|
||
.filter(
|
||
BizOutboundReleaseReview.tenant_no == tenant_no,
|
||
BizOutboundReleaseReview.del_flag == 0,
|
||
BizOutboundReleaseReview.review_conclusion.isnot(None),
|
||
BizOutboundReleaseReview.review_conclusion != "",
|
||
)
|
||
.subquery()
|
||
)
|
||
q0 = q0.filter(BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids))
|
||
if rc in ("tg", "btg"):
|
||
conclusion_ids = (
|
||
db.query(BizOutboundReleaseReview.infusion_schedule_id)
|
||
.filter(
|
||
BizOutboundReleaseReview.tenant_no == tenant_no,
|
||
BizOutboundReleaseReview.del_flag == 0,
|
||
BizOutboundReleaseReview.review_conclusion == rc,
|
||
)
|
||
.subquery()
|
||
)
|
||
q0 = q0.filter(BizPilotInfusionSchedule.id.in_(conclusion_ids))
|
||
elif rc in ("tg", "btg"):
|
||
reviewed_schedule_ids = (
|
||
db.query(BizOutboundReleaseReview.infusion_schedule_id)
|
||
.filter(
|
||
BizOutboundReleaseReview.tenant_no == tenant_no,
|
||
BizOutboundReleaseReview.del_flag == 0,
|
||
BizOutboundReleaseReview.review_conclusion == rc,
|
||
)
|
||
.subquery()
|
||
)
|
||
q0 = q0.filter(BizPilotInfusionSchedule.id.in_(reviewed_schedule_ids))
|
||
|
||
if normalize_outbound_ready_for_review_only(ready_for_review_only) is True:
|
||
q0 = q0.filter(
|
||
~_outbound_qc_unfinished_exists_for_schedule(tenant_no),
|
||
~_outbound_production_unfinished_exists_for_schedule(tenant_no),
|
||
)
|
||
|
||
# 出库质检/生产「有工单且全部 yzf」→ 列表隐藏(两 Tab 常驻)
|
||
q0 = q0.filter(
|
||
~or_(
|
||
_outbound_qc_all_void_for_schedule(tenant_no),
|
||
_outbound_production_all_void_for_schedule(tenant_no),
|
||
)
|
||
)
|
||
|
||
# ── 数据权限 ──
|
||
if user_info:
|
||
from app.core.data_scope import apply_data_scope
|
||
|
||
q0 = apply_data_scope(q0, BizPilotInfusionSchedule, user_info, db, tenant_no)
|
||
|
||
# ── 分页 ──
|
||
total = (
|
||
q0.order_by(None)
|
||
.with_entities(func.count(BizPilotInfusionSchedule.id))
|
||
.scalar()
|
||
or 0
|
||
)
|
||
page = max(1, page)
|
||
page_size = min(max(1, page_size), 100)
|
||
schedules = (
|
||
q0.order_by(BizPilotInfusionSchedule.create_time.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
|
||
schedule_ids = [s.id for s in schedules]
|
||
|
||
# ── 批量加载关联数据 ──
|
||
# 审核记录
|
||
review_map: Dict[str, BizOutboundReleaseReview] = {}
|
||
if schedule_ids:
|
||
for rev in (
|
||
db.query(BizOutboundReleaseReview)
|
||
.filter(
|
||
BizOutboundReleaseReview.infusion_schedule_id.in_(schedule_ids),
|
||
BizOutboundReleaseReview.tenant_no == tenant_no,
|
||
BizOutboundReleaseReview.del_flag == 0,
|
||
)
|
||
.all()
|
||
):
|
||
review_map[rev.infusion_schedule_id] = rev
|
||
|
||
# 生产工单(出库类型)
|
||
production_map: Dict[str, BizProductionWorkOrder] = {}
|
||
if schedule_ids:
|
||
for pwo in (
|
||
db.query(BizProductionWorkOrder)
|
||
.filter(
|
||
BizProductionWorkOrder.infusion_schedule_id.in_(schedule_ids),
|
||
BizProductionWorkOrder.work_order_type == "outbound",
|
||
BizProductionWorkOrder.biz_type.in_(
|
||
list(_BIZ_TYPE_LABELS.keys())
|
||
),
|
||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||
BizProductionWorkOrder.del_flag == 0,
|
||
)
|
||
.all()
|
||
):
|
||
production_map[pwo.infusion_schedule_id] = pwo
|
||
|
||
# 质检工单(出库类型)
|
||
qc_map: Dict[str, BizQcWorkOrder] = {}
|
||
if schedule_ids:
|
||
for qwo in (
|
||
db.query(BizQcWorkOrder)
|
||
.filter(
|
||
BizQcWorkOrder.infusion_schedule_id.in_(schedule_ids),
|
||
BizQcWorkOrder.qc_biz_type == "outbound",
|
||
BizQcWorkOrder.biz_type.in_(list(_BIZ_TYPE_LABELS.keys())),
|
||
BizQcWorkOrder.tenant_no == tenant_no,
|
||
BizQcWorkOrder.del_flag == 0,
|
||
BizQcWorkOrder.trigger_source == "schedule",
|
||
)
|
||
.all()
|
||
):
|
||
qc_map[qwo.infusion_schedule_id] = qwo
|
||
|
||
# 输注计划产品明细(用户需求细胞)
|
||
products_map: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||
if schedule_ids:
|
||
for product in (
|
||
db.query(BizPilotInfusionScheduleProduct)
|
||
.filter(
|
||
BizPilotInfusionScheduleProduct.infusion_schedule_id.in_(schedule_ids),
|
||
BizPilotInfusionScheduleProduct.tenant_no == tenant_no,
|
||
BizPilotInfusionScheduleProduct.del_flag == 0,
|
||
)
|
||
.order_by(BizPilotInfusionScheduleProduct.order_num.asc())
|
||
.all()
|
||
):
|
||
products_map[product.infusion_schedule_id].append(
|
||
{
|
||
"projectCode": product.project_code or "",
|
||
"projectName": product.project_name or "",
|
||
"cellTypeName": product.cell_type_name or "",
|
||
"specText": product.spec_text or "",
|
||
"packageId": getattr(product, "package_id", None) or "",
|
||
"packageText": getattr(product, "package_text", None) or "",
|
||
"agreementPrice": _dec(product.agreement_price),
|
||
"actualPrice": _dec(product.actual_price),
|
||
"quantity": product.quantity,
|
||
"productStatus": product.product_status or "",
|
||
}
|
||
)
|
||
|
||
# 输注计划 → 申请单 → 患者
|
||
plan_ids_for_schedules = {
|
||
s.id: s.infusion_plan_id for s in schedules if s.infusion_plan_id
|
||
}
|
||
unique_plan_ids = list(set(plan_ids_for_schedules.values()))
|
||
|
||
plan_map: Dict[str, BizPilotInfusionPlan] = {}
|
||
if unique_plan_ids:
|
||
for plan in (
|
||
db.query(BizPilotInfusionPlan)
|
||
.filter(
|
||
BizPilotInfusionPlan.id.in_(unique_plan_ids),
|
||
BizPilotInfusionPlan.tenant_no == tenant_no,
|
||
BizPilotInfusionPlan.del_flag == 0,
|
||
)
|
||
.all()
|
||
):
|
||
plan_map[plan.id] = plan
|
||
|
||
unique_app_ids = list(
|
||
{p.pilot_application_id for p in plan_map.values() if p.pilot_application_id}
|
||
)
|
||
app_map: Dict[str, BizPilotApplication] = {}
|
||
if unique_app_ids:
|
||
for app in (
|
||
db.query(BizPilotApplication)
|
||
.filter(
|
||
BizPilotApplication.id.in_(unique_app_ids),
|
||
BizPilotApplication.tenant_no == tenant_no,
|
||
BizPilotApplication.del_flag == 0,
|
||
)
|
||
.all()
|
||
):
|
||
app_map[app.id] = app
|
||
|
||
# 患者
|
||
patient_map: Dict[str, BizPilotPatient] = {}
|
||
if unique_app_ids:
|
||
for patient in (
|
||
db.query(BizPilotPatient)
|
||
.filter(
|
||
BizPilotPatient.pilot_application_id.in_(unique_app_ids),
|
||
BizPilotPatient.tenant_no == tenant_no,
|
||
BizPilotPatient.del_flag == 0,
|
||
)
|
||
.all()
|
||
):
|
||
patient_map[patient.pilot_application_id] = patient
|
||
|
||
# ── 组装列表项 ──
|
||
items: List[Dict[str, Any]] = []
|
||
for s in schedules:
|
||
plan = plan_map.get(s.infusion_plan_id)
|
||
app = app_map.get(plan.pilot_application_id) if plan else None
|
||
patient = patient_map.get(app.id) if app else None
|
||
rev = review_map.get(s.id)
|
||
pwo = production_map.get(s.id)
|
||
qwo = qc_map.get(s.id)
|
||
biz_type = _resolve_schedule_biz_type(app, qwo, pwo)
|
||
|
||
item: Dict[str, Any] = {
|
||
"id": s.id,
|
||
"scheduleId": s.id,
|
||
"bizType": biz_type,
|
||
"bizTypeLabel": _BIZ_TYPE_LABELS.get(biz_type, biz_type),
|
||
"applicationNo": (app.application_no or "") if app else "",
|
||
"applicationName": (app.application_name or "") if app else "",
|
||
"channelName": (app.channel_name or "") if app else "",
|
||
"projectTitle": (app.project_title or "") if app else "",
|
||
"customerName": (app.customer_name or "") if app else "",
|
||
"patientName": (patient.patient_name or "") if patient else "",
|
||
"cellType": s.cell_type or (plan.cell_type or "") if plan else (s.cell_type or ""),
|
||
"specText": s.spec_text or (plan.spec_text or "") if plan else (s.spec_text or ""),
|
||
"packageId": getattr(s, "package_id", None) or "",
|
||
"packageText": getattr(s, "package_text", None) or "",
|
||
"infusionTime": _fmt_dt(s.infusion_time),
|
||
"scheduleStatus": s.schedule_status or "",
|
||
"scheduleStatusLabel": _SCHEDULE_STATUS_LABELS.get(
|
||
s.schedule_status or "", s.schedule_status or ""
|
||
),
|
||
"qcStatus": s.qc_status or "",
|
||
"qcStatusLabel": _QC_STATUS_LABELS.get(
|
||
s.qc_status or "", s.qc_status or ""
|
||
),
|
||
"products": products_map.get(s.id, []),
|
||
"reviewConclusion": "",
|
||
"reviewConclusionLabel": "",
|
||
"reviewByName": "",
|
||
"reviewTime": "",
|
||
"reviewRemark": "",
|
||
"reviewAttachments": "",
|
||
"reviewId": "",
|
||
"productionWorkOrderNo": "",
|
||
"productionWorkOrderStatus": "",
|
||
"qcWorkOrderNo": "",
|
||
"qcWorkOrderStatus": "",
|
||
}
|
||
if rev:
|
||
rc_val = rev.review_conclusion or ""
|
||
item["reviewConclusion"] = rc_val
|
||
item["reviewConclusionLabel"] = _REVIEW_CONCLUSION_LABELS.get(rc_val, rc_val)
|
||
item["reviewByName"] = rev.review_by_name or ""
|
||
item["reviewTime"] = _fmt_dt(rev.review_time)
|
||
item["reviewRemark"] = rev.review_remark or ""
|
||
item["reviewAttachments"] = rev.review_attachments or ""
|
||
item["reviewId"] = rev.id
|
||
else:
|
||
item["reviewConclusionLabel"] = "待审核"
|
||
|
||
if pwo:
|
||
item["productionWorkOrderNo"] = pwo.work_order_no or ""
|
||
item["productionWorkOrderStatus"] = pwo.work_order_status or ""
|
||
if qwo:
|
||
item["qcWorkOrderNo"] = qwo.work_order_no or ""
|
||
item["qcWorkOrderStatus"] = qwo.work_order_status or ""
|
||
|
||
items.append(item)
|
||
|
||
return {
|
||
"list": items,
|
||
"total": int(total),
|
||
"page": page,
|
||
"pageSize": page_size,
|
||
"totalPages": _total_pages(int(total), page_size),
|
||
}
|
||
|
||
|
||
# ────────────────────────────────────────────
|
||
# Detail aggregation
|
||
# ────────────────────────────────────────────
|
||
|
||
|
||
def get_schedule_detail(
|
||
db: Session, schedule_id: str, tenant_no: str
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""按输注计划 ID 聚合出库放行审核详情(中试上下文 + 生产 + 质检 + 审核)。"""
|
||
from app.crud.qc_work_order import build_outbound_pilot_context_by_schedule
|
||
|
||
pilot_context = build_outbound_pilot_context_by_schedule(db, schedule_id, tenant_no)
|
||
|
||
# ── 生产工单 + 任务 ──
|
||
production_info: Dict[str, Any] = {}
|
||
pwo = (
|
||
db.query(BizProductionWorkOrder)
|
||
.filter(
|
||
BizProductionWorkOrder.infusion_schedule_id == schedule_id,
|
||
BizProductionWorkOrder.work_order_type == "outbound",
|
||
BizProductionWorkOrder.biz_type.in_(list(_BIZ_TYPE_LABELS.keys())),
|
||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||
BizProductionWorkOrder.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if pwo:
|
||
from app.crud.production_work_order import (
|
||
_production_info_item,
|
||
)
|
||
|
||
production_info = _production_info_item(db, pwo)
|
||
|
||
# ── 质检工单 + 任务 + 结果 ──
|
||
from app.crud import qc_work_order as qc_work_order_svc
|
||
|
||
qc_info = qc_work_order_svc.get_schedule_gate_qc_info(
|
||
db, schedule_id=schedule_id, tenant_no=tenant_no
|
||
) or {}
|
||
process_qc_info_list = qc_work_order_svc.list_process_qc_info_for_schedule(
|
||
db, schedule_id=schedule_id, tenant_no=tenant_no
|
||
)
|
||
|
||
# ── 审核记录 ──
|
||
review_info: Dict[str, Any] = {}
|
||
rev = (
|
||
db.query(BizOutboundReleaseReview)
|
||
.filter(
|
||
BizOutboundReleaseReview.infusion_schedule_id == schedule_id,
|
||
BizOutboundReleaseReview.tenant_no == tenant_no,
|
||
BizOutboundReleaseReview.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if rev:
|
||
review_info = _row_to_dict(rev)
|
||
|
||
outbound_material: Dict[str, Any] = {}
|
||
try:
|
||
from app.crud import outbound_release_material as orm_svc
|
||
|
||
outbound_material = orm_svc.get_outbound_material(db, schedule_id, tenant_no)
|
||
except Exception:
|
||
outbound_material = {}
|
||
|
||
# 与 OutboundReleaseReviewDetail / 出库质检详情一致:中试字段展平到顶层,勿嵌套 pilotContext
|
||
return {
|
||
**(pilot_context or {}),
|
||
"productionInfo": production_info,
|
||
"qcInfo": qc_info,
|
||
"processQcInfoList": process_qc_info_list,
|
||
"reviewInfo": review_info,
|
||
"outboundMaterial": outbound_material,
|
||
}
|
||
|
||
|
||
# ────────────────────────────────────────────
|
||
# Review operations
|
||
# ────────────────────────────────────────────
|
||
|
||
|
||
def review_schedule(
|
||
db: Session,
|
||
schedule_id: str,
|
||
*,
|
||
conclusion: str,
|
||
remark: Optional[str] = None,
|
||
attachments: Optional[str] = None,
|
||
tenant_no: str,
|
||
user_id: str,
|
||
) -> Dict[str, Any]:
|
||
"""对指定输注计划执行出库放行审核(upsert)。"""
|
||
now = datetime.now()
|
||
user_name = _get_user_name(db, user_id)
|
||
|
||
# 查找已有审核记录
|
||
rev = (
|
||
db.query(BizOutboundReleaseReview)
|
||
.filter(
|
||
BizOutboundReleaseReview.infusion_schedule_id == schedule_id,
|
||
BizOutboundReleaseReview.tenant_no == tenant_no,
|
||
BizOutboundReleaseReview.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
|
||
# 自动填充生产工单 ID
|
||
pwo = (
|
||
db.query(BizProductionWorkOrder)
|
||
.filter(
|
||
BizProductionWorkOrder.infusion_schedule_id == schedule_id,
|
||
BizProductionWorkOrder.work_order_type == "outbound",
|
||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||
BizProductionWorkOrder.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
# 自动填充质检工单 ID
|
||
qwo = (
|
||
db.query(BizQcWorkOrder)
|
||
.filter(
|
||
BizQcWorkOrder.infusion_schedule_id == schedule_id,
|
||
BizQcWorkOrder.qc_biz_type == "outbound",
|
||
BizQcWorkOrder.tenant_no == tenant_no,
|
||
BizQcWorkOrder.del_flag == 0,
|
||
BizQcWorkOrder.trigger_source == "schedule",
|
||
)
|
||
.first()
|
||
)
|
||
|
||
if rev:
|
||
# update
|
||
rev.review_conclusion = conclusion
|
||
rev.review_remark = (remark or "").strip() or None
|
||
rev.review_attachments = (attachments or "").strip() or None
|
||
rev.reviewer_user_id = user_id
|
||
rev.review_by_name = user_name
|
||
rev.review_time = now
|
||
rev.production_work_order_id = pwo.id if pwo else (rev.production_work_order_id or "")
|
||
rev.qc_work_order_id = qwo.id if qwo else (rev.qc_work_order_id or "")
|
||
rev.update_by = user_id
|
||
rev.update_time = now
|
||
else:
|
||
# create
|
||
rev = BizOutboundReleaseReview(
|
||
id=generate_uuid(),
|
||
infusion_schedule_id=schedule_id,
|
||
production_work_order_id=pwo.id if pwo else "",
|
||
qc_work_order_id=qwo.id if qwo else "",
|
||
review_conclusion=conclusion,
|
||
reviewer_user_id=user_id,
|
||
review_by_name=user_name,
|
||
review_time=now,
|
||
review_remark=(remark or "").strip() or None,
|
||
review_attachments=(attachments or "").strip() or None,
|
||
tenant_no=tenant_no,
|
||
order_num=0,
|
||
remark="",
|
||
create_by=user_id,
|
||
create_dept_id="",
|
||
create_time=now,
|
||
update_by=user_id,
|
||
update_time=now,
|
||
del_flag=0,
|
||
)
|
||
db.add(rev)
|
||
|
||
db.flush()
|
||
return _row_to_dict(rev)
|
||
|
||
|
||
def batch_review(
|
||
db: Session,
|
||
schedule_ids: List[str],
|
||
*,
|
||
conclusion: str,
|
||
remark: Optional[str] = None,
|
||
attachments: Optional[str] = None,
|
||
tenant_no: str,
|
||
user_id: str,
|
||
) -> Dict[str, Any]:
|
||
"""批量审核多个输注计划。"""
|
||
success = 0
|
||
fail = 0
|
||
for sid in schedule_ids:
|
||
try:
|
||
review_schedule(
|
||
db,
|
||
sid,
|
||
conclusion=conclusion,
|
||
remark=remark,
|
||
attachments=attachments,
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
)
|
||
success += 1
|
||
except Exception:
|
||
fail += 1
|
||
return {"success": success, "fail": fail}
|