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