529 lines
19 KiB
Python
529 lines
19 KiB
Python
"""出库放行审核 · 出库物料准备(台账 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.qc_material import BizQcMaterial
|
||
|
||
|
||
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 _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)
|
||
|
||
return _material_header_to_dict(
|
||
header,
|
||
[_cell_row_to_dict(r) for r in cell_rows],
|
||
[_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(BizQcMaterial)
|
||
.filter(
|
||
BizQcMaterial.id == mid,
|
||
BizQcMaterial.tenant_no == tenant_no,
|
||
BizQcMaterial.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)
|