Files
zkxw/backend/app/crud/outbound_release_material.py
T
2026-06-16 20:29:25 +08:00

572 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""出库放行审核 · 出库物料准备(台账 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)