837 lines
36 KiB
Python
837 lines
36 KiB
Python
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
|