486 lines
16 KiB
Python
486 lines
16 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.crud.material import _is_enabled_status
|
||
from app.crud.material_stock_batch import _get_material_for_update, _usage_batch_snapshots
|
||
from app.models.material import BizMaterialStockBatch, BizMaterialUsage
|
||
from app.models.models import BizCellProduct, BizStorageSlotOccupancy
|
||
from app.models.qc_material import BizQcMaterial, BizQcMaterialUsage
|
||
|
||
# 出库交接后细胞产品目标状态(已售卖/已出库)
|
||
HANDOVER_CELL_OUT_STATUS = "sold"
|
||
HANDOVER_MARKET_SOURCE_TYPE = "outbound_handover"
|
||
# 历史数据:曾扣减质检物料库存
|
||
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:
|
||
occupancy_svc.assert_slot_vacant(
|
||
db, latest_removed.slot_id, tenant_no=tenant_no, exclude_product_id=product.id,
|
||
)
|
||
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
|
||
occupancy_svc.assert_slot_vacant(
|
||
db, slot_id, tenant_no=tenant_no, exclude_product_id=product.id,
|
||
)
|
||
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_legacy_qc_handover_usages(
|
||
db: Session,
|
||
*,
|
||
schedule_id: str,
|
||
tenant_no: str,
|
||
user_id: Optional[str],
|
||
remark_prefix: str,
|
||
now: datetime,
|
||
) -> None:
|
||
"""回滚历史版本曾写入的质检物料出库交接记录。"""
|
||
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()
|
||
)
|
||
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)
|
||
|
||
|
||
def _revert_market_handover_usages(
|
||
db: Session,
|
||
*,
|
||
schedule_id: str,
|
||
tenant_no: str,
|
||
user_id: Optional[str],
|
||
remark_prefix: str,
|
||
now: datetime,
|
||
) -> None:
|
||
"""回滚市场物料出库交接记录(含批次剩余量)。"""
|
||
usage_rows = (
|
||
db.query(BizMaterialUsage)
|
||
.filter(
|
||
BizMaterialUsage.tenant_no == tenant_no,
|
||
BizMaterialUsage.del_flag == 0,
|
||
BizMaterialUsage.source_type == HANDOVER_MARKET_SOURCE_TYPE,
|
||
BizMaterialUsage.change_type == "use",
|
||
BizMaterialUsage.remark.like(f"%{remark_prefix}%"),
|
||
)
|
||
.all()
|
||
)
|
||
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 = _get_material_for_update(db, usage.material_id, tenant_no)
|
||
material.stock_qty = (material.stock_qty or Decimal("0")) + restore_qty
|
||
material.update_by = user_id
|
||
material.update_time = now
|
||
db.add(material)
|
||
|
||
batch_id = (usage.batch_id or "").strip()
|
||
if batch_id:
|
||
batch = (
|
||
db.query(BizMaterialStockBatch)
|
||
.with_for_update()
|
||
.filter(
|
||
BizMaterialStockBatch.id == batch_id,
|
||
BizMaterialStockBatch.tenant_no == tenant_no,
|
||
BizMaterialStockBatch.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if batch:
|
||
batch.remaining_qty = (batch.remaining_qty or Decimal("0")) + restore_qty
|
||
batch.update_by = user_id
|
||
batch.update_time = now
|
||
db.add(batch)
|
||
|
||
usage.del_flag = 1
|
||
usage.update_by = user_id
|
||
usage.update_time = now
|
||
db.add(usage)
|
||
|
||
|
||
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)
|
||
now = datetime.now()
|
||
_revert_legacy_qc_handover_usages(
|
||
db,
|
||
schedule_id=sid,
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
remark_prefix=remark_prefix,
|
||
now=now,
|
||
)
|
||
_revert_market_handover_usages(
|
||
db,
|
||
schedule_id=sid,
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
remark_prefix=remark_prefix,
|
||
now=now,
|
||
)
|
||
|
||
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 _deduct_market_material_for_handover(
|
||
db: Session,
|
||
material_id: str,
|
||
qty: Decimal,
|
||
*,
|
||
schedule_id: str,
|
||
tenant_no: str,
|
||
user_id: Optional[str],
|
||
create_dept_id: str,
|
||
receiver_person: str,
|
||
line_remark: str,
|
||
now: datetime,
|
||
) -> None:
|
||
"""按 FIFO 从市场物料入库批次扣减,并写入变动记录。"""
|
||
material = _get_material_for_update(db, material_id, tenant_no)
|
||
if not _is_enabled_status(material.status):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"市场物料「{material.material_name}」已停用,无法出库",
|
||
)
|
||
|
||
stock_before = material.stock_qty if material.stock_qty is not None else Decimal("0")
|
||
if stock_before < qty:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"物料「{material.material_name}」库存不足,"
|
||
f"当前库存 {stock_before},需出库 {qty}"
|
||
),
|
||
)
|
||
|
||
batches = (
|
||
db.query(BizMaterialStockBatch)
|
||
.with_for_update()
|
||
.filter(
|
||
BizMaterialStockBatch.material_id == material_id,
|
||
BizMaterialStockBatch.tenant_no == tenant_no,
|
||
BizMaterialStockBatch.del_flag == 0,
|
||
BizMaterialStockBatch.remaining_qty > 0,
|
||
)
|
||
.order_by(
|
||
BizMaterialStockBatch.stock_in_date.asc(),
|
||
BizMaterialStockBatch.create_time.asc(),
|
||
)
|
||
.all()
|
||
)
|
||
|
||
usage_remark = _handover_usage_remark(schedule_id)
|
||
receiver = (receiver_person or "").strip() or None
|
||
remaining = qty
|
||
running_stock = stock_before
|
||
|
||
for batch in batches:
|
||
if remaining <= 0:
|
||
break
|
||
rem = batch.remaining_qty if batch.remaining_qty is not None else Decimal("0")
|
||
if rem <= 0:
|
||
continue
|
||
take = min(rem, remaining)
|
||
batch.remaining_qty = rem - take
|
||
batch.update_by = user_id
|
||
batch.update_time = now
|
||
db.add(batch)
|
||
|
||
stock_after = running_stock - take
|
||
snaps = _usage_batch_snapshots(batch, out_qty=take)
|
||
db.add(
|
||
BizMaterialUsage(
|
||
id=generate_uuid(),
|
||
material_id=material.id,
|
||
material_code=material.material_code,
|
||
material_name=material.material_name,
|
||
change_type="use",
|
||
change_qty=-take,
|
||
usage_unit=(material.unit or "").strip() or None,
|
||
stock_before=running_stock,
|
||
stock_after=stock_after,
|
||
source_type=HANDOVER_MARKET_SOURCE_TYPE,
|
||
requisition_status="closed",
|
||
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,
|
||
**snaps,
|
||
)
|
||
)
|
||
running_stock = stock_after
|
||
remaining -= take
|
||
|
||
if remaining > 0:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=(
|
||
f"物料「{material.material_name}」批次库存不足,"
|
||
f"汇总库存 {stock_before},需出库 {qty},批次可分配 {qty - remaining}"
|
||
),
|
||
)
|
||
|
||
material.stock_qty = stock_before - qty
|
||
material.update_by = user_id
|
||
material.update_time = now
|
||
db.add(material)
|
||
|
||
|
||
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()
|
||
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")
|
||
|
||
line_remark = (item.get("remark") or "").strip()
|
||
_deduct_market_material_for_handover(
|
||
db,
|
||
mid,
|
||
qty_dec,
|
||
schedule_id=sid,
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
create_dept_id=create_dept_id,
|
||
receiver_person=receiver_person,
|
||
line_remark=line_remark,
|
||
now=now,
|
||
)
|
||
|
||
db.flush()
|