328 lines
11 KiB
Python
328 lines
11 KiB
Python
"""输注计划出库交接 — 细胞产品状态与质检物料库存联动。"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||
|
||
from fastapi import HTTPException
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.utils import generate_uuid
|
||
from app.crud import storage_occupancy as occupancy_svc
|
||
from app.models.models import BizCellProduct, BizStorageSlotOccupancy
|
||
from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage
|
||
|
||
# 出库交接后细胞产品目标状态(已售卖/已出库)
|
||
HANDOVER_CELL_OUT_STATUS = "sold"
|
||
HANDOVER_QC_SOURCE_TYPE = "outbound_handover"
|
||
|
||
|
||
def _handover_usage_remark(schedule_id: str) -> str:
|
||
return f"输注计划出库交接;scheduleId={schedule_id}"
|
||
|
||
|
||
def _handover_usage_remark_prefix(schedule_id: str) -> str:
|
||
return f"scheduleId={schedule_id}"
|
||
|
||
|
||
def _remove_cell_occupancy_no_commit(
|
||
db: Session,
|
||
item_id: str,
|
||
*,
|
||
user_id: Optional[str] = None,
|
||
) -> None:
|
||
"""按细胞产品 ID 释放库位占用(不 commit)。"""
|
||
obj = (
|
||
db.query(BizStorageSlotOccupancy)
|
||
.filter(
|
||
BizStorageSlotOccupancy.item_type == "cell_product",
|
||
BizStorageSlotOccupancy.item_id == item_id,
|
||
BizStorageSlotOccupancy.occupancy_status == "stored",
|
||
BizStorageSlotOccupancy.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not obj:
|
||
return
|
||
now = datetime.now()
|
||
obj.occupancy_status = "removed"
|
||
obj.removed_at = now
|
||
obj.update_by = user_id
|
||
obj.update_time = now
|
||
db.add(obj)
|
||
db.flush()
|
||
occupancy_svc._refresh_slot_cache(db, obj.slot_id)
|
||
|
||
|
||
def _restore_cell_occupancy_no_commit(
|
||
db: Session,
|
||
product: BizCellProduct,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
) -> None:
|
||
"""出库交接回滚时恢复库位占用(不 commit)。"""
|
||
slot_id = (product.storage_slot_id or "").strip()
|
||
if not slot_id:
|
||
return
|
||
existing_stored = (
|
||
db.query(BizStorageSlotOccupancy)
|
||
.filter(
|
||
BizStorageSlotOccupancy.item_type == "cell_product",
|
||
BizStorageSlotOccupancy.item_id == product.id,
|
||
BizStorageSlotOccupancy.occupancy_status == "stored",
|
||
BizStorageSlotOccupancy.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if existing_stored:
|
||
return
|
||
now = datetime.now()
|
||
latest_removed = (
|
||
db.query(BizStorageSlotOccupancy)
|
||
.filter(
|
||
BizStorageSlotOccupancy.item_type == "cell_product",
|
||
BizStorageSlotOccupancy.item_id == product.id,
|
||
BizStorageSlotOccupancy.occupancy_status == "removed",
|
||
BizStorageSlotOccupancy.del_flag == 0,
|
||
)
|
||
.order_by(BizStorageSlotOccupancy.removed_at.desc())
|
||
.first()
|
||
)
|
||
if latest_removed:
|
||
latest_removed.occupancy_status = "stored"
|
||
latest_removed.removed_at = None
|
||
latest_removed.update_by = user_id
|
||
latest_removed.update_time = now
|
||
db.add(latest_removed)
|
||
db.flush()
|
||
occupancy_svc._refresh_slot_cache(db, latest_removed.slot_id)
|
||
return
|
||
db.add(
|
||
BizStorageSlotOccupancy(
|
||
id=generate_uuid(),
|
||
slot_id=slot_id,
|
||
item_type="cell_product",
|
||
item_id=product.id,
|
||
item_code=(product.product_code or "").strip() or None,
|
||
item_name=(product.product_name or "").strip() or None,
|
||
item_source="outbound_handover_revert",
|
||
stored_at=now,
|
||
removed_at=None,
|
||
occupancy_status="stored",
|
||
tenant_no=tenant_no,
|
||
order_num=0,
|
||
remark="出库交接回滚恢复占用",
|
||
create_by=user_id,
|
||
create_dept_id=product.create_dept_id or "",
|
||
create_time=now,
|
||
update_by=user_id,
|
||
update_time=now,
|
||
del_flag=0,
|
||
)
|
||
)
|
||
db.flush()
|
||
occupancy_svc._refresh_slot_cache(db, slot_id)
|
||
|
||
|
||
def _get_cell_product_locked(
|
||
db: Session, cell_product_id: str, tenant_no: str
|
||
) -> BizCellProduct:
|
||
product = (
|
||
db.query(BizCellProduct)
|
||
.with_for_update()
|
||
.filter(
|
||
BizCellProduct.id == cell_product_id,
|
||
BizCellProduct.tenant_no == tenant_no,
|
||
BizCellProduct.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not product:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"细胞产品不存在或已删除(ID: {cell_product_id})",
|
||
)
|
||
return product
|
||
|
||
|
||
def revert_handover_inventory(
|
||
db: Session,
|
||
schedule_id: str,
|
||
*,
|
||
cell_product_ids: Iterable[str],
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
) -> None:
|
||
"""回滚本输注计划上次出库交接产生的库存/状态变更(不 commit)。"""
|
||
sid = (schedule_id or "").strip()
|
||
if not sid:
|
||
return
|
||
|
||
remark_prefix = _handover_usage_remark_prefix(sid)
|
||
usage_rows = (
|
||
db.query(BizQcMaterialUsage)
|
||
.filter(
|
||
BizQcMaterialUsage.tenant_no == tenant_no,
|
||
BizQcMaterialUsage.del_flag == 0,
|
||
BizQcMaterialUsage.source_type == HANDOVER_QC_SOURCE_TYPE,
|
||
BizQcMaterialUsage.change_type == "use",
|
||
BizQcMaterialUsage.remark.like(f"%{remark_prefix}%"),
|
||
)
|
||
.all()
|
||
)
|
||
now = datetime.now()
|
||
for usage in usage_rows:
|
||
restore_qty = abs(usage.change_qty) if usage.change_qty is not None else Decimal("0")
|
||
if restore_qty > 0:
|
||
material = (
|
||
db.query(BizQcMaterial)
|
||
.with_for_update()
|
||
.filter(
|
||
BizQcMaterial.id == usage.material_id,
|
||
BizQcMaterial.tenant_no == tenant_no,
|
||
BizQcMaterial.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if material:
|
||
material.stock_qty = (material.stock_qty or Decimal("0")) + restore_qty
|
||
material.update_by = user_id
|
||
material.update_time = now
|
||
db.add(material)
|
||
usage.del_flag = 1
|
||
usage.update_by = user_id
|
||
usage.update_time = now
|
||
db.add(usage)
|
||
|
||
seen: Set[str] = set()
|
||
for raw_id in cell_product_ids:
|
||
cp_id = (raw_id or "").strip()
|
||
if not cp_id or cp_id in seen:
|
||
continue
|
||
seen.add(cp_id)
|
||
product = _get_cell_product_locked(db, cp_id, tenant_no)
|
||
current = (product.product_status or "frozen").strip()
|
||
if current != HANDOVER_CELL_OUT_STATUS:
|
||
continue
|
||
product.product_status = "frozen"
|
||
product.update_by = user_id
|
||
product.update_time = now
|
||
db.add(product)
|
||
_restore_cell_occupancy_no_commit(
|
||
db, product, tenant_no=tenant_no, user_id=user_id
|
||
)
|
||
|
||
db.flush()
|
||
|
||
|
||
def apply_handover_inventory(
|
||
db: Session,
|
||
schedule_id: str,
|
||
*,
|
||
cells: List[Dict[str, Any]],
|
||
qc_materials: List[Dict[str, Any]],
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
create_dept_id: str = "",
|
||
receiver_person: str = "",
|
||
) -> None:
|
||
"""应用出库交接:细胞产品出库 + 质检物料扣减(不 commit)。"""
|
||
sid = (schedule_id or "").strip()
|
||
usage_remark = _handover_usage_remark(sid)
|
||
receiver = (receiver_person or "").strip() or None
|
||
now = datetime.now()
|
||
|
||
for item in cells or []:
|
||
cp_id = (item.get("cellProductId") or item.get("cell_product_id") or "").strip()
|
||
if not cp_id:
|
||
continue
|
||
product = _get_cell_product_locked(db, cp_id, tenant_no)
|
||
current = (product.product_status or "frozen").strip()
|
||
if current != "frozen":
|
||
label = product.product_code or product.product_name or cp_id
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"细胞产品「{label}」当前状态不可出库交接"
|
||
f"(仅「已冻存」可出库,当前为 {current or '未知'})"
|
||
),
|
||
)
|
||
product.product_status = HANDOVER_CELL_OUT_STATUS
|
||
product.update_by = user_id
|
||
product.update_time = now
|
||
db.add(product)
|
||
_remove_cell_occupancy_no_commit(db, cp_id, user_id=user_id)
|
||
|
||
for item in 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)
|
||
.with_for_update()
|
||
.filter(
|
||
BizQcMaterial.id == mid,
|
||
BizQcMaterial.tenant_no == tenant_no,
|
||
BizQcMaterial.del_flag == 0,
|
||
BizQcMaterial.status == "1",
|
||
)
|
||
.first()
|
||
)
|
||
if not material:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"质检物料不存在或已停用(ID: {mid})",
|
||
)
|
||
|
||
stock_before = material.stock_qty if material.stock_qty is not None else Decimal("0")
|
||
if stock_before < qty_dec:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"物料「{material.material_name}」库存不足,"
|
||
f"当前库存 {stock_before},需出库 {qty_dec}"
|
||
),
|
||
)
|
||
|
||
stock_after = stock_before - qty_dec
|
||
material.stock_qty = stock_after
|
||
material.update_by = user_id
|
||
material.update_time = now
|
||
db.add(material)
|
||
|
||
line_remark = (item.get("remark") or "").strip()
|
||
db.add(
|
||
BizQcMaterialUsage(
|
||
id=generate_uuid(),
|
||
material_id=mid,
|
||
material_code=material.material_code,
|
||
material_name=material.material_name,
|
||
change_type="use",
|
||
change_qty=-qty_dec,
|
||
stock_after=stock_after,
|
||
source_type=HANDOVER_QC_SOURCE_TYPE,
|
||
tenant_no=tenant_no,
|
||
receiver=receiver,
|
||
purpose="输注计划出库交接",
|
||
remark=usage_remark if not line_remark else f"{usage_remark};{line_remark}",
|
||
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()
|