Files
shanxia2.0/backend/app/crud/outbound_handover_inventory.py
T
2026-05-28 08:37:37 +08:00

328 lines
11 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.
"""输注计划出库交接 — 细胞产品状态与质检物料库存联动。"""
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()