| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685 |
- """细胞产品 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.utils.sample_special import sample_special_to_dict
- from app.crud import storage_occupancy as occupancy_svc
- from app.models.models import BizStorageSlotOccupancy
- from app.models.models import (
- BizCellProduct,
- BizCellProductSpec,
- BizProductionProductType,
- BizStorageFacility,
- BizStorageFacilitySlot,
- )
- from app.services.storage_facility_code import build_full_slot_code
- _CELL_PRODUCT_STATUSES = frozenset(
- {"frozen", "in_production", "sold", "used", "voided", "thawed"}
- )
- _CELL_PRODUCT_RELEASE_STATUSES = frozenset({"sold", "used", "voided"})
- _CELL_PRODUCT_STORE_STATUSES = frozenset({"frozen", "thawed", "in_production"})
- def _apply_cell_product_status_change(
- db: Session,
- obj: BizCellProduct,
- to_status: str,
- tenant_no: str,
- user_id: Optional[str],
- now: datetime,
- ) -> None:
- """管理端手动改状态:允许在合法枚举间任意变更,并同步库位占用。"""
- from_status = (obj.product_status or "").strip()
- to_status = (to_status or "").strip()
- if to_status == from_status:
- return
- if to_status not in _CELL_PRODUCT_STATUSES:
- raise ValueError(
- f"状态必须为 {'/'.join(sorted(_CELL_PRODUCT_STATUSES))}"
- )
- obj.product_status = to_status
- slot_id = (obj.storage_slot_id or "").strip()
- if to_status in _CELL_PRODUCT_RELEASE_STATUSES:
- if slot_id:
- try:
- occupancy_svc.remove_by_item(
- db, "cell_product", obj.id, user_id=user_id,
- )
- except Exception:
- pass
- return
- if to_status in _CELL_PRODUCT_STORE_STATUSES and slot_id:
- existing = db.query(BizStorageSlotOccupancy).filter(
- BizStorageSlotOccupancy.item_type == "cell_product",
- BizStorageSlotOccupancy.item_id == obj.id,
- BizStorageSlotOccupancy.occupancy_status == "stored",
- BizStorageSlotOccupancy.del_flag == 0,
- ).first()
- if existing:
- return
- occupancy_svc.assert_slot_vacant(
- db, slot_id, tenant_no=tenant_no, exclude_product_id=obj.id,
- )
- occupancy_svc._add_stored_occupancy_no_commit(
- db,
- slot_id=slot_id,
- item_type="cell_product",
- item_id=obj.id,
- item_code=obj.product_code or "",
- item_name=obj.product_name or "",
- item_source=obj.source_type or "manual",
- tenant_no=tenant_no,
- user_id=user_id,
- create_dept_id=obj.create_dept_id or "",
- stored_at=now,
- )
- 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 "",
- "specText": c.spec_text or "",
- "specName": c.spec_text or "",
- "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(BizProductionProductType)
- .filter(
- BizProductionProductType.id.in_(type_ids),
- BizProductionProductType.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:
- slot_map[slot.id] = build_full_slot_code(
- slot.facility_node_id,
- slot.slot_code,
- facility_by_id,
- )
- # --- 来源信息富化 ---
- 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 ""
- stored_spec = (item.get("specText") or item.get("spec_text") or "").strip()
- if stored_spec:
- item["specName"] = stored_spec
- elif sid:
- item["specName"] = spec_map.get(sid, "")
- else:
- item["specName"] = (item.get("specName") or "").strip()
- 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(),
- BizCellProduct.product_code.asc(),
- )
- .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,
- spec_text=(getattr(data, "spec_name", None) 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()
- slot_id = (obj.storage_slot_id or "").strip()
- if slot_id and (obj.product_status or "") in _CELL_PRODUCT_STORE_STATUSES:
- occupancy_svc.assert_slot_vacant(db, slot_id, tenant_no=tenant_no, exclude_product_id=obj.id)
- occupancy_svc._add_stored_occupancy_no_commit(
- db,
- slot_id=slot_id,
- item_type="cell_product",
- item_id=obj.id,
- item_code=obj.product_code or "",
- item_name=obj.product_name or "",
- item_source=obj.source_type or "manual",
- tenant_no=tenant_no,
- user_id=user_id,
- create_dept_id=create_dept_id or "",
- )
- 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("细胞产品不存在")
- old_slot_id = (obj.storage_slot_id or "").strip()
- slot_change_requested = data.storage_slot_id is not None
- if getattr(data, "product_code", None) is not None:
- code = (data.product_code or "").strip()
- if code and code != (obj.product_code or "").strip():
- if product_code_exists(db, tenant_no, code, exclude_id=obj.id):
- raise ValueError(f"产品编码 {code} 已存在")
- obj.product_code = code
- 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 getattr(data, "spec_name", None) is not None:
- obj.spec_text = (data.spec_name 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:
- _apply_cell_product_status_change(
- db,
- obj,
- data.product_status,
- tenant_no,
- user_id,
- datetime.now(),
- )
- 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
- new_slot_id = (obj.storage_slot_id or "").strip()
- in_store = (obj.product_status or "") in _CELL_PRODUCT_STORE_STATUSES
- if slot_change_requested and old_slot_id != new_slot_id:
- stored_occ = db.query(BizStorageSlotOccupancy).filter(
- BizStorageSlotOccupancy.item_type == "cell_product",
- BizStorageSlotOccupancy.item_id == obj.id,
- BizStorageSlotOccupancy.occupancy_status == "stored",
- BizStorageSlotOccupancy.del_flag == 0,
- ).first()
- if stored_occ and (stored_occ.slot_id or "").strip() != new_slot_id:
- occupancy_svc.remove_by_item_no_commit(db, "cell_product", obj.id, user_id=user_id)
- stored_occ = None
- if new_slot_id and in_store and not stored_occ:
- occupancy_svc.assert_slot_vacant(
- db, new_slot_id, tenant_no=tenant_no, exclude_product_id=obj.id,
- )
- occupancy_svc._add_stored_occupancy_no_commit(
- db,
- slot_id=new_slot_id,
- item_type="cell_product",
- item_id=obj.id,
- item_code=obj.product_code or "",
- item_name=obj.product_name or "",
- item_source=obj.source_type or "manual",
- tenant_no=tenant_no,
- user_id=user_id,
- create_dept_id=obj.create_dept_id or "",
- )
- elif not new_slot_id and stored_occ:
- occupancy_svc.remove_by_item_no_commit(db, "cell_product", obj.id, user_id=user_id)
- 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("细胞产品不存在")
- occupancy_svc.remove_by_item_no_commit(db, "cell_product", obj.id, user_id=user_id)
- 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})$")
- _PASSAGE_PRODUCT_CODE_RE = re.compile(r"^(.+)-(P\d+)-(\d{2})$", re.IGNORECASE)
- def normalize_passage_product_base(code: str) -> str:
- """去掉末尾 -P{n}-XX 或 -XX,得到代次产品编码的基础前缀。"""
- c = (code or "").strip()
- if not c:
- return ""
- m = _PASSAGE_PRODUCT_CODE_RE.match(c)
- if m:
- return m.group(1).strip()
- return normalize_product_code_base(c)
- def get_max_passage_product_seq(
- db: Session,
- tenant_no: str,
- base_code: str,
- passage: str,
- ) -> int:
- """查询 `{base_code}-{passage}-XX` 形式产品编码在库中的最大两位序号(无匹配则 0)。"""
- base = normalize_passage_product_base(base_code)
- passage = (passage or "").strip()
- if not base or not passage:
- return 0
- prefix = f"{base}-{passage}-"
- suffix_re = re.compile(rf"^{re.escape(prefix)}(\d{{2}})$")
- rows = (
- db.query(BizCellProduct.product_code)
- .filter(
- BizCellProduct.tenant_no == tenant_no,
- BizCellProduct.product_code.like(f"{prefix}%"),
- BizCellProduct.del_flag == 0,
- )
- .all()
- )
- max_seq = 0
- for (code,) in rows:
- m = suffix_re.match((code or "").strip())
- if not m:
- continue
- try:
- max_seq = max(max_seq, int(m.group(1)))
- except ValueError:
- continue
- return max_seq
- def get_max_passage_product_seq_map(
- db: Session,
- tenant_no: str,
- base_code: str,
- passages: List[str],
- ) -> Dict[str, int]:
- """批量查询各代次在 `{base_code}-{passage}-XX` 规则下的最大序号。"""
- result: Dict[str, int] = {}
- seen: set[str] = set()
- for raw in passages or []:
- passage = (raw or "").strip()
- if not passage or passage in seen:
- continue
- seen.add(passage)
- result[passage] = get_max_passage_product_seq(db, tenant_no, base_code, passage)
- return result
- def get_max_product_suffix_seq(
- db: Session,
- tenant_no: str,
- base_code: str,
- ) -> int:
- """查询 `{base_code}-XX` 形式产品编码在库中的最大两位序号(无匹配则 0)。"""
- base = normalize_product_code_base((base_code or "").strip())
- if not base:
- return 0
- prefix = f"{base}-"
- suffix_re = re.compile(rf"^{re.escape(base)}-(\d{{2}})$")
- rows = (
- db.query(BizCellProduct.product_code)
- .filter(
- BizCellProduct.tenant_no == tenant_no,
- BizCellProduct.product_code.like(f"{prefix}%"),
- BizCellProduct.del_flag == 0,
- )
- .all()
- )
- max_seq = 0
- for (code,) in rows:
- m = suffix_re.match((code or "").strip())
- if not m:
- continue
- try:
- max_seq = max(max_seq, int(m.group(1)))
- except ValueError:
- continue
- return max_seq
- def product_code_exists(
- db: Session,
- tenant_no: str,
- product_code: str,
- exclude_id: Optional[str] = None,
- ) -> bool:
- code = (product_code or "").strip()
- if not code:
- return False
- q = db.query(BizCellProduct.id).filter(
- BizCellProduct.tenant_no == tenant_no,
- BizCellProduct.product_code == code,
- BizCellProduct.del_flag == 0,
- )
- if exclude_id:
- q = q.filter(BizCellProduct.id != exclude_id)
- return q.first() is not None
- 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)
- occupancy_svc.assert_batch_slots_unique(slot_ids)
- 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 …(须通过 items 为每份指定独立储物格)"""
- count = max(1, min(int(count), 50))
- if count > 1:
- raise ValueError("入库多份时请通过入库明细为每份产品分别选择储物格")
- 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,
- )
- for slot_id in slot_ids:
- if slot_id:
- occupancy_svc.assert_slot_vacant(db, slot_id, tenant_no=tenant_no)
- now = datetime.now()
- created: List[BizCellProduct] = []
- for it, slot_id in zip(items, slot_ids):
- code = (getattr(it, "product_code", None) or "").strip()
- item_passage = (getattr(it, "passage", None) or "").strip() or None
- if not item_passage and getattr(data, "passage", None):
- item_passage = (data.passage or "").strip() or None
- item_spec = (getattr(it, "spec_name", None) or "").strip() or None
- if not item_spec and getattr(data, "spec_name", None):
- item_spec = (data.spec_name or "").strip() or None
- item_expected = getattr(it, "expected_cell_count", None)
- if item_expected is None:
- item_expected = data.expected_cell_count if hasattr(data, "expected_cell_count") else None
- item_viability = getattr(it, "viability", None)
- if item_viability is None:
- item_viability = data.viability if hasattr(data, "viability") else None
- 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,
- spec_text=item_spec,
- batch_no=(data.batch_no or "").strip() or None,
- storage_slot_id=slot_id,
- passage=item_passage,
- freeze_date=data.freeze_date if hasattr(data, "freeze_date") else None,
- expected_cell_count=item_expected,
- viability=item_viability,
- 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)
- db.flush()
- for obj in created:
- slot_id = (obj.storage_slot_id or "").strip()
- if slot_id and (obj.product_status or "") in _CELL_PRODUCT_STORE_STATUSES:
- occupancy_svc._add_stored_occupancy_no_commit(
- db,
- slot_id=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",
- tenant_no=tenant_no,
- user_id=user_id,
- create_dept_id=create_dept_id or "",
- stored_at=now,
- )
- 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 _nest_ancestor_parent(chain: List[Dict[str, Any]]) -> Any:
- """多进一时 parent 为数组;单链时为对象;无上游为 null。"""
- if not chain:
- return None
- if len(chain) == 1:
- return chain[0]
- return chain
- _WORK_ORDER_TYPE_LABELS = {
- "inbound": "入库生产",
- "temporary": "临时生产",
- "outbound": "出库生产",
- }
- def _edge_meta(task: Any, wo: Any = None) -> Dict[str, Any]:
- edge: Dict[str, Any] = {
- "workOrderNo": "",
- "workOrderType": "",
- "workOrderTypeLabel": "",
- "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 ""
- wo_type = getattr(wo, "work_order_type", None) or "inbound"
- edge["workOrderType"] = wo_type
- edge["workOrderTypeLabel"] = _WORK_ORDER_TYPE_LABELS.get(wo_type, wo_type)
- return edge
- _SOURCE_LABELS = {"cell_storage": "细胞存储", "research_donation": "科研捐赠"}
- 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 _donation_sample_customer_dict(db: Session, donation_id: str, tenant_no: str) -> Dict[str, Any]:
- if not donation_id:
- return {}
- from app.models.research_donation import BizResearchDonationParty
- party = (
- db.query(BizResearchDonationParty)
- .filter(
- BizResearchDonationParty.research_donation_id == donation_id,
- BizResearchDonationParty.tenant_no == tenant_no,
- BizResearchDonationParty.del_flag == 0,
- )
- .first()
- )
- if not party:
- return {}
- return {
- "motherName": party.mother_name or "",
- "motherPhone": party.mother_phone or "",
- "motherIdCard": party.mother_id_card or "",
- "newbornName": party.newborn_name or "",
- }
- def _sample_type_name_from_id(db: Session, sample_type_id: Optional[str]) -> str:
- if not sample_type_id:
- return ""
- from app.models.models import BizCellSampleType
- row = (
- db.query(BizCellSampleType.type_name)
- .filter(BizCellSampleType.id == sample_type_id, BizCellSampleType.del_flag == 0)
- .first()
- )
- return (row[0] if row else "") or ""
- def _find_protocol_sample(
- db: Session,
- tenant_no: str,
- input_id: str,
- input_code: str,
- ) -> Optional[Any]:
- from app.models.models import BizProtocolSample
- sample = None
- if input_id:
- sample = (
- db.query(BizProtocolSample)
- .filter(
- BizProtocolSample.id == input_id,
- BizProtocolSample.tenant_no == tenant_no,
- BizProtocolSample.del_flag == 0,
- )
- .first()
- )
- if not sample and input_code:
- sample = (
- db.query(BizProtocolSample)
- .filter(
- BizProtocolSample.sample_code == input_code,
- BizProtocolSample.tenant_no == tenant_no,
- BizProtocolSample.del_flag == 0,
- )
- .first()
- )
- return sample
- def _find_donation_sample(
- db: Session,
- tenant_no: str,
- input_id: str,
- input_code: str,
- wo: Any = None,
- ) -> Optional[Any]:
- from app.models.research_donation import BizResearchDonationSample
- from app.crud.sample_reception import _donation_sample_id_col
- sample = None
- if input_id:
- sample = (
- db.query(BizResearchDonationSample)
- .filter(
- _donation_sample_id_col(BizResearchDonationSample.id) == input_id,
- BizResearchDonationSample.tenant_no == tenant_no,
- BizResearchDonationSample.del_flag == 0,
- )
- .first()
- )
- if not sample and input_code:
- sample = (
- db.query(BizResearchDonationSample)
- .filter(
- BizResearchDonationSample.sample_code == input_code,
- BizResearchDonationSample.tenant_no == tenant_no,
- BizResearchDonationSample.del_flag == 0,
- )
- .first()
- )
- if not sample and wo and (getattr(wo, "reception_source", None) or "") == "research_donation":
- wo_sample_id = (getattr(wo, "protocol_sample_id", None) or "").strip()
- if wo_sample_id:
- sample = (
- db.query(BizResearchDonationSample)
- .filter(
- _donation_sample_id_col(BizResearchDonationSample.id) == wo_sample_id,
- BizResearchDonationSample.tenant_no == tenant_no,
- BizResearchDonationSample.del_flag == 0,
- )
- .first()
- )
- return sample
- def _protocol_sample_chain_node(
- db: Session,
- tenant_no: str,
- sample: Any,
- *,
- wo: Any,
- edge: Dict[str, Any],
- sample_type_name: str,
- ) -> Dict[str, Any]:
- reception_source = getattr(wo, "reception_source", None) if wo else ""
- reception_source = reception_source or "cell_storage"
- protocol_id = sample.collection_protocol_id or ""
- return {
- "node": {
- "id": sample.id,
- "productCode": sample.sample_code or "",
- "productName": "",
- "nodeType": "sample",
- "sampleCode": sample.sample_code or "",
- "sampleTypeName": sample_type_name,
- "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,
- **sample_special_to_dict(sample),
- },
- "edge": edge,
- "parent": None,
- }
- def _donation_sample_chain_node(
- db: Session,
- tenant_no: str,
- sample: Any,
- *,
- wo: Any,
- edge: Dict[str, Any],
- sample_type_name: str,
- ) -> Dict[str, Any]:
- donation_id = sample.research_donation_id or ""
- type_name = sample_type_name or _sample_type_name_from_id(db, sample.sample_type_id)
- return {
- "node": {
- "id": sample.id,
- "productCode": sample.sample_code or "",
- "productName": "",
- "nodeType": "sample",
- "sampleCode": sample.sample_code or "",
- "sampleTypeName": type_name,
- "protocolNo": wo.protocol_no if wo else "",
- "collectionProtocolId": donation_id,
- "receptionSource": "research_donation",
- "receptionSourceLabel": _SOURCE_LABELS["research_donation"],
- "customer": _donation_sample_customer_dict(db, donation_id, tenant_no),
- "isCurrent": False,
- **sample_special_to_dict(sample),
- },
- "edge": edge,
- "parent": None,
- }
- def _ancestor_sample_chain_link(
- db: Session,
- tenant_no: str,
- sample_input: Any,
- wo: Any,
- edge: Dict[str, Any],
- ) -> Optional[Dict[str, Any]]:
- """解析任务样本投入物,返回祖先链单段(样本终止节点)。"""
- input_id = (sample_input.input_id or "").strip()
- input_code = (sample_input.input_code or "").strip()
- if not input_id and not input_code:
- if wo and (getattr(wo, "reception_source", None) or "") == "research_donation":
- input_code = (getattr(wo, "sample_code", None) or "").strip()
- input_id = (getattr(wo, "protocol_sample_id", None) or "").strip()
- if not input_id and not input_code:
- return None
- input_name = (sample_input.input_name or "").strip()
- reception_source = (getattr(wo, "reception_source", None) or "") if wo else ""
- donation_sample = None
- if reception_source == "research_donation":
- donation_sample = _find_donation_sample(db, tenant_no, input_id, input_code, wo)
- if donation_sample:
- return _donation_sample_chain_node(
- db,
- tenant_no,
- donation_sample,
- wo=wo,
- edge=edge,
- sample_type_name=input_name,
- )
- protocol_sample = _find_protocol_sample(db, tenant_no, input_id, input_code)
- if protocol_sample:
- return _protocol_sample_chain_node(
- db,
- tenant_no,
- protocol_sample,
- wo=wo,
- edge=edge,
- sample_type_name=input_name
- or _sample_type_name_from_id(db, protocol_sample.sample_type_id),
- )
- if reception_source != "research_donation":
- donation_sample = _find_donation_sample(db, tenant_no, input_id, input_code, wo)
- if donation_sample:
- return _donation_sample_chain_node(
- db,
- tenant_no,
- donation_sample,
- wo=wo,
- edge=edge,
- sample_type_name=input_name,
- )
- return None
- def _find_cell_product_by_input(db: Session, inp: Any) -> Optional[BizCellProduct]:
- if not (inp.input_id or inp.input_code):
- return None
- if inp.input_id:
- product = (
- db.query(BizCellProduct)
- .filter(BizCellProduct.id == inp.input_id, BizCellProduct.del_flag == 0)
- .first()
- )
- if product:
- return product
- if inp.input_code:
- return (
- db.query(BizCellProduct)
- .filter(BizCellProduct.product_code == inp.input_code, BizCellProduct.del_flag == 0)
- .first()
- )
- return None
- def _ancestor_link_from_cell_product_input(
- db: Session,
- tenant_no: str,
- inp: Any,
- edge: Dict[str, Any],
- depth: int,
- ) -> Optional[Dict[str, Any]]:
- parent_product = _find_cell_product_by_input(db, inp)
- if not parent_product:
- return None
- parent_chain = _build_ancestor_chain(db, parent_product, depth + 1, tenant_no)
- return {
- "node": _product_node(parent_product),
- "edge": edge,
- "parent": _nest_ancestor_parent(parent_chain),
- }
- 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)
- inputs = (
- db.query(BizProductionTaskInput)
- .filter(
- BizProductionTaskInput.task_id == task_id,
- BizProductionTaskInput.input_type.in_(("cell_product", "sample")),
- BizProductionTaskInput.del_flag == 0,
- )
- .order_by(
- BizProductionTaskInput.order_num.asc(),
- BizProductionTaskInput.create_time.asc(),
- )
- .all()
- )
- if not inputs:
- return []
- links: List[Dict[str, Any]] = []
- seen_node_ids: set = set()
- for inp in inputs:
- link: Optional[Dict[str, Any]] = None
- if inp.input_type == "cell_product":
- link = _ancestor_link_from_cell_product_input(
- db, tenant_no, inp, edge, depth + 1
- )
- elif inp.input_type == "sample":
- link = _ancestor_sample_chain_link(db, tenant_no, inp, wo, edge)
- if not link:
- continue
- node_id = (link.get("node") or {}).get("id") or ""
- if node_id and node_id in seen_node_ids:
- continue
- if node_id:
- seen_node_ids.add(node_id)
- links.append(link)
- return links
- 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 _lineage_node_key(node: Dict[str, Any]) -> str:
- nt = (node.get("nodeType") or "").strip()
- nid = (node.get("id") or "").strip()
- if nt == "sample":
- code = (node.get("sampleCode") or "").strip()
- return f"sample:{(nid or code)}"
- return f"{nt}:{nid}"
- def _walk_ancestor_link(
- link: Any,
- out: List[Dict[str, Any]],
- seen: set,
- ) -> None:
- if not link:
- return
- if isinstance(link, list):
- for item in link:
- _walk_ancestor_link(item, out, seen)
- return
- if not isinstance(link, dict):
- return
- parent = link.get("parent")
- _walk_ancestor_link(parent, out, seen)
- node = link.get("node")
- if not isinstance(node, dict):
- return
- key = _lineage_node_key(node)
- if not key or key in seen:
- return
- seen.add(key)
- out.append(dict(node))
- def _flatten_lineage_nodes(chain: Dict[str, Any]) -> List[Dict[str, Any]]:
- """将 get_product_chain 结果展平为上游→下游节点列表(含 current,不含 descendants)。"""
- out: List[Dict[str, Any]] = []
- seen: set = set()
- for link in chain.get("ancestors") or []:
- _walk_ancestor_link(link, out, seen)
- current = chain.get("current")
- if isinstance(current, dict):
- key = _lineage_node_key(current)
- if key and key not in seen:
- seen.add(key)
- node = dict(current)
- node["isCurrent"] = True
- out.append(node)
- elif key:
- for n in out:
- if _lineage_node_key(n) == key:
- n["isCurrent"] = True
- break
- return out
- 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
- ),
- }
- def _wo_status_label(status: str) -> str:
- from app.crud.qc_work_order import _WORK_ORDER_STATUS_LABELS
- return _WORK_ORDER_STATUS_LABELS.get(status or "", status or "")
- def _slim_task_for_lineage(
- task: Dict[str, Any], results: List[Dict[str, Any]]
- ) -> Dict[str, Any]:
- return {
- "id": task.get("id") or "",
- "taskNo": task.get("taskNo") or "",
- "itemName": task.get("itemName") or "",
- "itemCode": task.get("itemCode") or "",
- "taskType": task.get("taskType") or "",
- "taskStatus": task.get("taskStatus") or "",
- "taskStatusLabel": task.get("taskStatusLabel") or "",
- "conclusion": task.get("conclusion") or "",
- "conclusionLabel": task.get("conclusionLabel") or "",
- "remark": task.get("remark") or "",
- "completedTime": task.get("completedTime") or "",
- "companyName": task.get("companyName") or "",
- "results": [
- {
- "paramName": r.get("paramName") or "",
- "paramValue": r.get("paramValue") or "",
- "paramUnit": r.get("paramUnit") or "",
- }
- for r in (results or [])
- ],
- "attachmentFiles": task.get("attachmentFiles") or [],
- }
- def _load_lineage_qc_for_nodes(
- db: Session, nodes: List[Dict[str, Any]], tenant_no: str
- ) -> List[Dict[str, Any]]:
- from app.models.models import BizQcWorkOrder
- from app.crud import qc_task as task_svc
- from app.crud import qc_task_result as result_svc
- from app.crud.qc_work_order import list_work_orders_by_sample
- product_ids = [
- (n.get("id") or "").strip()
- for n in nodes
- if (n.get("nodeType") or "") == "cell_product" and (n.get("id") or "").strip()
- ]
- wo_by_product: Dict[str, List[BizQcWorkOrder]] = {pid: [] for pid in product_ids}
- if product_ids:
- rows = (
- db.query(BizQcWorkOrder)
- .filter(
- BizQcWorkOrder.cell_product_id.in_(product_ids),
- BizQcWorkOrder.tenant_no == tenant_no,
- BizQcWorkOrder.del_flag == 0,
- )
- .order_by(BizQcWorkOrder.create_time.desc())
- .all()
- )
- for wo in rows:
- pid = (wo.cell_product_id or "").strip()
- if pid in wo_by_product:
- wo_by_product[pid].append(wo)
- sample_wo_cache: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
- node_work_orders_orm: List[List[BizQcWorkOrder]] = []
- for n in nodes:
- if (n.get("nodeType") or "") == "cell_product":
- node_work_orders_orm.append(wo_by_product.get((n.get("id") or "").strip(), []))
- else:
- node_work_orders_orm.append([])
- sample_wo_dicts: Dict[int, List[Dict[str, Any]]] = {}
- for idx, n in enumerate(nodes):
- if (n.get("nodeType") or "") != "sample":
- continue
- code = (n.get("sampleCode") or "").strip()
- rs = (n.get("receptionSource") or "").strip()
- if not code or not rs:
- sample_wo_dicts[idx] = []
- continue
- key = (code, rs)
- if key not in sample_wo_cache:
- sample_wo_cache[key] = list_work_orders_by_sample(
- db,
- sample_code=code,
- reception_source=rs,
- tenant_no=tenant_no,
- user_info=None,
- )
- sample_wo_dicts[idx] = sample_wo_cache[key]
- pending_wo_ids: List[str] = []
- for wos in node_work_orders_orm:
- for wo in wos:
- pending_wo_ids.append(wo.id)
- for wos in sample_wo_dicts.values():
- for wo in wos:
- wid = (wo.get("id") or "").strip()
- if wid:
- pending_wo_ids.append(wid)
- unique_wo_ids = list({(x or "").strip() for x in pending_wo_ids if (x or "").strip()})
- tasks_by_wo: Dict[str, List[Dict[str, Any]]] = {}
- for i in range(0, len(unique_wo_ids), 100):
- chunk = unique_wo_ids[i : i + 100]
- chunk_tasks = task_svc.list_tasks_by_work_order_ids(db, chunk, tenant_no)
- for wo_id, tasks in chunk_tasks.items():
- tasks_by_wo.setdefault(wo_id, []).extend(tasks)
- all_task_ids = [
- (t.get("id") or "").strip()
- for tasks in tasks_by_wo.values()
- for t in tasks
- if (t.get("id") or "").strip()
- ]
- results_by_task = result_svc.list_saved_results_by_task_ids(db, all_task_ids, tenant_no)
- def pack_orm_wos(wos: List[BizQcWorkOrder]) -> List[Dict[str, Any]]:
- packed = []
- for wo in wos:
- tasks = tasks_by_wo.get(wo.id, [])
- packed.append(
- {
- "id": wo.id,
- "workOrderNo": wo.work_order_no or "",
- "workOrderType": wo.work_order_type or "",
- "workOrderStatus": wo.work_order_status or "",
- "workOrderStatusLabel": _wo_status_label(wo.work_order_status or ""),
- "tasks": [
- _slim_task_for_lineage(
- t, results_by_task.get(t.get("id") or "", [])
- )
- for t in tasks
- ],
- }
- )
- return packed
- def pack_dict_wos(wos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
- packed = []
- for wo in wos:
- wid = (wo.get("id") or "").strip()
- tasks = tasks_by_wo.get(wid, [])
- packed.append(
- {
- "id": wid,
- "workOrderNo": wo.get("workOrderNo") or "",
- "workOrderType": wo.get("workOrderType") or "",
- "workOrderStatus": wo.get("workOrderStatus") or "",
- "workOrderStatusLabel": wo.get("workOrderStatusLabel")
- or _wo_status_label(wo.get("workOrderStatus") or ""),
- "tasks": [
- _slim_task_for_lineage(
- t, results_by_task.get(t.get("id") or "", [])
- )
- for t in tasks
- ],
- }
- )
- return packed
- out_nodes: List[Dict[str, Any]] = []
- for idx, n in enumerate(nodes):
- nt = n.get("nodeType") or ""
- if nt == "sample":
- label = (n.get("sampleCode") or n.get("id") or "").strip()
- work_orders = pack_dict_wos(sample_wo_dicts.get(idx, []))
- else:
- label = (n.get("productCode") or n.get("id") or "").strip()
- work_orders = pack_orm_wos(node_work_orders_orm[idx])
- out_nodes.append(
- {
- "nodeType": nt,
- "id": n.get("id") or "",
- "label": label,
- "productName": n.get("productName") or "",
- "passage": n.get("passage") or None,
- "receptionSource": n.get("receptionSource") or None,
- "receptionSourceLabel": n.get("receptionSourceLabel") or None,
- "isCurrent": bool(n.get("isCurrent")),
- "workOrders": work_orders,
- }
- )
- return out_nodes
- def get_product_lineage_qc(
- db: Session, product_id: str, tenant_no: str
- ) -> Optional[Dict[str, Any]]:
- chain = get_product_chain(db, product_id, tenant_no)
- if not chain:
- return None
- current = chain.get("current") or {}
- flat = _flatten_lineage_nodes(chain)
- nodes = _load_lineage_qc_for_nodes(db, flat, tenant_no)
- return {
- "productId": current.get("id") or product_id,
- "productCode": current.get("productCode") or "",
- "productName": current.get("productName") or "",
- "nodes": nodes,
- }
|