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