| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571 |
- """出库放行审核 · 出库物料准备(台账 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)
|