"""出库放行审核 · 出库物料准备(台账 CRUD)""" from __future__ import annotations from datetime import datetime from decimal import Decimal from typing import Any, Dict, List, Optional from fastapi import HTTPException from sqlalchemy.orm import Session from app.core.utils import generate_uuid from app.crud import cell_product as cell_product_svc from app.models.models import ( BizCellProduct, BizOutboundReleaseCell, BizOutboundReleaseMaterial, BizOutboundReleasePaper, BizOutboundReleaseQcMaterial, BizPilotInfusionSchedule, ) from app.models.material import BizMaterial def _fmt_dt(t: Optional[datetime]) -> str: return t.strftime("%Y-%m-%d %H:%M:%S") if t else "" def _cell_row_to_dict(row: BizOutboundReleaseCell) -> Dict[str, Any]: return { "id": row.id, "materialId": row.material_id or "", "infusionScheduleId": row.infusion_schedule_id or "", "cellProductId": row.cell_product_id or "", "productCode": row.product_code or "", "productName": row.product_name or "", "productTypeName": row.product_type_name or "", "specName": row.spec_name or "", "passage": row.passage or "", "batchNo": row.batch_no or "", "storageSlotName": row.storage_slot_name or "", "productStatus": row.product_status or "", "remark": row.remark or "", "orderNum": row.order_num if row.order_num is not None else 0, } def _enrich_cell_dicts_with_live_status( db: Session, tenant_no: str, cells: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: """出库细胞状态以细胞产品库为准(台账 product_status 仅为保存时快照)。""" cp_ids = { (c.get("cellProductId") or c.get("cell_product_id") or "").strip() for c in cells } cp_ids.discard("") if not cp_ids: return cells live_status: Dict[str, str] = {} for product in ( db.query(BizCellProduct) .filter( BizCellProduct.id.in_(cp_ids), BizCellProduct.tenant_no == tenant_no, BizCellProduct.del_flag == 0, ) .all() ): live_status[product.id] = (product.product_status or "").strip() enriched: List[Dict[str, Any]] = [] for cell in cells: cp_id = (cell.get("cellProductId") or cell.get("cell_product_id") or "").strip() status = live_status.get(cp_id) if status: enriched.append({**cell, "productStatus": status}) else: enriched.append(cell) return enriched def _qc_material_row_to_dict(row: BizOutboundReleaseQcMaterial) -> Dict[str, Any]: return { "id": row.id, "materialId": row.material_id or "", "infusionScheduleId": row.infusion_schedule_id or "", "qcMaterialId": row.qc_material_id or "", "qty": float(row.qty) if row.qty is not None else 0, "materialCode": row.material_code or "", "materialName": row.material_name or "", "specModel": row.spec_model or "", "unit": row.unit or "", "remark": row.remark or "", "orderNum": row.order_num if row.order_num is not None else 0, } def _paper_row_to_dict(row: BizOutboundReleasePaper) -> Dict[str, Any]: return { "id": row.id, "materialId": row.material_id or "", "infusionScheduleId": row.infusion_schedule_id or "", "paperName": row.paper_name or "", "copies": row.copies if row.copies is not None else 1, "attachmentUrl": row.attachment_url or "", "remark": row.remark or "", "orderNum": row.order_num if row.order_num is not None else 0, } def _legacy_paper_rows_from_attachments(paper_attachments: Optional[str]) -> List[Dict[str, Any]]: urls = (paper_attachments or "").strip() if not urls: return [] rows: List[Dict[str, Any]] = [] for idx, file_url in enumerate([u.strip() for u in urls.split(",") if u.strip()]): file_name = file_url.split("/").pop() or "附件" rows.append( { "id": "", "materialId": "", "infusionScheduleId": "", "paperName": file_name, "copies": 1, "attachmentUrl": file_url, "remark": "", "orderNum": idx + 1, } ) return rows def _material_header_to_dict( header: Optional[BizOutboundReleaseMaterial], cells: List[Dict[str, Any]], qc_materials: List[Dict[str, Any]], paper_materials: List[Dict[str, Any]], ) -> Dict[str, Any]: if not header: return { "id": "", "infusionScheduleId": "", "paperAttachments": "", "paperMaterials": paper_materials, "remark": "", "cells": cells, "qcMaterials": qc_materials, "createTime": "", "updateTime": "", } return { "id": header.id, "infusionScheduleId": header.infusion_schedule_id or "", "paperAttachments": header.paper_attachments or "", "paperMaterials": paper_materials, "remark": header.remark or "", "cells": cells, "qcMaterials": qc_materials, "createTime": _fmt_dt(header.create_time), "updateTime": _fmt_dt(header.update_time), } def _soft_delete_outbound_cells_not_in( db: Session, schedule_id: str, tenant_no: str, keep_cell_product_ids: set, user_id: str, now: datetime, ) -> None: q = db.query(BizOutboundReleaseCell).filter( BizOutboundReleaseCell.infusion_schedule_id == schedule_id, BizOutboundReleaseCell.tenant_no == tenant_no, BizOutboundReleaseCell.del_flag == 0, ) if keep_cell_product_ids: q = q.filter(BizOutboundReleaseCell.cell_product_id.notin_(keep_cell_product_ids)) q.update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False) def _soft_delete_outbound_qc_materials_not_in( db: Session, schedule_id: str, tenant_no: str, keep_qc_material_ids: set, user_id: str, now: datetime, ) -> None: q = db.query(BizOutboundReleaseQcMaterial).filter( BizOutboundReleaseQcMaterial.infusion_schedule_id == schedule_id, BizOutboundReleaseQcMaterial.tenant_no == tenant_no, BizOutboundReleaseQcMaterial.del_flag == 0, ) if keep_qc_material_ids: q = q.filter(BizOutboundReleaseQcMaterial.qc_material_id.notin_(keep_qc_material_ids)) q.update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False) def _find_outbound_cell_row( db: Session, schedule_id: str, tenant_no: str, cell_product_id: str ) -> Optional[BizOutboundReleaseCell]: """按 schedule+cell 查找台账行(优先未删除)。""" return ( db.query(BizOutboundReleaseCell) .filter( BizOutboundReleaseCell.infusion_schedule_id == schedule_id, BizOutboundReleaseCell.cell_product_id == cell_product_id, BizOutboundReleaseCell.tenant_no == tenant_no, ) .order_by(BizOutboundReleaseCell.del_flag.asc()) .first() ) def _find_outbound_qc_material_row( db: Session, schedule_id: str, tenant_no: str, qc_material_id: str ) -> Optional[BizOutboundReleaseQcMaterial]: return ( db.query(BizOutboundReleaseQcMaterial) .filter( BizOutboundReleaseQcMaterial.infusion_schedule_id == schedule_id, BizOutboundReleaseQcMaterial.qc_material_id == qc_material_id, BizOutboundReleaseQcMaterial.tenant_no == tenant_no, ) .order_by(BizOutboundReleaseQcMaterial.del_flag.asc()) .first() ) def _get_schedule(db: Session, schedule_id: str, tenant_no: str) -> Optional[BizPilotInfusionSchedule]: sid = (schedule_id or "").strip() if not sid: return None return ( db.query(BizPilotInfusionSchedule) .filter( BizPilotInfusionSchedule.id == sid, BizPilotInfusionSchedule.tenant_no == tenant_no, BizPilotInfusionSchedule.del_flag == 0, ) .first() ) def get_outbound_material( db: Session, schedule_id: str, tenant_no: str, ) -> Dict[str, Any]: """获取输注计划的出库物料准备(台账)。""" schedule = _get_schedule(db, schedule_id, tenant_no) if not schedule: raise HTTPException(status_code=404, detail="输注计划不存在") header = ( db.query(BizOutboundReleaseMaterial) .filter( BizOutboundReleaseMaterial.infusion_schedule_id == schedule_id, BizOutboundReleaseMaterial.tenant_no == tenant_no, BizOutboundReleaseMaterial.del_flag == 0, ) .first() ) cell_rows = ( db.query(BizOutboundReleaseCell) .filter( BizOutboundReleaseCell.infusion_schedule_id == schedule_id, BizOutboundReleaseCell.tenant_no == tenant_no, BizOutboundReleaseCell.del_flag == 0, ) .order_by( BizOutboundReleaseCell.order_num.asc(), BizOutboundReleaseCell.create_time.asc(), ) .all() ) qc_rows = ( db.query(BizOutboundReleaseQcMaterial) .filter( BizOutboundReleaseQcMaterial.infusion_schedule_id == schedule_id, BizOutboundReleaseQcMaterial.tenant_no == tenant_no, BizOutboundReleaseQcMaterial.del_flag == 0, ) .order_by( BizOutboundReleaseQcMaterial.order_num.asc(), BizOutboundReleaseQcMaterial.create_time.asc(), ) .all() ) paper_rows = ( db.query(BizOutboundReleasePaper) .filter( BizOutboundReleasePaper.infusion_schedule_id == schedule_id, BizOutboundReleasePaper.tenant_no == tenant_no, BizOutboundReleasePaper.del_flag == 0, ) .order_by( BizOutboundReleasePaper.order_num.asc(), BizOutboundReleasePaper.create_time.asc(), ) .all() ) paper_materials = [_paper_row_to_dict(r) for r in paper_rows] if not paper_materials and header and (header.paper_attachments or "").strip(): paper_materials = _legacy_paper_rows_from_attachments(header.paper_attachments) cell_dicts = _enrich_cell_dicts_with_live_status( db, tenant_no, [_cell_row_to_dict(r) for r in cell_rows], ) return _material_header_to_dict( header, cell_dicts, [_qc_material_row_to_dict(r) for r in qc_rows], paper_materials, ) def save_outbound_material( db: Session, schedule_id: str, *, paper_attachments: Optional[str], paper_materials: List[Dict[str, Any]], remark: Optional[str], cells: List[Dict[str, Any]], qc_materials: List[Dict[str, Any]], tenant_no: str, user_id: str, create_dept_id: str = "", ) -> Dict[str, Any]: """全量保存出库物料准备(台账,不联动库存)。""" schedule = _get_schedule(db, schedule_id, tenant_no) if not schedule: raise HTTPException(status_code=404, detail="输注计划不存在") cell_ids = [ (it.get("cellProductId") or it.get("cell_product_id") or "").strip() for it in (cells or []) ] qc_ids = [ (it.get("materialId") or it.get("material_id") or "").strip() for it in (qc_materials or []) ] if len([x for x in cell_ids if x]) != len(set(x for x in cell_ids if x)): raise HTTPException(status_code=400, detail="同一输注计划中不能重复添加相同细胞产品") if len([x for x in qc_ids if x]) != len(set(x for x in qc_ids if x)): raise HTTPException(status_code=400, detail="同一输注计划中不能重复添加相同物料") now = datetime.now() header = ( db.query(BizOutboundReleaseMaterial) .filter( BizOutboundReleaseMaterial.infusion_schedule_id == schedule_id, BizOutboundReleaseMaterial.tenant_no == tenant_no, BizOutboundReleaseMaterial.del_flag == 0, ) .first() ) if not header: header = BizOutboundReleaseMaterial( id=generate_uuid(), infusion_schedule_id=schedule_id, tenant_no=tenant_no, order_num=0, create_by=user_id, create_dept_id=create_dept_id or "", create_time=now, del_flag=0, ) db.add(header) db.flush() else: header.update_by = user_id header.update_time = now header.paper_attachments = None header.remark = (remark or "").strip() or None db.add(header) incoming_cell_ids = {x for x in cell_ids if x} incoming_qc_ids = {x for x in qc_ids if x} _soft_delete_outbound_cells_not_in( db, schedule_id, tenant_no, incoming_cell_ids, user_id, now ) _soft_delete_outbound_qc_materials_not_in( db, schedule_id, tenant_no, incoming_qc_ids, user_id, now ) db.query(BizOutboundReleasePaper).filter( BizOutboundReleasePaper.infusion_schedule_id == schedule_id, BizOutboundReleasePaper.tenant_no == tenant_no, BizOutboundReleasePaper.del_flag == 0, ).update({"del_flag": 1, "update_by": user_id, "update_time": now}, synchronize_session=False) db.flush() for idx, item in enumerate(cells or []): cp_id = (item.get("cellProductId") or item.get("cell_product_id") or "").strip() if not cp_id: raise HTTPException(status_code=400, detail="细胞产品ID不能为空") product = ( db.query(BizCellProduct) .filter( BizCellProduct.id == cp_id, BizCellProduct.tenant_no == tenant_no, BizCellProduct.del_flag == 0, ) .first() ) if not product: raise HTTPException(status_code=400, detail=f"细胞产品不存在或已删除(ID: {cp_id})") enriched = cell_product_svc._enrich_product_dicts( db, tenant_no, [cell_product_svc._row_to_dict(product)], )[0] cell_row = _find_outbound_cell_row(db, schedule_id, tenant_no, cp_id) if cell_row: cell_row.material_id = header.id cell_row.product_code = enriched.get("productCode") or "" cell_row.product_name = enriched.get("productName") or "" cell_row.product_type_name = enriched.get("productTypeName") or "" cell_row.spec_name = enriched.get("specName") or "" cell_row.passage = enriched.get("passage") or "" cell_row.batch_no = enriched.get("batchNo") or "" cell_row.storage_slot_name = enriched.get("storageSlotName") or "" cell_row.product_status = enriched.get("productStatus") or "" cell_row.order_num = idx + 1 cell_row.remark = (item.get("remark") or "").strip() or None cell_row.del_flag = 0 cell_row.update_by = user_id cell_row.update_time = now db.add(cell_row) else: db.add( BizOutboundReleaseCell( id=generate_uuid(), material_id=header.id, infusion_schedule_id=schedule_id, cell_product_id=cp_id, product_code=enriched.get("productCode") or "", product_name=enriched.get("productName") or "", product_type_name=enriched.get("productTypeName") or "", spec_name=enriched.get("specName") or "", passage=enriched.get("passage") or "", batch_no=enriched.get("batchNo") or "", storage_slot_name=enriched.get("storageSlotName") or "", product_status=enriched.get("productStatus") or "", tenant_no=tenant_no, order_num=idx + 1, remark=(item.get("remark") or "").strip() or None, create_by=user_id, create_dept_id=create_dept_id or "", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) ) for idx, item in enumerate(qc_materials or []): mid = (item.get("materialId") or item.get("material_id") or "").strip() qty_raw = item.get("qty") if not mid: raise HTTPException(status_code=400, detail="市场物料ID不能为空") try: qty_dec = Decimal(str(qty_raw)) except Exception: raise HTTPException(status_code=400, detail="物料用量格式无效") if qty_dec <= 0: raise HTTPException(status_code=400, detail="物料用量必须大于0") material = ( db.query(BizMaterial) .filter( BizMaterial.id == mid, BizMaterial.tenant_no == tenant_no, BizMaterial.del_flag == 0, ) .first() ) if not material: raise HTTPException(status_code=400, detail=f"市场物料不存在或已删除(ID: {mid})") qc_row = _find_outbound_qc_material_row(db, schedule_id, tenant_no, mid) if qc_row: qc_row.material_id = header.id qc_row.qty = qty_dec qc_row.material_code = material.material_code or "" qc_row.material_name = material.material_name or "" qc_row.spec_model = material.spec_model or "" qc_row.unit = material.unit or "" qc_row.order_num = idx + 1 qc_row.remark = (item.get("remark") or "").strip() or None qc_row.del_flag = 0 qc_row.update_by = user_id qc_row.update_time = now db.add(qc_row) else: db.add( BizOutboundReleaseQcMaterial( id=generate_uuid(), material_id=header.id, infusion_schedule_id=schedule_id, qc_material_id=mid, qty=qty_dec, material_code=material.material_code or "", material_name=material.material_name or "", spec_model=material.spec_model or "", unit=material.unit or "", tenant_no=tenant_no, order_num=idx + 1, remark=(item.get("remark") or "").strip() or None, create_by=user_id, create_dept_id=create_dept_id or "", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) ) for idx, item in enumerate(paper_materials or []): paper_name = (item.get("paperName") or item.get("paper_name") or "").strip() attachment_url = (item.get("attachmentUrl") or item.get("attachment_url") or "").strip() copies_raw = item.get("copies") if not paper_name: raise HTTPException(status_code=400, detail="纸质材料名称不能为空") if not attachment_url: raise HTTPException(status_code=400, detail="纸质材料附件不能为空") try: copies = int(copies_raw) except Exception: raise HTTPException(status_code=400, detail="纸质材料份数格式无效") if copies <= 0: raise HTTPException(status_code=400, detail="纸质材料份数必须大于0") db.add( BizOutboundReleasePaper( id=generate_uuid(), material_id=header.id, infusion_schedule_id=schedule_id, paper_name=paper_name, copies=copies, attachment_url=attachment_url, tenant_no=tenant_no, order_num=idx + 1, remark=(item.get("remark") or "").strip() or None, create_by=user_id, create_dept_id=create_dept_id or "", create_time=now, update_by=user_id, update_time=now, del_flag=0, ) ) db.flush() return get_outbound_material(db, schedule_id, tenant_no)