Files
zkxw/backend/app/crud/storage_occupancy.py
T
zlwuandCursor 0a055557cb feat(storage): expose storedOccupancyId on shelf slot enrichment
Enable web-admin batch outbound to call existing remove occupancy API per selected cell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 19:11:24 +08:00

570 lines
18 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.
"""库位占用记录 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)}