import math import statistics from collections import defaultdict from datetime import datetime from typing import Any from fastapi import UploadFile from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from scripts.breeding_stats import fdist from app.api.v1.module_bre.germplasm.model import BreedingGermplasmModel from app.api.v1.module_bre.trait.model import TraitModel from app.api.v1.module_bre.trial_study.model import TrialStudyModel from app.core.base_crud import assert_no_children, assert_parents_exist from app.core.base_schema import AuthSchema, ImportResultSchema, PageResultSchema from app.core.exceptions import CustomException from app.core.logger import logger from app.utils.common_util import search_to_dict from app.utils.excel_util import ExcelUtil from .crud import ( DusDescriptorCRUD, DusObservationCRUD, DusTestCRUD, ) from .model import DusDescriptorModel, DusObservationModel, DusTestModel from .schema import ( DusDescriptorCreateSchema, DusDescriptorOutSchema, DusDescriptorQueryParam, DusDescriptorUpdateSchema, DusObservationCreateSchema, DusObservationOutSchema, DusObservationQueryParam, DusObservationUpdateSchema, DusTestCreateSchema, DusTestOutSchema, DusTestQueryParam, DusTestUpdateSchema, ) def _is_blank(v: Any) -> bool: return v is None or (isinstance(v, str) and v.strip() == "") def _none_if_blank(v: Any) -> Any: if _is_blank(v): return None return str(v).strip() if isinstance(v, str) else v def _to_int(v: Any) -> int | None: if _is_blank(v): return None try: return int(float(str(v).strip())) except (TypeError, ValueError): return None def _to_float(v: Any) -> float | None: if _is_blank(v): return None try: return float(str(v).strip()) except (TypeError, ValueError): return None def _variety_obs(rows: list[dict[str, Any]]) -> dict[int, dict[str, list[Any]]]: """把某品种的一组观测行聚成 {descriptor_id: {"numeric": [...], "text": [...]}}。""" out: dict[int, dict[str, list[Any]]] = defaultdict(lambda: {"numeric": [], "text": []}) for r in rows: did = r.get("dus_descriptor_id") if did is None: continue if r.get("value_numeric") is not None: out[did]["numeric"].append(float(r["value_numeric"])) if r.get("value_text") not in (None, ""): out[did]["text"].append(str(r["value_text"])) return dict(out) def _merge_obs(target: dict[int, dict[str, list[Any]]], rows: list[dict[str, Any]]) -> None: for r in rows: did = r.get("dus_descriptor_id") if did is None: continue bucket = target.setdefault(did, {"numeric": [], "text": []}) if r.get("value_numeric") is not None: bucket["numeric"].append(float(r["value_numeric"])) if r.get("value_text") not in (None, ""): bucket["text"].append(str(r["value_text"])) def _text_modes(vals: list[str]) -> list[str]: if not vals: return [] counts: dict[str, int] = defaultdict(int) for s in vals: counts[s] += 1 top = max(counts.values()) return sorted([s for s, c in counts.items() if c == top]) def distinctness( candidate: dict[int, dict[str, list[Any]]], references: list[dict[int, dict[str, list[Any]]]], descriptor_meta: dict[int, dict[str, Any]], alpha: float = 0.01, ) -> dict[str, Any]: """DUS 特异性统计判定(纯函数,§8.18)。 - QN(value_numeric):候选 vs 每参照单因素 ANOVA → MSE,df → LSD = t_crit(α/2,df)·√(MSE·(1/n_cand + 1/n_ref)),|均值差|>LSD 视为 distinct; 误差自由度<1(无重复)→ 数据不足 pending。 - PQ/QL(value_text):表达状态众数比较,候选众数状态 ∉ 参照众数状态集 → distinct。 返回逐描述符判定表 + 总体建议(仅 required 描述符驱动)。 """ desc_ids = sorted(set(candidate) | {did for ref in references for did in ref}) descriptors: list[dict[str, Any]] = [] distinct_nos: list[str] = [] required_distinct: list[str] = [] pending_nos: list[str] = [] for did in desc_ids: meta = descriptor_meta.get(did, {}) exp_type = str(meta.get("expression_type") or "QN").upper() desc_no = str(meta.get("descriptor_no") or did) desc_name = meta.get("descriptor_name") or "" required = str(meta.get("required") or "0").strip() == "1" c_obs = candidate.get(did, {"numeric": [], "text": []}) refs_obs = [ref.get(did, {"numeric": [], "text": []}) for ref in references] row: dict[str, Any] = { "descriptor_id": did, "descriptor_no": desc_no, "descriptor_name": desc_name, "expression_type": exp_type, "required": "1" if required else "0", "candidate_value": None, "ref_values": [None] * len(refs_obs), "statistic": None, "critical": None, "df": None, "distinct": None, "note": None, } if exp_type == "QN": cand_vals = [v for v in c_obs.get("numeric", []) if v is not None] ref_val_lists = [ [v for v in r.get("numeric", []) if v is not None] for r in refs_obs ] row["candidate_value"] = round(statistics.fmean(cand_vals), 4) if cand_vals else None row["ref_values"] = [ round(statistics.fmean(vs), 4) if vs else None for vs in ref_val_lists ] groups = [(0, cand_vals)] + [ (i + 1, vs) for i, vs in enumerate(ref_val_lists) if vs ] groups = [g for g in groups if g[1]] k = len(groups) n_total = sum(len(vs) for _, vs in groups) df_error = n_total - k if k < 2 or df_error < 1: row["distinct"] = None row["note"] = "数据不足:QN 需候选与参照均有有效值且具备重复(误差自由度≥1),无法做 LSD" pending_nos.append(desc_no) descriptors.append(row) continue means: dict[int, float] = {} sse = 0.0 for gi, vals in groups: m = statistics.fmean(vals) means[gi] = m sse += sum((x - m) ** 2 for x in vals) mse = sse / df_error t = fdist.t_crit(alpha, df_error) row["df"] = df_error row["critical"] = round(t, 4) lsd_used = None row["distinct"] = False row["note"] = "与所有参照均未达显著差异" for i, (gi, vals) in enumerate(groups): if gi == 0: continue n_c, n_r = len(cand_vals), len(vals) lsd = t * math.sqrt(mse * (1.0 / n_c + 1.0 / n_r)) lsd_used = lsd if abs(means[0] - means[gi]) > lsd: row["distinct"] = True row["note"] = ( f"与参照{i}显著不同(|Δ|={abs(means[0] - means[gi]):.3f} > LSD={lsd:.3f})" ) break row["statistic"] = round(lsd_used, 4) if lsd_used is not None else None else: cand_modes = _text_modes(c_obs.get("text", [])) ref_modes_all = [_text_modes(r.get("text", [])) for r in refs_obs] row["candidate_value"] = " / ".join(cand_modes) if cand_modes else None row["ref_values"] = [" / ".join(ms) if ms else None for ms in ref_modes_all] if not cand_modes: row["distinct"] = None row["note"] = "数据不足:PQ/QL 需候选表达状态" pending_nos.append(desc_no) else: union = {s for ms in ref_modes_all for s in ms} overlap = any(s in union for s in cand_modes) row["distinct"] = not overlap row["note"] = ( f"候选状态{'/'.join(cand_modes)} 不在参照状态集" if row["distinct"] else f"候选状态{'/'.join(cand_modes)} 与参照重叠" ) if row["distinct"] is True: distinct_nos.append(desc_no) if required: required_distinct.append(desc_no) descriptors.append(row) req_pending = [d["descriptor_no"] for d in descriptors if d["required"] == "1" and d["distinct"] is None] req_distinct = [d["descriptor_no"] for d in descriptors if d["required"] == "1" and d["distinct"] is True] if req_pending: suggestion = "pending" elif req_distinct: suggestion = "distinct" else: suggestion = "not_distinct" warning = None if pending_nos: shown = "、".join(pending_nos[:8]) if len(pending_nos) > 8: shown += "…" warning = f"{len(pending_nos)} 个描述符数据不足未判定:{shown}" return { "alpha": alpha, "n_references": len(references), "descriptors": descriptors, "distinct_descriptors": distinct_nos, "n_distinct": len(distinct_nos), "required_distinct": required_distinct, "n_required_distinct": len(required_distinct), "suggestion": suggestion, "warning": warning, } class DusService: """DUS/品种保护 模块服务层(描述符模板 / 测试记录 / 观测 三组资源)。""" def __init__(self, auth: AuthSchema, db: AsyncSession) -> None: self.auth = auth self.db = db # ---------- 描述符模板 ---------- async def _check_descriptor_no(self, no: str, exclude_id: int | None = None) -> None: if _is_blank(no): raise CustomException(msg="描述符编号不能为空") result = await self.db.execute( select(DusDescriptorModel.id).where( DusDescriptorModel.descriptor_no == str(no).strip(), DusDescriptorModel.is_deleted.is_(False), ) ) obj_id = result.scalar_one_or_none() if obj_id is not None and obj_id != exclude_id: raise CustomException(msg=f"描述符编号 {no} 已存在", status_code=409) async def _attach_trait_labels(self, items: list[DusDescriptorOutSchema]) -> None: ids = {getattr(it, "trait_id") for it in items if getattr(it, "trait_id")} if not ids: return rows = (await self.db.execute( select(TraitModel.id, TraitModel.trait_name).where(TraitModel.id.in_(ids)) )).all() ref_map = {rid: name for rid, name in rows} for it in items: it.trait_name = ref_map.get(getattr(it, "trait_id")) async def descriptor_detail(self, id: int) -> DusDescriptorOutSchema: obj = await DusDescriptorCRUD(self.auth, self.db).get(id=id) if not obj: raise CustomException(msg="该描述符不存在") out = DusDescriptorOutSchema.model_validate(obj) await self._attach_trait_labels([out]) return out async def descriptor_get_list( self, search: DusDescriptorQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> list[DusDescriptorOutSchema]: obj_list = await DusDescriptorCRUD(self.auth, self.db).get_list( search=search_to_dict(search), order_by=order_by ) outs = [DusDescriptorOutSchema.model_validate(obj) for obj in obj_list] await self._attach_trait_labels(outs) return outs async def descriptor_page( self, page_no: int, page_size: int, search: DusDescriptorQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> PageResultSchema[DusDescriptorOutSchema]: offset = (page_no - 1) * page_size result = await DusDescriptorCRUD(self.auth, self.db).page( offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], search=search_to_dict(search, {}), out_schema=DusDescriptorOutSchema, ) await self._attach_trait_labels(result.items) return result async def descriptor_create(self, data: DusDescriptorCreateSchema) -> DusDescriptorOutSchema: await self._check_descriptor_no(data.descriptor_no) await assert_parents_exist(self.db, [(TraitModel, data.trait_id, '性状字典')]) obj = await DusDescriptorCRUD(self.auth, self.db).create(data=data) out = DusDescriptorOutSchema.model_validate(obj) await self._attach_trait_labels([out]) return out async def descriptor_update(self, id: int, data: DusDescriptorUpdateSchema) -> DusDescriptorOutSchema: obj = await DusDescriptorCRUD(self.auth, self.db).get(id=id) if not obj: raise CustomException(msg="更新失败,该描述符不存在") if not _is_blank(data.descriptor_no): await self._check_descriptor_no(data.descriptor_no, exclude_id=id) await assert_parents_exist(self.db, [(TraitModel, data.trait_id, '性状字典')]) obj = await DusDescriptorCRUD(self.auth, self.db).update(id=id, data=data) out = DusDescriptorOutSchema.model_validate(obj) await self._attach_trait_labels([out]) return out async def descriptor_delete(self, ids: list[int]) -> None: if not ids: raise CustomException(msg="删除失败,删除对象不能为空") objs = await DusDescriptorCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)}) obj_map = {o.id: o for o in objs} for id_ in ids: if id_ not in obj_map: raise CustomException(msg="删除失败,该描述符不存在") await assert_no_children(self.db, ids, [(DusObservationModel, "dus_descriptor_id", "DUS观测")]) await DusDescriptorCRUD(self.auth, self.db).delete(ids=ids) async def descriptor_list_options(self) -> list[dict[str, Any]]: obj_list = await DusDescriptorCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}]) return [{"value": o.id, "label": f"{o.descriptor_no} {o.descriptor_name}"} for o in obj_list] @staticmethod def descriptor_batch_export(obj_list: list[dict[str, Any]]) -> bytes: mapping_dict = { "descriptor_no": "描述符编号", "descriptor_name": "特性中文名", "trait_code": "关联性状编码", "trait_name": "关联性状名称", "expression_type": "表达类型", "method": "测试方法", "example_varieties": "标准/示例品种", "test_stage": "测试生育期", "required": "是否必测", "remark": "备注", "created_time": "创建时间", "created_by": "创建者", } data = [dict(item) for item in obj_list] for item in data: creator = item.get("created_by") item["created_by"] = creator.get("name", "未知") if isinstance(creator, dict) else "未知" return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict) async def descriptor_batch_import(self, file: UploadFile, update_support: bool = False) -> ImportResultSchema: header_dict = { "描述符编号": "descriptor_no", "特性中文名": "descriptor_name", "关联性状编码": "trait_code", "表达类型": "expression_type", "测试方法": "method", "标准/示例品种": "example_varieties", "测试生育期": "test_stage", "是否必测": "required", "备注": "remark", } try: contents = await file.read() rows = ExcelUtil.read_excel_to_dicts(contents) await file.close() if not rows: raise CustomException(msg="导入文件为空") missing_headers = [h for h in header_dict if h not in rows[0]] if missing_headers: raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}") trait_rows = (await self.db.execute(select(TraitModel.trait_code, TraitModel.id))).all() trait_map = {code: tid for code, tid in trait_rows} error_msgs: list[str] = [] success_count = 0 crud = DusDescriptorCRUD(self.auth, self.db) seen_nos: set[str] = set() for i, row in enumerate(rows, start=1): try: no = row.get("descriptor_no") if _is_blank(no): raise CustomException(msg="描述符编号不能为空") name = row.get("descriptor_name") if _is_blank(name): raise CustomException(msg="特性中文名不能为空") no = str(no).strip() if no in seen_nos: raise CustomException(msg=f"文件内描述符编号重复:{no}") seen_nos.add(no) await self._check_descriptor_no(no) trait_code = _none_if_blank(row.get("trait_code")) trait_id = trait_map.get(str(trait_code)) if trait_code else None fields = { "descriptor_no": no, "descriptor_name": str(name).strip(), "trait_id": trait_id, "trait_code": trait_code, "expression_type": str(row.get("expression_type") or "QN").strip(), "method": _none_if_blank(row.get("method")), "example_varieties": _none_if_blank(row.get("example_varieties")), "test_stage": _none_if_blank(row.get("test_stage")), "required": str(row.get("required") or "1").strip(), "remark": _none_if_blank(row.get("remark")), } await crud.create(data=DusDescriptorCreateSchema(**fields)) success_count += 1 except Exception as e: error_msgs.append(f"第{i}行: {e!s}") continue return ImportResultSchema( valid_count=success_count, invalid_count=len(error_msgs), message_list=error_msgs, ) except Exception as e: logger.error(f"批量导入描述符失败: {e!s}") raise CustomException(msg=f"导入失败: {e!s}") @staticmethod def descriptor_import_template_download() -> bytes: header_list = [ "描述符编号", "特性中文名", "关联性状编码", "表达类型", "测试方法", "标准/示例品种", "测试生育期", "是否必测", "备注", ] return ExcelUtil.get_excel_template( header_list=header_list, selector_header_list=["表达类型", "是否必测"], option_list=[{"表达类型": ["QN", "PQ", "QL"]}, {"是否必测": ["1", "0"]}], ) # ---------- 测试记录 ---------- async def _check_test_name(self, name: str, exclude_id: int | None = None) -> None: if _is_blank(name): raise CustomException(msg="测试名称不能为空") result = await self.db.execute( select(DusTestModel.id).where( DusTestModel.test_name == str(name).strip(), DusTestModel.is_deleted.is_(False), ) ) obj_id = result.scalar_one_or_none() if obj_id is not None and obj_id != exclude_id: raise CustomException(msg=f"测试名称 {name} 已存在", status_code=409) async def _attach_test_labels(self, items: list[DusTestOutSchema]) -> None: if not items: return germ_ids = {getattr(it, "germplasm_id") for it in items if getattr(it, "germplasm_id")} trial_ids = {getattr(it, "trial_study_id") for it in items if getattr(it, "trial_study_id")} if germ_ids: rows = (await self.db.execute( select(BreedingGermplasmModel.id, BreedingGermplasmModel.cultivar_name).where( BreedingGermplasmModel.id.in_(germ_ids) ) )).all() ref_map = {rid: name for rid, name in rows} for it in items: it.germplasm_name = ref_map.get(getattr(it, "germplasm_id")) if trial_ids: rows = (await self.db.execute( select(TrialStudyModel.id, TrialStudyModel.study_name).where( TrialStudyModel.id.in_(trial_ids) ) )).all() ref_map = {rid: name for rid, name in rows} for it in items: it.trial_study_name = ref_map.get(getattr(it, "trial_study_id")) async def test_detail(self, id: int) -> DusTestOutSchema: obj = await DusTestCRUD(self.auth, self.db).get(id=id) if not obj: raise CustomException(msg="该测试记录不存在") out = DusTestOutSchema.model_validate(obj) await self._attach_test_labels([out]) return out async def test_get_list( self, search: DusTestQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> list[DusTestOutSchema]: obj_list = await DusTestCRUD(self.auth, self.db).get_list( search=search_to_dict(search), order_by=order_by ) outs = [DusTestOutSchema.model_validate(obj) for obj in obj_list] await self._attach_test_labels(outs) return outs async def test_page( self, page_no: int, page_size: int, search: DusTestQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> PageResultSchema[DusTestOutSchema]: offset = (page_no - 1) * page_size result = await DusTestCRUD(self.auth, self.db).page( offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], search=search_to_dict(search, {}), out_schema=DusTestOutSchema, ) await self._attach_test_labels(result.items) return result async def test_create(self, data: DusTestCreateSchema) -> DusTestOutSchema: await self._check_test_name(data.test_name) await assert_parents_exist(self.db, [ (BreedingGermplasmModel, data.germplasm_id, '申请品种'), (TrialStudyModel, data.trial_study_id, '测试点'), ]) obj = await DusTestCRUD(self.auth, self.db).create(data=data) out = DusTestOutSchema.model_validate(obj) await self._attach_test_labels([out]) return out async def test_update(self, id: int, data: DusTestUpdateSchema) -> DusTestOutSchema: obj = await DusTestCRUD(self.auth, self.db).get(id=id) if not obj: raise CustomException(msg="更新失败,该测试记录不存在") if not _is_blank(data.test_name): await self._check_test_name(data.test_name, exclude_id=id) await assert_parents_exist(self.db, [ (BreedingGermplasmModel, data.germplasm_id, '申请品种'), (TrialStudyModel, data.trial_study_id, '测试点'), ]) obj = await DusTestCRUD(self.auth, self.db).update(id=id, data=data) out = DusTestOutSchema.model_validate(obj) await self._attach_test_labels([out]) return out async def test_delete(self, ids: list[int]) -> None: if not ids: raise CustomException(msg="删除失败,删除对象不能为空") objs = await DusTestCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)}) obj_map = {o.id: o for o in objs} for id_ in ids: if id_ not in obj_map: raise CustomException(msg="删除失败,该测试记录不存在") await assert_no_children(self.db, ids, [(DusObservationModel, "dus_test_id", "DUS观测")]) await DusTestCRUD(self.auth, self.db).delete(ids=ids) async def test_list_options(self) -> list[dict[str, Any]]: obj_list = await DusTestCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}]) return [{"value": o.id, "label": o.test_name} for o in obj_list] @staticmethod def test_batch_export(obj_list: list[dict[str, Any]]) -> bytes: mapping_dict = { "test_name": "测试名称", "germplasm_name": "申请品种", "trial_study_name": "测试点", "tester": "测试人", "test_date": "测试日期", "status": "状态", "conclusion": "结论", "report_path": "报告附件", "remark": "备注", "created_time": "创建时间", "created_by": "创建者", } data = [dict(item) for item in obj_list] for item in data: creator = item.get("created_by") item["created_by"] = creator.get("name", "未知") if isinstance(creator, dict) else "未知" return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict) # ---------- 观测 ---------- async def _check_observation_dup( self, dus_test_id: int | None, dus_descriptor_id: int | None, exclude_id: int | None = None ) -> None: if dus_test_id is None or dus_descriptor_id is None: return conditions = [ DusObservationModel.dus_test_id == dus_test_id, DusObservationModel.dus_descriptor_id == dus_descriptor_id, DusObservationModel.is_deleted.is_(False), ] if exclude_id is not None: conditions.append(DusObservationModel.id != exclude_id) result = await self.db.execute(select(func.count()).select_from(DusObservationModel).where(*conditions)) if result.scalar() or 0: raise CustomException(msg="该(测试,描述符)观测已存在", status_code=409) async def _attach_descriptor_labels(self, items: list[DusObservationOutSchema]) -> None: ids = {getattr(it, "dus_descriptor_id") for it in items if getattr(it, "dus_descriptor_id")} if not ids: return rows = (await self.db.execute( select(DusDescriptorModel.id, DusDescriptorModel.descriptor_no, DusDescriptorModel.descriptor_name).where( DusDescriptorModel.id.in_(ids) ) )).all() ref_map = {rid: (no, name) for rid, no, name in rows} for it in items: pair = ref_map.get(getattr(it, "dus_descriptor_id")) if pair: it.descriptor_no, it.descriptor_name = pair async def observation_detail(self, id: int) -> DusObservationOutSchema: obj = await DusObservationCRUD(self.auth, self.db).get(id=id) if not obj: raise CustomException(msg="该观测不存在") out = DusObservationOutSchema.model_validate(obj) await self._attach_descriptor_labels([out]) return out async def observation_get_list( self, search: DusObservationQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> list[DusObservationOutSchema]: obj_list = await DusObservationCRUD(self.auth, self.db).get_list( search=search_to_dict(search), order_by=order_by ) outs = [DusObservationOutSchema.model_validate(obj) for obj in obj_list] await self._attach_descriptor_labels(outs) return outs async def observation_page( self, page_no: int, page_size: int, search: DusObservationQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> PageResultSchema[DusObservationOutSchema]: offset = (page_no - 1) * page_size result = await DusObservationCRUD(self.auth, self.db).page( offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], search=search_to_dict(search, {}), out_schema=DusObservationOutSchema, ) await self._attach_descriptor_labels(result.items) return result async def observation_create(self, data: DusObservationCreateSchema) -> DusObservationOutSchema: await self._check_observation_dup(data.dus_test_id, data.dus_descriptor_id) await assert_parents_exist(self.db, [ (DusTestModel, data.dus_test_id, 'DUS测试'), (DusDescriptorModel, data.dus_descriptor_id, '描述符'), ]) obj = await DusObservationCRUD(self.auth, self.db).create(data=data) out = DusObservationOutSchema.model_validate(obj) await self._attach_descriptor_labels([out]) return out async def observation_update(self, id: int, data: DusObservationUpdateSchema) -> DusObservationOutSchema: obj = await DusObservationCRUD(self.auth, self.db).get(id=id) if not obj: raise CustomException(msg="更新失败,该观测不存在") await self._check_observation_dup(data.dus_test_id, data.dus_descriptor_id, exclude_id=id) await assert_parents_exist(self.db, [ (DusTestModel, data.dus_test_id, 'DUS测试'), (DusDescriptorModel, data.dus_descriptor_id, '描述符'), ]) obj = await DusObservationCRUD(self.auth, self.db).update(id=id, data=data) out = DusObservationOutSchema.model_validate(obj) await self._attach_descriptor_labels([out]) return out async def observation_delete(self, ids: list[int]) -> None: if not ids: raise CustomException(msg="删除失败,删除对象不能为空") objs = await DusObservationCRUD(self.auth, self.db).get_list(search={"id": ("in", ids)}) obj_map = {o.id: o for o in objs} for id_ in ids: if id_ not in obj_map: raise CustomException(msg="删除失败,该观测不存在") await DusObservationCRUD(self.auth, self.db).delete(ids=ids) @staticmethod def observation_batch_export(obj_list: list[dict[str, Any]]) -> bytes: mapping_dict = { "descriptor_no": "描述符编号", "descriptor_name": "特性名称", "value_numeric": "数值表达值", "value_text": "文本表达值", "expression_note": "表达说明", "remark": "备注", "created_time": "创建时间", "created_by": "创建者", } data = [dict(item) for item in obj_list] for item in data: creator = item.get("created_by") item["created_by"] = creator.get("name", "未知") if isinstance(creator, dict) else "未知" return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict) # ---------- 特异性统计判定(§8.18) ---------- async def test_distinctness( self, test_id: int, reference_test_ids: list[int] | None = None, alpha: float = 0.01, ) -> dict[str, Any]: test = await DusTestCRUD(self.auth, self.db).get(id=test_id) if not test: raise CustomException(msg="该测试记录不存在") if reference_test_ids: ref_ids = [rid for rid in reference_test_ids if rid != test_id] if not ref_ids: raise CustomException(msg="参照测试不能为空或与候选相同") ref_objs = await DusTestCRUD(self.auth, self.db).get_list(search={"id": ("in", ref_ids)}) ref_map = {r.id: r for r in ref_objs} missing = [rid for rid in ref_ids if rid not in ref_map] if missing: raise CustomException(msg=f"参照测试不存在: {missing}") else: if test.trial_study_id is None: raise CustomException( msg="候选测试未指定测试点,无法自动选取参照;请显式传入 reference_test_ids" ) ref_objs = await DusTestCRUD(self.auth, self.db).get_list( search={"trial_study_id": test.trial_study_id} ) ref_objs = [r for r in ref_objs if r.id != test_id] if not ref_objs: raise CustomException(msg="同一测试点无其他测试可作参照") all_ids = [test_id] + [r.id for r in ref_objs] obs_rows = (await self.db.execute( select( DusObservationModel.dus_test_id, DusObservationModel.dus_descriptor_id, DusObservationModel.value_numeric, DusObservationModel.value_text, ).where( DusObservationModel.dus_test_id.in_(all_ids), DusObservationModel.is_deleted.is_(False), ) )).all() by_test: dict[int, list[dict[str, Any]]] = defaultdict(list) for row in obs_rows: by_test[row.dus_test_id].append({ "dus_descriptor_id": row.dus_descriptor_id, "value_numeric": row.value_numeric, "value_text": row.value_text, }) # 品种分组:同 germplasm_id 的多个测试视为同一品种组的重复观测(跨年/多点) cand_key: Any = test.germplasm_id if test.germplasm_id is not None else f"self{test_id}" group_obs: dict[Any, dict[int, dict[str, list[Any]]]] = {} _merge_obs(group_obs.setdefault(cand_key, {}), by_test.get(test_id, [])) for ro in ref_objs: key: Any = ro.germplasm_id if ro.germplasm_id is not None else f"t{ro.id}" if key == cand_key: _merge_obs(group_obs[cand_key], by_test.get(ro.id, [])) else: _merge_obs(group_obs.setdefault(key, {}), by_test.get(ro.id, [])) candidate = group_obs[cand_key] references = [group_obs[k] for k in group_obs if k != cand_key] if not references: raise CustomException(msg="参照测试均为候选同品种,无法构成参照集") all_dids = set(candidate) | {did for ref in references for did in ref} descriptor_meta: dict[int, dict[str, Any]] = {} if all_dids: desc_rows = (await self.db.execute( select( DusDescriptorModel.id, DusDescriptorModel.descriptor_no, DusDescriptorModel.descriptor_name, DusDescriptorModel.expression_type, DusDescriptorModel.required, ).where(DusDescriptorModel.id.in_(all_dids)) )).all() descriptor_meta = { row.id: { "descriptor_no": row.descriptor_no, "descriptor_name": row.descriptor_name, "expression_type": row.expression_type, "required": row.required, } for row in desc_rows } if not descriptor_meta: raise CustomException(msg="候选与参照均无观测数据,无法判定") report = distinctness(candidate, references, descriptor_meta, alpha=alpha) report["candidate_test_id"] = test_id report["reference_test_ids"] = [r.id for r in ref_objs] report["ran_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conclusion = test.conclusion if report["suggestion"] in ("distinct", "not_distinct") and conclusion in (None, "", "pending"): conclusion = report["suggestion"] data: dict[str, Any] = {"analysis_json": report} if conclusion != test.conclusion: data["conclusion"] = conclusion await DusTestCRUD(self.auth, self.db).update(id=test_id, data=data) return report async def distinctness_report(self, test_id: int) -> dict[str, Any]: test = await DusTestCRUD(self.auth, self.db).get(id=test_id) if not test: raise CustomException(msg="该测试记录不存在") if not test.analysis_json: raise CustomException(msg="该测试尚未运行特异性判定(POST /bre/dus_test/distinctness/{id})") return test.analysis_json