948 lines
32 KiB
Python
948 lines
32 KiB
Python
"""细胞产品 CRUD(biz_cell_product)"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
from datetime import datetime
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from sqlalchemy import and_, func, or_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.utils import generate_uuid
|
||
from app.crud import storage_occupancy as occupancy_svc
|
||
from app.models.models import BizStorageSlotOccupancy
|
||
from app.models.models import (
|
||
BizCellProduct,
|
||
BizCellProductSpec,
|
||
BizCellProductType,
|
||
BizStorageFacility,
|
||
BizStorageFacilitySlot,
|
||
)
|
||
|
||
|
||
def _row_to_dict(c: BizCellProduct) -> Dict[str, Any]:
|
||
return {
|
||
"id": c.id,
|
||
"productCode": c.product_code,
|
||
"productName": c.product_name,
|
||
"productTypeId": c.product_type_id or "",
|
||
"productTypeName": "",
|
||
"specId": c.spec_id or "",
|
||
"specName": "",
|
||
"batchNo": c.batch_no or "",
|
||
"storageSlotId": c.storage_slot_id or "",
|
||
"storageSlotName": "",
|
||
"passage": c.passage or "",
|
||
"freezeDate": c.freeze_date.strftime("%Y-%m-%d %H:%M:%S") if c.freeze_date else "",
|
||
"expectedCellCount": float(c.expected_cell_count) if c.expected_cell_count is not None else None,
|
||
"viability": float(c.viability) if c.viability is not None else None,
|
||
"productStatus": c.product_status or "",
|
||
"sourceType": c.source_type or "",
|
||
"productionTaskId": c.production_task_id or "",
|
||
"quantity": float(c.quantity) if c.quantity is not None else None,
|
||
"unit": c.unit or "",
|
||
"expiryDate": c.expiry_date.strftime("%Y-%m-%d %H:%M:%S") if c.expiry_date else "",
|
||
"productImages": c.product_images or "",
|
||
"tenantNo": c.tenant_no,
|
||
"orderNum": c.order_num if c.order_num is not None else 0,
|
||
"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 "",
|
||
"updateBy": c.update_by or "",
|
||
"updateTime": c.update_time.strftime("%Y-%m-%d %H:%M:%S") if c.update_time else "",
|
||
}
|
||
|
||
|
||
def _build_facility_path_label(node_id: str, by_id: Dict[str, BizStorageFacility]) -> str:
|
||
parts: List[str] = []
|
||
cur_id = (node_id or "").strip()
|
||
visited: set[str] = set()
|
||
while cur_id and cur_id != "0" and cur_id not in visited:
|
||
visited.add(cur_id)
|
||
node = by_id.get(cur_id)
|
||
if not node:
|
||
break
|
||
parts.append(node.node_name or "")
|
||
cur_id = (node.parent_id or "").strip()
|
||
parts.reverse()
|
||
return " / ".join(p for p in parts if p)
|
||
|
||
|
||
def _enrich_product_dicts(
|
||
db: Session,
|
||
tenant_no: str,
|
||
items: List[Dict[str, Any]],
|
||
) -> List[Dict[str, Any]]:
|
||
if not items:
|
||
return items
|
||
|
||
type_ids = {i.get("productTypeId") for i in items if i.get("productTypeId")}
|
||
spec_ids = {i.get("specId") for i in items if i.get("specId")}
|
||
slot_ids = {i.get("storageSlotId") for i in items if i.get("storageSlotId")}
|
||
|
||
type_map: Dict[str, str] = {}
|
||
if type_ids:
|
||
rows = (
|
||
db.query(BizCellProductType)
|
||
.filter(
|
||
BizCellProductType.id.in_(type_ids),
|
||
BizCellProductType.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
type_map = {r.id: r.product_type_name or "" for r in rows}
|
||
|
||
spec_map: Dict[str, str] = {}
|
||
if spec_ids:
|
||
rows = (
|
||
db.query(BizCellProductSpec)
|
||
.filter(
|
||
BizCellProductSpec.id.in_(spec_ids),
|
||
BizCellProductSpec.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
spec_map = {r.id: r.spec_name or "" for r in rows}
|
||
|
||
slot_map: Dict[str, str] = {}
|
||
if slot_ids:
|
||
slots = (
|
||
db.query(BizStorageFacilitySlot)
|
||
.filter(
|
||
BizStorageFacilitySlot.id.in_(slot_ids),
|
||
BizStorageFacilitySlot.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
facilities = (
|
||
db.query(BizStorageFacility)
|
||
.filter(
|
||
BizStorageFacility.tenant_no == tenant_no,
|
||
BizStorageFacility.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
facility_by_id = {f.id: f for f in facilities}
|
||
for slot in slots:
|
||
node_path = _build_facility_path_label(slot.facility_node_id, facility_by_id)
|
||
code = (slot.slot_code or "").strip()
|
||
if node_path and code:
|
||
slot_map[slot.id] = f"{node_path} / {code}"
|
||
else:
|
||
slot_map[slot.id] = code or node_path or ""
|
||
|
||
# --- 来源信息富化 ---
|
||
from app.models.models import BizProductionTask, BizProductionWorkOrder
|
||
|
||
task_ids = {i.get("productionTaskId") for i in items if i.get("productionTaskId")}
|
||
task_map: Dict[str, Any] = {}
|
||
task_to_wo: Dict[str, str] = {}
|
||
wo_map: Dict[str, Any] = {}
|
||
|
||
if task_ids:
|
||
tasks = (
|
||
db.query(BizProductionTask)
|
||
.filter(
|
||
BizProductionTask.id.in_(task_ids),
|
||
BizProductionTask.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
for t in tasks:
|
||
task_map[t.id] = t
|
||
if t.work_order_id:
|
||
task_to_wo[t.id] = t.work_order_id
|
||
|
||
wo_ids = set(task_to_wo.values())
|
||
if wo_ids:
|
||
wos = (
|
||
db.query(BizProductionWorkOrder)
|
||
.filter(
|
||
BizProductionWorkOrder.id.in_(wo_ids),
|
||
BizProductionWorkOrder.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
wo_map = {w.id: w for w in wos}
|
||
|
||
source_type_labels = {"production": "生产入库", "manual": "手动录入"}
|
||
|
||
for item in items:
|
||
tid = item.get("productTypeId") or ""
|
||
sid = item.get("specId") or ""
|
||
slot_id = item.get("storageSlotId") or ""
|
||
item["productTypeName"] = type_map.get(tid, "") if tid else ""
|
||
item["specName"] = spec_map.get(sid, "") if sid else ""
|
||
item["storageSlotName"] = slot_map.get(slot_id, "") if slot_id else ""
|
||
# 来源信息
|
||
src_type = item.get("sourceType", "")
|
||
item["sourceTypeLabel"] = source_type_labels.get(src_type, src_type or "")
|
||
tid = item.get("productionTaskId") or ""
|
||
if tid and tid in task_map:
|
||
t_obj = task_map[tid]
|
||
item["sourceTaskNo"] = t_obj.task_no or ""
|
||
wo_id = task_to_wo.get(tid)
|
||
if wo_id and wo_id in wo_map:
|
||
wo_obj = wo_map[wo_id]
|
||
item["sourceWorkOrderNo"] = wo_obj.work_order_no or ""
|
||
item["sourceProtocolNo"] = wo_obj.protocol_no or ""
|
||
item["sourceReceptionSource"] = wo_obj.reception_source or ""
|
||
else:
|
||
item["sourceWorkOrderNo"] = ""
|
||
item["sourceProtocolNo"] = ""
|
||
item["sourceReceptionSource"] = ""
|
||
else:
|
||
item["sourceTaskNo"] = ""
|
||
item["sourceWorkOrderNo"] = ""
|
||
item["sourceProtocolNo"] = ""
|
||
item["sourceReceptionSource"] = ""
|
||
return items
|
||
|
||
|
||
def product_to_response(db: Session, tenant_no: str, obj: BizCellProduct) -> Dict[str, Any]:
|
||
enriched = _enrich_product_dicts(db, tenant_no, [_row_to_dict(obj)])
|
||
return enriched[0] if enriched else _row_to_dict(obj)
|
||
|
||
|
||
def list_cell_products(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
keyword: Optional[str] = None,
|
||
product_code: Optional[str] = None,
|
||
product_name: Optional[str] = None,
|
||
product_type_id: Optional[str] = None,
|
||
product_status: Optional[str] = None,
|
||
passage: Optional[str] = None,
|
||
source_type: Optional[str] = None,
|
||
user_info=None,
|
||
) -> Dict[str, Any]:
|
||
def _filtered():
|
||
q0 = db.query(BizCellProduct).filter(
|
||
BizCellProduct.del_flag == 0,
|
||
BizCellProduct.tenant_no == tenant_no,
|
||
)
|
||
kw = (keyword or "").strip()
|
||
if kw:
|
||
like = f"%{kw}%"
|
||
q0 = q0.filter(
|
||
or_(
|
||
BizCellProduct.product_code.ilike(like),
|
||
BizCellProduct.product_name.ilike(like),
|
||
)
|
||
)
|
||
pc = (product_code or "").strip()
|
||
if pc:
|
||
q0 = q0.filter(BizCellProduct.product_code.like(f"%{pc}%"))
|
||
pn = (product_name or "").strip()
|
||
if pn:
|
||
q0 = q0.filter(BizCellProduct.product_name.like(f"%{pn}%"))
|
||
if product_type_id:
|
||
q0 = q0.filter(BizCellProduct.product_type_id == product_type_id)
|
||
if product_status:
|
||
q0 = q0.filter(BizCellProduct.product_status == product_status)
|
||
if passage:
|
||
q0 = q0.filter(BizCellProduct.passage == passage)
|
||
if source_type:
|
||
q0 = q0.filter(BizCellProduct.source_type == source_type)
|
||
if user_info:
|
||
from app.core.data_scope import apply_data_scope
|
||
q0 = apply_data_scope(q0, BizCellProduct, user_info, db, tenant_no)
|
||
return q0
|
||
|
||
total = _filtered().order_by(None).with_entities(func.count(BizCellProduct.id)).scalar() or 0
|
||
page = max(1, page)
|
||
page_size = min(max(1, page_size), 100)
|
||
rows = (
|
||
_filtered()
|
||
.order_by(BizCellProduct.create_time.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
.all()
|
||
)
|
||
items = _enrich_product_dicts(
|
||
db, tenant_no, [_row_to_dict(x) for x in rows]
|
||
)
|
||
return {"items": items, "total": int(total)}
|
||
|
||
|
||
def get_cell_product(db: Session, id: str, tenant_no: str) -> Optional[BizCellProduct]:
|
||
return (
|
||
db.query(BizCellProduct)
|
||
.filter(
|
||
and_(
|
||
BizCellProduct.id == id,
|
||
BizCellProduct.tenant_no == tenant_no,
|
||
BizCellProduct.del_flag == 0,
|
||
)
|
||
)
|
||
.first()
|
||
)
|
||
|
||
|
||
def create_cell_product(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
create_dept_id: Optional[str] = None,
|
||
data: Any = None,
|
||
) -> BizCellProduct:
|
||
code = (data.product_code or "").strip()
|
||
if not code:
|
||
raise ValueError("产品编码不能为空")
|
||
|
||
existing = db.query(BizCellProduct).filter(
|
||
BizCellProduct.tenant_no == tenant_no,
|
||
BizCellProduct.product_code == code,
|
||
BizCellProduct.del_flag == 0,
|
||
).first()
|
||
if existing:
|
||
raise ValueError(f"产品编码 {code} 已存在")
|
||
|
||
obj = BizCellProduct(
|
||
id=generate_uuid(),
|
||
product_code=code,
|
||
product_name=(data.product_name or "").strip(),
|
||
product_type_id=(data.product_type_id or "").strip() or None,
|
||
spec_id=(data.spec_id or "").strip() or None,
|
||
batch_no=(data.batch_no or "").strip() or None,
|
||
storage_slot_id=(data.storage_slot_id or "").strip() or None,
|
||
passage=((data.passage or "").strip() or None) if getattr(data, "passage", None) else None,
|
||
freeze_date=data.freeze_date if hasattr(data, "freeze_date") else None,
|
||
expected_cell_count=data.expected_cell_count if hasattr(data, "expected_cell_count") else None,
|
||
viability=data.viability if hasattr(data, "viability") else None,
|
||
product_status=data.product_status or "frozen",
|
||
source_type=data.source_type or "manual",
|
||
quantity=data.quantity,
|
||
unit=(data.unit or "").strip() or None,
|
||
expiry_date=data.expiry_date,
|
||
product_images=(data.product_images or "").strip() or None,
|
||
tenant_no=tenant_no,
|
||
order_num=0,
|
||
remark=(data.remark or "").strip() or None,
|
||
create_by=user_id,
|
||
create_dept_id=create_dept_id or "",
|
||
create_time=datetime.now(),
|
||
update_by=user_id,
|
||
update_time=datetime.now(),
|
||
del_flag=0,
|
||
)
|
||
db.add(obj)
|
||
db.flush()
|
||
|
||
if obj.storage_slot_id:
|
||
occ = BizStorageSlotOccupancy(
|
||
id=generate_uuid(),
|
||
slot_id=obj.storage_slot_id,
|
||
item_type="cell_product",
|
||
item_id=obj.id,
|
||
item_code=obj.product_code,
|
||
item_name=obj.product_name,
|
||
item_source=obj.source_type or "manual",
|
||
stored_at=datetime.now(),
|
||
occupancy_status="stored",
|
||
tenant_no=tenant_no,
|
||
order_num=0,
|
||
create_by=user_id,
|
||
create_dept_id=create_dept_id or "",
|
||
create_time=datetime.now(),
|
||
update_by=user_id,
|
||
update_time=datetime.now(),
|
||
del_flag=0,
|
||
)
|
||
db.add(occ)
|
||
db.flush()
|
||
occupancy_svc._refresh_slot_cache(db, obj.storage_slot_id)
|
||
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
|
||
|
||
def update_cell_product(
|
||
db: Session,
|
||
id: str,
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
data: Any = None,
|
||
) -> BizCellProduct:
|
||
obj = get_cell_product(db, id, tenant_no)
|
||
if not obj:
|
||
raise ValueError("细胞产品不存在")
|
||
|
||
if data.product_name is not None:
|
||
obj.product_name = (data.product_name or "").strip()
|
||
if data.product_type_id is not None:
|
||
obj.product_type_id = (data.product_type_id or "").strip() or None
|
||
if data.spec_id is not None:
|
||
obj.spec_id = (data.spec_id or "").strip() or None
|
||
if data.batch_no is not None:
|
||
obj.batch_no = (data.batch_no or "").strip() or None
|
||
if data.storage_slot_id is not None:
|
||
obj.storage_slot_id = (data.storage_slot_id or "").strip() or None
|
||
if hasattr(data, "passage") and data.passage is not None:
|
||
obj.passage = (data.passage or "").strip() or None
|
||
if hasattr(data, "freeze_date") and data.freeze_date is not None:
|
||
obj.freeze_date = data.freeze_date
|
||
if hasattr(data, "expected_cell_count") and data.expected_cell_count is not None:
|
||
obj.expected_cell_count = data.expected_cell_count
|
||
if hasattr(data, "viability") and data.viability is not None:
|
||
obj.viability = data.viability
|
||
if data.product_status is not None and data.product_status != obj.product_status:
|
||
allowed = {"frozen": {"sold", "used", "voided"}}
|
||
from_status = obj.product_status
|
||
to_status = data.product_status
|
||
if from_status not in allowed or to_status not in allowed[from_status]:
|
||
raise ValueError(f"不允许从状态 {from_status} 变更为 {to_status}")
|
||
obj.product_status = data.product_status
|
||
if obj.storage_slot_id:
|
||
try:
|
||
occupancy_svc.remove_by_item(
|
||
db, "cell_product", obj.id, user_id=user_id,
|
||
)
|
||
except Exception:
|
||
pass
|
||
if data.quantity is not None:
|
||
obj.quantity = data.quantity
|
||
if data.unit is not None:
|
||
obj.unit = (data.unit or "").strip() or None
|
||
if data.expiry_date is not None:
|
||
obj.expiry_date = data.expiry_date
|
||
if data.product_images is not None:
|
||
obj.product_images = (data.product_images or "").strip() or None
|
||
if data.remark is not None:
|
||
obj.remark = (data.remark or "").strip() or None
|
||
|
||
obj.update_by = user_id
|
||
obj.update_time = datetime.now()
|
||
db.add(obj)
|
||
db.commit()
|
||
db.refresh(obj)
|
||
return obj
|
||
|
||
|
||
def delete_cell_product(
|
||
db: Session,
|
||
id: str,
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
) -> None:
|
||
obj = get_cell_product(db, id, tenant_no)
|
||
if not obj:
|
||
raise ValueError("细胞产品不存在")
|
||
obj.del_flag = 1
|
||
obj.update_by = user_id
|
||
obj.update_time = datetime.now()
|
||
db.add(obj)
|
||
db.commit()
|
||
|
||
|
||
def _next_code_start_seq(db: Session, tenant_no: str, prefix: str) -> int:
|
||
last = (
|
||
db.query(BizCellProduct)
|
||
.filter(
|
||
BizCellProduct.tenant_no == tenant_no,
|
||
BizCellProduct.product_code.like(f"{prefix}%"),
|
||
BizCellProduct.del_flag == 0,
|
||
)
|
||
.order_by(BizCellProduct.product_code.desc())
|
||
.first()
|
||
)
|
||
seq = 1
|
||
if last and last.product_code:
|
||
try:
|
||
base = normalize_product_code_base(last.product_code)
|
||
seq = int(base[-6:]) + 1
|
||
except (ValueError, IndexError):
|
||
seq = 1
|
||
return seq
|
||
|
||
|
||
def get_next_code(db: Session, tenant_no: str) -> str:
|
||
today_str = datetime.now().strftime("%Y%m%d")
|
||
prefix = f"CP{today_str}"
|
||
seq = _next_code_start_seq(db, tenant_no, prefix)
|
||
return f"{prefix}{seq:06d}"
|
||
|
||
|
||
_PRODUCT_CODE_SUFFIX_RE = re.compile(r"^(.+)-(\d{2})$")
|
||
|
||
|
||
def normalize_product_code_base(code: str) -> str:
|
||
"""去掉末尾 -01/-02 等序号,得到多份入库时的基础编码。"""
|
||
c = (code or "").strip()
|
||
if not c:
|
||
return ""
|
||
m = _PRODUCT_CODE_SUFFIX_RE.match(c)
|
||
if m:
|
||
return m.group(1).strip()
|
||
return c
|
||
|
||
|
||
def build_split_product_codes(base: str, count: int) -> List[str]:
|
||
"""按基础编码生成 基础-01、基础-02 …"""
|
||
if count < 1:
|
||
return []
|
||
return [f"{base}-{i:02d}" for i in range(1, count + 1)]
|
||
|
||
|
||
def reserve_next_codes(db: Session, tenant_no: str, count: int) -> List[str]:
|
||
if count < 1:
|
||
return []
|
||
count = min(count, 50)
|
||
today_str = datetime.now().strftime("%Y%m%d")
|
||
prefix = f"CP{today_str}"
|
||
start = _next_code_start_seq(db, tenant_no, prefix)
|
||
return [f"{prefix}{start + i:06d}" for i in range(count)]
|
||
|
||
|
||
def _validate_batch_items(
|
||
db: Session,
|
||
tenant_no: str,
|
||
items: List[Any],
|
||
*,
|
||
require_storage_slot: bool = True,
|
||
) -> Tuple[List[str], List[Optional[str]]]:
|
||
if not items:
|
||
raise ValueError("至少需要一份产品明细")
|
||
if len(items) > 50:
|
||
raise ValueError("单次入库份数不能超过 50")
|
||
|
||
codes: List[str] = []
|
||
slot_ids: List[Optional[str]] = []
|
||
for it in items:
|
||
code = (getattr(it, "product_code", None) or "").strip()
|
||
if not code:
|
||
raise ValueError("产品编码不能为空")
|
||
if code in codes:
|
||
raise ValueError(f"产品编码 {code} 重复")
|
||
codes.append(code)
|
||
|
||
sid = (getattr(it, "storage_slot_id", None) or "").strip() or None
|
||
if require_storage_slot and not sid:
|
||
raise ValueError("每份产品须选择储物格位置")
|
||
slot_ids.append(sid)
|
||
|
||
existing_codes = (
|
||
db.query(BizCellProduct.product_code)
|
||
.filter(
|
||
BizCellProduct.tenant_no == tenant_no,
|
||
BizCellProduct.product_code.in_(codes),
|
||
BizCellProduct.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
if existing_codes:
|
||
dup = existing_codes[0][0]
|
||
raise ValueError(f"产品编码 {dup} 已存在")
|
||
|
||
return codes, slot_ids
|
||
|
||
|
||
def create_cell_products_by_quantity(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
create_dept_id: Optional[str] = None,
|
||
data: Any = None,
|
||
count: int,
|
||
) -> List[BizCellProduct]:
|
||
"""按入库份数拆分:基础编码-01、基础编码-02 …"""
|
||
count = max(1, min(int(count), 50))
|
||
base = normalize_product_code_base(getattr(data, "product_code", None) or "")
|
||
if not base:
|
||
raise ValueError("产品编码不能为空")
|
||
codes = build_split_product_codes(base, count)
|
||
slot_id = (getattr(data, "storage_slot_id", None) or "").strip() or None
|
||
from types import SimpleNamespace
|
||
|
||
items = [
|
||
SimpleNamespace(product_code=c, storage_slot_id=slot_id) for c in codes
|
||
]
|
||
return create_cell_products_batch(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
user_id=user_id,
|
||
create_dept_id=create_dept_id,
|
||
data=data,
|
||
items=items,
|
||
require_storage_slot=False,
|
||
)
|
||
|
||
|
||
def create_cell_products_batch(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
user_id: Optional[str] = None,
|
||
create_dept_id: Optional[str] = None,
|
||
data: Any = None,
|
||
items: List[Any],
|
||
require_storage_slot: bool = True,
|
||
) -> List[BizCellProduct]:
|
||
codes, slot_ids = _validate_batch_items(
|
||
db,
|
||
tenant_no,
|
||
items,
|
||
require_storage_slot=require_storage_slot,
|
||
)
|
||
now = datetime.now()
|
||
created: List[BizCellProduct] = []
|
||
for code, slot_id in zip(codes, slot_ids):
|
||
obj = BizCellProduct(
|
||
id=generate_uuid(),
|
||
product_code=code,
|
||
product_name=(data.product_name or "").strip(),
|
||
product_type_id=(data.product_type_id or "").strip() or None,
|
||
spec_id=(data.spec_id or "").strip() or None,
|
||
batch_no=(data.batch_no or "").strip() or None,
|
||
storage_slot_id=slot_id,
|
||
passage=((data.passage or "").strip() or None) if getattr(data, "passage", None) else None,
|
||
freeze_date=data.freeze_date if hasattr(data, "freeze_date") else None,
|
||
expected_cell_count=data.expected_cell_count if hasattr(data, "expected_cell_count") else None,
|
||
viability=data.viability if hasattr(data, "viability") else None,
|
||
product_status=data.product_status or "frozen",
|
||
source_type=data.source_type or "manual",
|
||
quantity=1,
|
||
unit=(data.unit or "").strip() or None,
|
||
expiry_date=data.expiry_date,
|
||
product_images=(data.product_images or "").strip() or None,
|
||
tenant_no=tenant_no,
|
||
order_num=0,
|
||
remark=(data.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)
|
||
created.append(obj)
|
||
for obj in created:
|
||
if obj.storage_slot_id:
|
||
occ = BizStorageSlotOccupancy(
|
||
id=generate_uuid(),
|
||
slot_id=obj.storage_slot_id,
|
||
item_type="cell_product",
|
||
item_id=obj.id,
|
||
item_code=obj.product_code,
|
||
item_name=obj.product_name,
|
||
item_source=obj.source_type or "manual",
|
||
stored_at=now,
|
||
occupancy_status="stored",
|
||
tenant_no=tenant_no,
|
||
order_num=0,
|
||
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(occ)
|
||
db.flush()
|
||
slot_ids_seen = {obj.storage_slot_id for obj in created if obj.storage_slot_id}
|
||
for sid in slot_ids_seen:
|
||
occupancy_svc._refresh_slot_cache(db, sid)
|
||
|
||
db.commit()
|
||
for obj in created:
|
||
db.refresh(obj)
|
||
return created
|
||
|
||
|
||
def total_pages(total: int, page_size: int) -> int:
|
||
if total <= 0:
|
||
return 0
|
||
return int(math.ceil(total / float(page_size)))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 来源链路 API
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_MAX_CHAIN_DEPTH = 10
|
||
|
||
_SOURCE_TYPE_LABELS = {"production": "生产入库", "manual": "手动录入"}
|
||
|
||
|
||
def _product_node(obj: BizCellProduct, is_current: bool = False) -> Dict[str, Any]:
|
||
return {
|
||
"id": obj.id,
|
||
"productCode": obj.product_code or "",
|
||
"productName": obj.product_name or "",
|
||
"nodeType": "cell_product",
|
||
"productStatus": obj.product_status or "",
|
||
"passage": obj.passage or "",
|
||
"isCurrent": is_current,
|
||
}
|
||
|
||
|
||
def _first_chain_link(chain: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||
"""祖先链单链路段:列表仅含一个节点时取首项,供 parent 嵌套为对象而非数组。"""
|
||
return chain[0] if chain else None
|
||
|
||
|
||
def _edge_meta(task: Any, wo: Any = None) -> Dict[str, Any]:
|
||
edge: Dict[str, Any] = {
|
||
"workOrderNo": "",
|
||
"workOrderType": "",
|
||
"taskNo": "",
|
||
"taskName": "",
|
||
"taskId": "",
|
||
}
|
||
if task:
|
||
edge["taskNo"] = task.task_no or ""
|
||
edge["taskName"] = task.task_name or ""
|
||
edge["taskId"] = task.id or ""
|
||
if wo:
|
||
edge["workOrderNo"] = wo.work_order_no or ""
|
||
edge["workOrderType"] = wo.reception_source or ""
|
||
return edge
|
||
|
||
|
||
def _sample_customer_dict(db: Session, protocol_id: str, tenant_no: str) -> Dict[str, Any]:
|
||
if not protocol_id:
|
||
return {}
|
||
from app.models.models import BizProtocolPartyCustomer
|
||
|
||
pc = (
|
||
db.query(BizProtocolPartyCustomer)
|
||
.filter(
|
||
BizProtocolPartyCustomer.collection_protocol_id == protocol_id,
|
||
BizProtocolPartyCustomer.tenant_no == tenant_no,
|
||
BizProtocolPartyCustomer.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not pc:
|
||
return {}
|
||
return {
|
||
"motherName": pc.mother_name or "",
|
||
"motherPhone": pc.mother_phone or "",
|
||
"motherIdCard": pc.mother_id_card or "",
|
||
"newbornName": pc.newborn_name or "",
|
||
}
|
||
|
||
|
||
def _build_ancestor_chain(
|
||
db: Session, product: BizCellProduct, depth: int, tenant_no: str
|
||
) -> List[Dict[str, Any]]:
|
||
if depth >= _MAX_CHAIN_DEPTH:
|
||
return []
|
||
task_id = product.production_task_id
|
||
if not task_id:
|
||
return []
|
||
|
||
from app.models.models import BizProductionTask, BizProductionWorkOrder
|
||
from app.models.models import BizProductionTaskInput
|
||
|
||
task = (
|
||
db.query(BizProductionTask)
|
||
.filter(BizProductionTask.id == task_id, BizProductionTask.del_flag == 0)
|
||
.first()
|
||
)
|
||
if not task:
|
||
return []
|
||
|
||
wo = None
|
||
if task.work_order_id:
|
||
wo = (
|
||
db.query(BizProductionWorkOrder)
|
||
.filter(BizProductionWorkOrder.id == task.work_order_id, BizProductionWorkOrder.del_flag == 0)
|
||
.first()
|
||
)
|
||
edge = _edge_meta(task, wo)
|
||
|
||
# 检查投入物中是否有父细胞产品(按排序取首个)
|
||
parent_input = (
|
||
db.query(BizProductionTaskInput)
|
||
.filter(
|
||
BizProductionTaskInput.task_id == task_id,
|
||
BizProductionTaskInput.input_type == "cell_product",
|
||
BizProductionTaskInput.del_flag == 0,
|
||
)
|
||
.order_by(
|
||
BizProductionTaskInput.order_num.asc(),
|
||
BizProductionTaskInput.create_time.asc(),
|
||
)
|
||
.first()
|
||
)
|
||
if parent_input and (parent_input.input_id or parent_input.input_code):
|
||
parent_product = None
|
||
if parent_input.input_id:
|
||
parent_product = (
|
||
db.query(BizCellProduct)
|
||
.filter(BizCellProduct.id == parent_input.input_id, BizCellProduct.del_flag == 0)
|
||
.first()
|
||
)
|
||
if not parent_product and parent_input.input_code:
|
||
parent_product = (
|
||
db.query(BizCellProduct)
|
||
.filter(BizCellProduct.product_code == parent_input.input_code, BizCellProduct.del_flag == 0)
|
||
.first()
|
||
)
|
||
if parent_product:
|
||
parent_chain = _build_ancestor_chain(db, parent_product, depth + 1, tenant_no)
|
||
return [
|
||
{
|
||
"node": _product_node(parent_product),
|
||
"edge": edge,
|
||
"parent": _first_chain_link(parent_chain),
|
||
}
|
||
]
|
||
|
||
# 检查投入物中是否有样本(终止节点)
|
||
sample_input = (
|
||
db.query(BizProductionTaskInput)
|
||
.filter(
|
||
BizProductionTaskInput.task_id == task_id,
|
||
BizProductionTaskInput.input_type == "sample",
|
||
BizProductionTaskInput.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if sample_input and (sample_input.input_id or sample_input.input_code):
|
||
from app.models.models import BizProtocolSample
|
||
sample = None
|
||
if sample_input.input_id:
|
||
sample = (
|
||
db.query(BizProtocolSample)
|
||
.filter(BizProtocolSample.id == sample_input.input_id, BizProtocolSample.del_flag == 0)
|
||
.first()
|
||
)
|
||
if not sample and sample_input.input_code:
|
||
sample = (
|
||
db.query(BizProtocolSample)
|
||
.filter(BizProtocolSample.sample_code == sample_input.input_code, BizProtocolSample.del_flag == 0)
|
||
.first()
|
||
)
|
||
if sample:
|
||
reception_source = wo.reception_source if wo else ""
|
||
source_labels = {
|
||
"cell_storage": "细胞存储",
|
||
"research_donation": "科研捐赠",
|
||
}
|
||
protocol_id = sample.collection_protocol_id or ""
|
||
sample_node = {
|
||
"id": sample.id,
|
||
"productCode": sample.sample_code or "",
|
||
"productName": "",
|
||
"nodeType": "sample",
|
||
"sampleCode": sample.sample_code or "",
|
||
"sampleTypeName": sample_input.input_name or "",
|
||
"protocolNo": wo.protocol_no if wo else "",
|
||
"collectionProtocolId": protocol_id,
|
||
"receptionSource": reception_source,
|
||
"receptionSourceLabel": source_labels.get(reception_source, reception_source),
|
||
"customer": _sample_customer_dict(db, protocol_id, tenant_no),
|
||
"isCurrent": False,
|
||
}
|
||
return [{"node": sample_node, "edge": edge, "parent": None}]
|
||
|
||
return []
|
||
|
||
|
||
def _build_descendant_chain(
|
||
db: Session, product_id: str, product_code: str, depth: int, tenant_no: str
|
||
) -> List[Dict[str, Any]]:
|
||
if depth >= _MAX_CHAIN_DEPTH:
|
||
return []
|
||
|
||
from app.models.models import BizProductionTaskInput, BizProductionTaskOutput, BizProductionTask, BizProductionWorkOrder
|
||
|
||
# 找到引用此产品作为投入物的所有任务(id 或编码)
|
||
input_match = [BizProductionTaskInput.input_id == product_id]
|
||
if product_code:
|
||
input_match.append(BizProductionTaskInput.input_code == product_code)
|
||
input_rows = (
|
||
db.query(BizProductionTaskInput)
|
||
.filter(
|
||
BizProductionTaskInput.input_type == "cell_product",
|
||
BizProductionTaskInput.del_flag == 0,
|
||
or_(*input_match),
|
||
)
|
||
.all()
|
||
)
|
||
if not input_rows:
|
||
return []
|
||
|
||
task_ids = list({r.task_id for r in input_rows})
|
||
tasks = (
|
||
db.query(BizProductionTask)
|
||
.filter(BizProductionTask.id.in_(task_ids), BizProductionTask.del_flag == 0)
|
||
.all()
|
||
)
|
||
task_by_id = {t.id: t for t in tasks}
|
||
|
||
wo_ids = list({t.work_order_id for t in tasks if t.work_order_id})
|
||
wo_by_id: Dict[str, Any] = {}
|
||
if wo_ids:
|
||
wos = (
|
||
db.query(BizProductionWorkOrder)
|
||
.filter(BizProductionWorkOrder.id.in_(wo_ids), BizProductionWorkOrder.del_flag == 0)
|
||
.all()
|
||
)
|
||
wo_by_id = {w.id: w for w in wos}
|
||
|
||
# 对每个任务,找产出
|
||
result: List[Dict[str, Any]] = []
|
||
output_rows = (
|
||
db.query(BizProductionTaskOutput)
|
||
.filter(
|
||
BizProductionTaskOutput.task_id.in_(task_ids),
|
||
BizProductionTaskOutput.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
|
||
child_product_ids = list({o.cell_product_id for o in output_rows if o.cell_product_id})
|
||
child_products = {}
|
||
if child_product_ids:
|
||
cps = (
|
||
db.query(BizCellProduct)
|
||
.filter(BizCellProduct.id.in_(child_product_ids), BizCellProduct.del_flag == 0)
|
||
.all()
|
||
)
|
||
child_products = {cp.id: cp for cp in cps}
|
||
|
||
seen_child_ids: set = set()
|
||
for o in output_rows:
|
||
cp = child_products.get(o.cell_product_id)
|
||
if not cp or cp.id in seen_child_ids:
|
||
continue
|
||
seen_child_ids.add(cp.id)
|
||
t = task_by_id.get(o.task_id)
|
||
wo_obj = wo_by_id.get(t.work_order_id) if t and t.work_order_id else None
|
||
edge = _edge_meta(t, wo_obj)
|
||
|
||
result.append({
|
||
"node": _product_node(cp),
|
||
"edge": edge,
|
||
"children": _build_descendant_chain(
|
||
db, cp.id, cp.product_code or "", depth + 1, tenant_no
|
||
),
|
||
})
|
||
|
||
return result
|
||
|
||
|
||
def get_product_chain(db: Session, product_id: str, tenant_no: str) -> Optional[Dict[str, Any]]:
|
||
product = get_cell_product(db, product_id, tenant_no)
|
||
if not product:
|
||
return None
|
||
|
||
return {
|
||
"current": _product_node(product, is_current=True),
|
||
"ancestors": _build_ancestor_chain(db, product, 0, tenant_no),
|
||
"descendants": _build_descendant_chain(
|
||
db, product.id, product.product_code or "", 0, tenant_no
|
||
),
|
||
}
|