Enable web-admin batch outbound to call existing remove occupancy API per selected cell. Co-authored-by: Cursor <cursoragent@cursor.com>
570 lines
18 KiB
Python
570 lines
18 KiB
Python
"""库位占用记录 CRUD(biz_storage_slot_occupancy)"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from sqlalchemy import func
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.utils import generate_uuid
|
||
from app.models.models import (
|
||
BizCellProduct,
|
||
BizStorageFacilitySlot,
|
||
BizStorageSlotOccupancy,
|
||
)
|
||
|
||
SLOT_OCCUPIED_MSG = "该储物格已被占用,请选择其他储物格"
|
||
_BATCH_DUPLICATE_SLOT_MSG = "同一批次不能选择重复的储物格"
|
||
|
||
_CELL_PRODUCT_IN_STORE_STATUSES = frozenset({"frozen", "thawed", "in_production"})
|
||
|
||
|
||
def assert_batch_slots_unique(slot_ids: List[Optional[str]]) -> None:
|
||
"""同一批次内储物格不可重复。"""
|
||
seen: set[str] = set()
|
||
for sid in slot_ids:
|
||
slot_id = (sid or "").strip()
|
||
if not slot_id:
|
||
continue
|
||
if slot_id in seen:
|
||
raise ValueError(_BATCH_DUPLICATE_SLOT_MSG)
|
||
seen.add(slot_id)
|
||
|
||
|
||
def assert_slot_vacant(
|
||
db: Session,
|
||
slot_id: str,
|
||
*,
|
||
tenant_no: str = "",
|
||
exclude_occupancy_id: Optional[str] = None,
|
||
exclude_product_id: Optional[str] = None,
|
||
) -> None:
|
||
"""断言储物格当前无在库物品(任意 item_type)。"""
|
||
slot_id = (slot_id or "").strip()
|
||
if not slot_id:
|
||
return
|
||
|
||
slot = (
|
||
db.query(BizStorageFacilitySlot)
|
||
.filter(
|
||
BizStorageFacilitySlot.id == slot_id,
|
||
BizStorageFacilitySlot.del_flag == 0,
|
||
)
|
||
.with_for_update()
|
||
.first()
|
||
)
|
||
if not slot:
|
||
raise ValueError("储物格不存在")
|
||
if tenant_no and slot.tenant_no and slot.tenant_no != tenant_no:
|
||
raise ValueError("储物格不存在")
|
||
|
||
occ_q = db.query(BizStorageSlotOccupancy).filter(
|
||
BizStorageSlotOccupancy.slot_id == slot_id,
|
||
BizStorageSlotOccupancy.occupancy_status == "stored",
|
||
BizStorageSlotOccupancy.del_flag == 0,
|
||
)
|
||
if exclude_occupancy_id:
|
||
occ_q = occ_q.filter(BizStorageSlotOccupancy.id != exclude_occupancy_id)
|
||
if occ_q.first():
|
||
raise ValueError(SLOT_OCCUPIED_MSG)
|
||
|
||
prod_q = db.query(BizCellProduct).filter(
|
||
BizCellProduct.storage_slot_id == slot_id,
|
||
BizCellProduct.del_flag == 0,
|
||
BizCellProduct.product_status.in_(list(_CELL_PRODUCT_IN_STORE_STATUSES)),
|
||
)
|
||
if tenant_no:
|
||
prod_q = prod_q.filter(BizCellProduct.tenant_no == tenant_no)
|
||
if exclude_product_id:
|
||
prod_q = prod_q.filter(BizCellProduct.id != exclude_product_id)
|
||
if prod_q.first():
|
||
raise ValueError(SLOT_OCCUPIED_MSG)
|
||
|
||
|
||
def _add_stored_occupancy_no_commit(
|
||
db: Session,
|
||
*,
|
||
slot_id: str,
|
||
item_type: str,
|
||
item_id: str,
|
||
item_code: str,
|
||
item_name: str,
|
||
item_source: str,
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
create_dept_id: str = "",
|
||
remark: Optional[str] = None,
|
||
stored_at: Optional[datetime] = None,
|
||
) -> BizStorageSlotOccupancy:
|
||
now = stored_at or datetime.now()
|
||
obj = BizStorageSlotOccupancy(
|
||
id=generate_uuid(),
|
||
slot_id=slot_id,
|
||
item_type=item_type,
|
||
item_id=item_id,
|
||
item_code=(item_code or "").strip() or None,
|
||
item_name=(item_name or "").strip() or None,
|
||
item_source=(item_source or "manual").strip(),
|
||
stored_at=now,
|
||
removed_at=None,
|
||
occupancy_status="stored",
|
||
tenant_no=tenant_no,
|
||
order_num=0,
|
||
remark=(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.add(obj)
|
||
db.flush()
|
||
_refresh_slot_cache(db, slot_id)
|
||
return obj
|
||
|
||
|
||
def remove_by_item_no_commit(
|
||
db: Session,
|
||
item_type: str,
|
||
item_id: str,
|
||
*,
|
||
user_id: Optional[str] = None,
|
||
) -> None:
|
||
"""按物品出库(不 commit)。"""
|
||
obj = db.query(BizStorageSlotOccupancy).filter(
|
||
BizStorageSlotOccupancy.item_type == item_type,
|
||
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()
|
||
_refresh_slot_cache(db, obj.slot_id)
|
||
|
||
|
||
def _row_to_dict(c: BizStorageSlotOccupancy) -> Dict[str, Any]:
|
||
return {
|
||
"id": c.id,
|
||
"slotId": c.slot_id,
|
||
"itemType": c.item_type or "",
|
||
"itemId": c.item_id or "",
|
||
"itemCode": c.item_code or "",
|
||
"itemName": c.item_name or "",
|
||
"itemSource": c.item_source or "",
|
||
"storedAt": c.stored_at.strftime("%Y-%m-%d %H:%M:%S") if c.stored_at else "",
|
||
"removedAt": c.removed_at.strftime("%Y-%m-%d %H:%M:%S") if c.removed_at else "",
|
||
"occupancyStatus": c.occupancy_status or "",
|
||
"tenantNo": c.tenant_no,
|
||
"remark": c.remark or "",
|
||
"createBy": c.create_by or "",
|
||
"createTime": c.create_time.strftime("%Y-%m-%d %H:%M:%S") if c.create_time else "",
|
||
}
|
||
|
||
|
||
def _load_cell_product_map(
|
||
db: Session,
|
||
tenant_no: str,
|
||
product_ids: List[str],
|
||
) -> Dict[str, Dict[str, Any]]:
|
||
"""批量加载细胞产品完整信息(一次 enrich,避免 per-id 的 N+1)。"""
|
||
ids = [i for i in {(x or "").strip() for x in product_ids} if i]
|
||
if not ids:
|
||
return {}
|
||
from app.crud.cell_product import _enrich_product_dicts, _row_to_dict
|
||
|
||
q = db.query(BizCellProduct).filter(
|
||
BizCellProduct.id.in_(ids),
|
||
BizCellProduct.del_flag == 0,
|
||
)
|
||
if tenant_no:
|
||
q = q.filter(BizCellProduct.tenant_no == tenant_no)
|
||
rows = q.all()
|
||
enriched = _enrich_product_dicts(db, tenant_no, [_row_to_dict(p) for p in rows])
|
||
return {item["id"]: item for item in enriched if item.get("id")}
|
||
|
||
|
||
def _load_cell_product_shelf_summaries(
|
||
db: Session,
|
||
tenant_no: str,
|
||
product_ids: List[str],
|
||
) -> Dict[str, Dict[str, Any]]:
|
||
"""库位货架展示专用:仅批量取 id/编码/名称/类型名/批次号(2 次查询)。"""
|
||
from app.models.models import BizProductionProductType
|
||
|
||
ids = [i for i in {(x or "").strip() for x in product_ids} if i]
|
||
if not ids:
|
||
return {}
|
||
q = db.query(
|
||
BizCellProduct.id,
|
||
BizCellProduct.product_code,
|
||
BizCellProduct.product_name,
|
||
BizCellProduct.product_type_id,
|
||
BizCellProduct.batch_no,
|
||
).filter(
|
||
BizCellProduct.id.in_(ids),
|
||
BizCellProduct.del_flag == 0,
|
||
)
|
||
if tenant_no:
|
||
q = q.filter(BizCellProduct.tenant_no == tenant_no)
|
||
products = q.all()
|
||
type_ids = {r.product_type_id for r in products if r.product_type_id}
|
||
type_map: Dict[str, str] = {}
|
||
if type_ids:
|
||
type_rows = (
|
||
db.query(BizProductionProductType.id, BizProductionProductType.product_type_name)
|
||
.filter(
|
||
BizProductionProductType.id.in_(type_ids),
|
||
BizProductionProductType.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
type_map = {r.id: r.product_type_name or "" for r in type_rows}
|
||
return {
|
||
r.id: {
|
||
"id": r.id,
|
||
"productCode": r.product_code or "",
|
||
"productName": r.product_name or "",
|
||
"productTypeId": r.product_type_id or "",
|
||
"productTypeName": type_map.get(r.product_type_id or "", ""),
|
||
"batchNo": r.batch_no or "",
|
||
}
|
||
for r in products
|
||
}
|
||
|
||
|
||
def _attach_cell_product_fields(item: Dict[str, Any], cp: Dict[str, Any]) -> None:
|
||
item["cellProduct"] = cp
|
||
item["productTypeName"] = cp.get("productTypeName") or ""
|
||
item["specName"] = cp.get("specName") or ""
|
||
item["passage"] = cp.get("passage") or ""
|
||
item["batchNo"] = cp.get("batchNo") or ""
|
||
item["productStatus"] = cp.get("productStatus") or ""
|
||
item["expectedCellCount"] = cp.get("expectedCellCount")
|
||
item["viability"] = cp.get("viability")
|
||
freeze = cp.get("freezeDate") or ""
|
||
expiry = cp.get("expiryDate") or ""
|
||
item["freezeDate"] = freeze[:10] if freeze else ""
|
||
item["expiryDate"] = expiry[:10] if expiry else ""
|
||
item["cellProductSourceTypeLabel"] = cp.get("sourceTypeLabel") or ""
|
||
|
||
|
||
def enrich_occupancy_items(
|
||
db: Session,
|
||
tenant_no: str,
|
||
items: List[Dict[str, Any]],
|
||
) -> List[Dict[str, Any]]:
|
||
cp_ids = [
|
||
(i.get("itemId") or "").strip()
|
||
for i in items
|
||
if (i.get("itemType") or "") == "cell_product" and (i.get("itemId") or "").strip()
|
||
]
|
||
cp_map = _load_cell_product_map(db, tenant_no, cp_ids)
|
||
for item in items:
|
||
if (item.get("itemType") or "") != "cell_product":
|
||
continue
|
||
cp = cp_map.get((item.get("itemId") or "").strip())
|
||
if cp:
|
||
_attach_cell_product_fields(item, cp)
|
||
return items
|
||
|
||
|
||
def enrich_slots_with_stored_cell_products(
|
||
db: Session,
|
||
tenant_no: str,
|
||
slots: List[Dict[str, Any]],
|
||
) -> List[Dict[str, Any]]:
|
||
"""为占用格附带在库占用 id 与物品摘要(名称/类型),供库位货架展示与批量出库。"""
|
||
slot_ids = [s.get("id") for s in slots if int(s.get("occupiedCount") or 0) > 0 and s.get("id")]
|
||
if not slot_ids:
|
||
return slots
|
||
occs = (
|
||
db.query(BizStorageSlotOccupancy)
|
||
.filter(
|
||
BizStorageSlotOccupancy.slot_id.in_(slot_ids),
|
||
BizStorageSlotOccupancy.occupancy_status == "stored",
|
||
BizStorageSlotOccupancy.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
occ_by_slot = {o.slot_id: o for o in occs}
|
||
cp_ids = [
|
||
o.item_id
|
||
for o in occs
|
||
if (o.item_type or "") == "cell_product" and (o.item_id or "").strip()
|
||
]
|
||
# 货架只需名称+类型,勿走完整 product_to_response(会 N+1 拉设施树/任务等)
|
||
cp_map = _load_cell_product_shelf_summaries(db, tenant_no, cp_ids)
|
||
for slot in slots:
|
||
occ = occ_by_slot.get(slot.get("id"))
|
||
if not occ:
|
||
continue
|
||
slot["storedOccupancyId"] = occ.id
|
||
slot["storedItemType"] = occ.item_type or ""
|
||
slot["storedItemCode"] = occ.item_code or ""
|
||
slot["storedItemName"] = occ.item_name or ""
|
||
if (occ.item_type or "") == "cell_product" and (occ.item_id or "").strip():
|
||
cp = cp_map.get(occ.item_id)
|
||
if cp:
|
||
slot["storedCellProduct"] = cp
|
||
elif occ.item_name or occ.item_code:
|
||
# 产品已删时仍用占用快照展示名称
|
||
slot["storedCellProduct"] = {
|
||
"id": occ.item_id,
|
||
"productCode": occ.item_code or "",
|
||
"productName": occ.item_name or "",
|
||
"productTypeName": "",
|
||
}
|
||
return slots
|
||
|
||
|
||
def _refresh_slot_cache(db: Session, slot_id: str) -> None:
|
||
"""刷新 slot 的 occupied_count / last_item_type / last_stored_at / slot_status"""
|
||
slot = db.query(BizStorageFacilitySlot).filter(
|
||
BizStorageFacilitySlot.id == slot_id,
|
||
BizStorageFacilitySlot.del_flag == 0,
|
||
).first()
|
||
if not slot:
|
||
return
|
||
|
||
count = db.query(func.count(BizStorageSlotOccupancy.id)).filter(
|
||
BizStorageSlotOccupancy.slot_id == slot_id,
|
||
BizStorageSlotOccupancy.occupancy_status == "stored",
|
||
BizStorageSlotOccupancy.del_flag == 0,
|
||
).scalar() or 0
|
||
|
||
slot.occupied_count = count
|
||
if count > 0:
|
||
slot.slot_status = "占用"
|
||
latest = db.query(BizStorageSlotOccupancy).filter(
|
||
BizStorageSlotOccupancy.slot_id == slot_id,
|
||
BizStorageSlotOccupancy.occupancy_status == "stored",
|
||
BizStorageSlotOccupancy.del_flag == 0,
|
||
).order_by(BizStorageSlotOccupancy.stored_at.desc()).first()
|
||
if latest:
|
||
slot.last_item_type = latest.item_type
|
||
slot.last_stored_at = latest.stored_at
|
||
else:
|
||
slot.slot_status = "空"
|
||
slot.last_item_type = None
|
||
slot.last_stored_at = None
|
||
slot.update_time = datetime.now()
|
||
db.add(slot)
|
||
db.flush()
|
||
|
||
|
||
def create_occupancy(
|
||
db: Session,
|
||
*,
|
||
slot_id: str,
|
||
item_type: str,
|
||
item_id: str,
|
||
item_code: str,
|
||
item_name: str,
|
||
item_source: str = "manual",
|
||
tenant_no: str = "",
|
||
user_id: Optional[str] = None,
|
||
remark: Optional[str] = None,
|
||
) -> BizStorageSlotOccupancy:
|
||
"""创建入库记录 + 刷新 slot 缓存"""
|
||
slot_id = (slot_id or "").strip()
|
||
if not slot_id:
|
||
raise ValueError("储物格不能为空")
|
||
assert_slot_vacant(db, slot_id, tenant_no=tenant_no)
|
||
obj = _add_stored_occupancy_no_commit(
|
||
db,
|
||
slot_id=slot_id,
|
||
item_type=item_type,
|
||
item_id=item_id,
|
||
item_code=item_code,
|
||
item_name=item_name,
|
||
item_source=item_source,
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
remark=remark,
|
||
)
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
|
||
|
||
def batch_create_occupancies(
|
||
db: Session,
|
||
*,
|
||
items: List[Dict[str, Any]],
|
||
tenant_no: str = "",
|
||
user_id: Optional[str] = None,
|
||
) -> List[BizStorageSlotOccupancy]:
|
||
"""批量入库"""
|
||
slot_ids = [(it.get("slot_id") or "").strip() for it in items]
|
||
assert_batch_slots_unique(slot_ids)
|
||
for sid in slot_ids:
|
||
if sid:
|
||
assert_slot_vacant(db, sid, tenant_no=tenant_no)
|
||
|
||
created: List[BizStorageSlotOccupancy] = []
|
||
slot_ids_seen: set = set()
|
||
for it in items:
|
||
sid = (it.get("slot_id") or "").strip()
|
||
obj = _add_stored_occupancy_no_commit(
|
||
db,
|
||
slot_id=sid,
|
||
item_type=it.get("item_type", "cell_product"),
|
||
item_id=it["item_id"],
|
||
item_code=it.get("item_code") or "",
|
||
item_name=it.get("item_name") or "",
|
||
item_source=it.get("item_source") or "production",
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
remark=it.get("remark"),
|
||
)
|
||
created.append(obj)
|
||
slot_ids_seen.add(sid)
|
||
db.commit()
|
||
for obj in created:
|
||
db.refresh(obj)
|
||
return created
|
||
|
||
|
||
def remove_occupancy(
|
||
db: Session,
|
||
occupancy_id: str,
|
||
*,
|
||
user_id: Optional[str] = None,
|
||
) -> BizStorageSlotOccupancy:
|
||
"""手动出库"""
|
||
obj = db.query(BizStorageSlotOccupancy).filter(
|
||
BizStorageSlotOccupancy.id == occupancy_id,
|
||
BizStorageSlotOccupancy.del_flag == 0,
|
||
).first()
|
||
if not obj:
|
||
raise ValueError("占用记录不存在")
|
||
if obj.occupancy_status == "removed":
|
||
raise ValueError("该物品已出库")
|
||
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()
|
||
_refresh_slot_cache(db, obj.slot_id)
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
|
||
|
||
def remove_by_item(
|
||
db: Session,
|
||
item_type: str,
|
||
item_id: str,
|
||
*,
|
||
user_id: Optional[str] = None,
|
||
) -> None:
|
||
"""按物品类型+ID出库(细胞产品状态变更时调用)"""
|
||
remove_by_item_no_commit(db, item_type, item_id, user_id=user_id)
|
||
db.commit()
|
||
|
||
|
||
def list_occupancies(
|
||
db: Session,
|
||
*,
|
||
slot_id: str,
|
||
occupancy_status: Optional[str] = "stored",
|
||
item_type: Optional[str] = None,
|
||
tenant_no: str = "",
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> Dict[str, Any]:
|
||
"""分页查询占用记录"""
|
||
q = db.query(BizStorageSlotOccupancy).filter(
|
||
BizStorageSlotOccupancy.slot_id == slot_id,
|
||
BizStorageSlotOccupancy.del_flag == 0,
|
||
)
|
||
if tenant_no:
|
||
q = q.filter(BizStorageSlotOccupancy.tenant_no == tenant_no)
|
||
if occupancy_status:
|
||
q = q.filter(BizStorageSlotOccupancy.occupancy_status == occupancy_status)
|
||
if item_type:
|
||
q = q.filter(BizStorageSlotOccupancy.item_type == item_type)
|
||
|
||
total = q.order_by(None).with_entities(func.count(BizStorageSlotOccupancy.id)).scalar() or 0
|
||
page = max(1, page)
|
||
page_size = min(max(1, page_size), 100)
|
||
rows = (
|
||
q.order_by(BizStorageSlotOccupancy.stored_at.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
items = enrich_occupancy_items(db, tenant_no, [_row_to_dict(r) for r in rows])
|
||
return {"items": items, "total": int(total)}
|
||
|
||
|
||
def list_occupancy_history(
|
||
db: Session,
|
||
*,
|
||
slot_id: Optional[str] = None,
|
||
facility_node_id: Optional[str] = None,
|
||
item_type: Optional[str] = None,
|
||
start_date: Optional[str] = None,
|
||
end_date: Optional[str] = None,
|
||
tenant_no: str = "",
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
) -> Dict[str, Any]:
|
||
"""出入库历史"""
|
||
q = db.query(BizStorageSlotOccupancy).filter(
|
||
BizStorageSlotOccupancy.del_flag == 0,
|
||
)
|
||
if tenant_no:
|
||
q = q.filter(BizStorageSlotOccupancy.tenant_no == tenant_no)
|
||
if slot_id:
|
||
q = q.filter(BizStorageSlotOccupancy.slot_id == slot_id)
|
||
if item_type:
|
||
q = q.filter(BizStorageSlotOccupancy.item_type == item_type)
|
||
if start_date:
|
||
try:
|
||
sd = datetime.strptime(start_date, "%Y-%m-%d")
|
||
q = q.filter(BizStorageSlotOccupancy.stored_at >= sd)
|
||
except ValueError:
|
||
pass
|
||
if end_date:
|
||
try:
|
||
ed = datetime.strptime(end_date, "%Y-%m-%d")
|
||
ed = ed.replace(hour=23, minute=59, second=59)
|
||
q = q.filter(BizStorageSlotOccupancy.stored_at <= ed)
|
||
except ValueError:
|
||
pass
|
||
if facility_node_id:
|
||
slot_ids = [
|
||
r[0] for r in db.query(BizStorageFacilitySlot.id).filter(
|
||
BizStorageFacilitySlot.facility_node_id == facility_node_id,
|
||
BizStorageFacilitySlot.del_flag == 0,
|
||
).all()
|
||
]
|
||
if slot_ids:
|
||
q = q.filter(BizStorageSlotOccupancy.slot_id.in_(slot_ids))
|
||
else:
|
||
return {"items": [], "total": 0}
|
||
|
||
total = q.order_by(None).with_entities(func.count(BizStorageSlotOccupancy.id)).scalar() or 0
|
||
page = max(1, page)
|
||
page_size = min(max(1, page_size), 100)
|
||
rows = (
|
||
q.order_by(BizStorageSlotOccupancy.create_time.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
items = enrich_occupancy_items(db, tenant_no, [_row_to_dict(r) for r in rows])
|
||
return {"items": items, "total": int(total)}
|