Files
dpb/backend/app/api/v1/module_bre/observation/service.py
T
34047007@qq.com b95053c52c init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
2026-08-06 00:17:49 +08:00

358 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any
from fastapi import UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_schema import AuthSchema, PageResultSchema, ImportResultSchema
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 BreedingObservationCRUD
from .schema import (
ObservationCreateSchema,
ObservationOutSchema,
ObservationQueryParam,
ObservationUpdateSchema,
)
from app.core.base_crud import assert_parents_exist
from app.api.v1.module_bre.tree.crud import BreedingTreeCRUD
from app.api.v1.module_bre.tree.model import TreeModel
from app.api.v1.module_bre.trait.crud import BreedingTraitCRUD
from app.api.v1.module_bre.trait.model import TraitModel
from app.api.v1.module_bre.trial_study.crud import BreedingTrialStudyCRUD
from app.api.v1.module_bre.trial_study.model import TrialStudyModel
from app.api.v1.module_bre.personnel.crud import BreedingPersonnelCRUD
from app.api.v1.module_bre.personnel.model import PersonnelModel
from app.api.v1.module_bre.germplasm.crud import BreedingGermplasmCRUD
from app.api.v1.module_bre.germplasm.model import BreedingGermplasmModel
from app.api.v1.module_bre.site.crud import BreedingPlotCRUD, BreedingSiteCRUD
from app.api.v1.module_bre.site.model import BreedingPlotModel
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(v))
except (TypeError, ValueError):
return None
class ObservationService:
"""通用观测 模块服务层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def _validate_obs(self, data: Any, exclude_id: int | None = None) -> None:
"""EAV 观测校验(A5):obs_type 解析校验 + 数值范围 + 同日同目标同性状判重。"""
obs_type = getattr(data, "obs_type", None)
obs_value = getattr(data, "obs_value", None)
if obs_type and obs_value is not None:
val = str(obs_value).strip()
if obs_type == "numeric":
try:
float(val)
except (TypeError, ValueError):
raise CustomException(msg=f"观测值「{obs_value}」不是有效数值(obs_type=numeric")
elif obs_type == "date":
import datetime
try:
datetime.date.fromisoformat(val)
except ValueError:
raise CustomException(msg=f"观测值「{obs_value}」不是有效日期(obs_type=date,需 YYYY-MM-DD")
trait_id = getattr(data, "trait_id", None)
if trait_id and obs_type == "numeric" and obs_value is not None:
trait = await BreedingTraitCRUD(self.auth, self.db).get(id=trait_id)
if trait:
num = float(obs_value)
if trait.valid_min is not None and num < float(trait.valid_min):
raise CustomException(msg=f"数值 {num} 低于性状「{trait.trait_name}」有效下限 {trait.valid_min}")
if trait.valid_max is not None and num > float(trait.valid_max):
raise CustomException(msg=f"数值 {num} 超出性状「{trait.trait_name}」有效上限 {trait.valid_max}")
subj = [
("tree_id", getattr(data, "tree_id", None)),
("plot_id", getattr(data, "plot_id", None)),
("germplasm_id", getattr(data, "germplasm_id", None)),
]
present = [(k, v) for k, v in subj if v is not None]
if len(present) > 1:
raise CustomException(msg="观测目标只能指定一个:单株/小区/种质资源 不可同时指定")
if trait_id and present:
key, subj_id = present[0]
dup = await BreedingObservationCRUD(self.auth, self.db).get(
**{key: subj_id},
trait_id=trait_id,
obs_date=getattr(data, "obs_date", None),
obs_year=getattr(data, "obs_year", None),
)
if dup and dup.id != exclude_id:
raise CustomException(msg="该目标在此日期/年份已存在同性状观测(同日同性状判重)")
async def _attach_fk_labels(self, items: list[ObservationOutSchema]) -> None:
if not items:
return
tree_ids = {getattr(it, "tree_id") for it in items if getattr(it, "tree_id")}
if tree_ids:
refs = await BreedingTreeCRUD(self.auth, self.db).get_list(search={"id": ("in", list(tree_ids))})
ref_map = {r.id: getattr(r, "tree_no") for r in refs}
for it in items:
it.tree_no = ref_map.get(getattr(it, "tree_id"))
plot_ids = {getattr(it, "plot_id") for it in items if getattr(it, "plot_id")}
if plot_ids:
plots = await BreedingPlotCRUD(self.auth, self.db).get_list(search={"id": ("in", list(plot_ids))})
site_ids = {getattr(p, "site_id") for p in plots if getattr(p, "site_id")}
site_map: dict[int, str] = {}
if site_ids:
sites = await BreedingSiteCRUD(self.auth, self.db).get_list(search={"id": ("in", list(site_ids))})
site_map = {r.id: getattr(r, "site_name") for r in sites}
plot_map = {p.id: f"{site_map.get(p.site_id, '')}-{p.plot_code}" for p in plots}
for it in items:
it.plot_label = plot_map.get(getattr(it, "plot_id"))
trait_ids = {getattr(it, "trait_id") for it in items if getattr(it, "trait_id")}
if trait_ids:
refs = await BreedingTraitCRUD(self.auth, self.db).get_list(search={"id": ("in", list(trait_ids))})
ref_map = {r.id: getattr(r, "trait_name") for r in refs}
for it in items:
it.trait_name = ref_map.get(getattr(it, "trait_id"))
germplasm_ids = {getattr(it, "germplasm_id") for it in items if getattr(it, "germplasm_id")}
if germplasm_ids:
refs = await BreedingGermplasmCRUD(self.auth, self.db).get_list(search={"id": ("in", list(germplasm_ids))})
ref_map = {r.id: getattr(r, "cultivar_name") for r in refs}
for it in items:
it.germplasm_name = ref_map.get(getattr(it, "germplasm_id"))
trial_study_ids = {getattr(it, "trial_study_id") for it in items if getattr(it, "trial_study_id")}
if trial_study_ids:
refs = await BreedingTrialStudyCRUD(self.auth, self.db).get_list(search={"id": ("in", list(trial_study_ids))})
ref_map = {r.id: getattr(r, "study_name") for r in refs}
for it in items:
it.trial_study_name = ref_map.get(getattr(it, "trial_study_id"))
operator_ids = {getattr(it, "operator_id") for it in items if getattr(it, "operator_id")}
if operator_ids:
refs = await BreedingPersonnelCRUD(self.auth, self.db).get_list(search={"id": ("in", list(operator_ids))})
ref_map = {r.id: getattr(r, "name") for r in refs}
for it in items:
it.operator_name = ref_map.get(getattr(it, "operator_id"))
async def detail(self, id: int) -> ObservationOutSchema:
obj = await BreedingObservationCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="该观测不存在")
out = ObservationOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def get_list(
self,
search: ObservationQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[ObservationOutSchema]:
obj_list = await BreedingObservationCRUD(self.auth, self.db).get_list(
search=search_to_dict(search), order_by=order_by
)
outs = [ObservationOutSchema.model_validate(obj) for obj in obj_list]
await self._attach_fk_labels(outs)
return outs
async def page(
self,
page_no: int,
page_size: int,
search: ObservationQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[ObservationOutSchema]:
offset = (page_no - 1) * page_size
result = await BreedingObservationCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=ObservationOutSchema,
)
await self._attach_fk_labels(result.items)
return result
async def create(self, data: ObservationCreateSchema) -> ObservationOutSchema:
await assert_parents_exist(
self.db,
[
(TreeModel, data.tree_id, '单株'),
(BreedingPlotModel, data.plot_id, '小区'),
(BreedingGermplasmModel, data.germplasm_id, '种质资源'),
(TraitModel, data.trait_id, '性状'),
(TrialStudyModel, data.trial_study_id, '试验'),
(PersonnelModel, data.operator_id, '观测人'),
],
)
await self._validate_obs(data)
obj = await BreedingObservationCRUD(self.auth, self.db).create(data=data)
out = ObservationOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def update(self, id: int, data: ObservationUpdateSchema) -> ObservationOutSchema:
obj = await BreedingObservationCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="更新失败,该观测不存在")
await assert_parents_exist(
self.db,
[
(TreeModel, data.tree_id, '单株'),
(BreedingPlotModel, data.plot_id, '小区'),
(BreedingGermplasmModel, data.germplasm_id, '种质资源'),
(TraitModel, data.trait_id, '性状'),
(TrialStudyModel, data.trial_study_id, '试验'),
(PersonnelModel, data.operator_id, '观测人'),
],
)
await self._validate_obs(data, exclude_id=id)
obj = await BreedingObservationCRUD(self.auth, self.db).update(id=id, data=data)
out = ObservationOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def delete(self, ids: list[int]) -> None:
if not ids:
raise CustomException(msg="删除失败,删除对象不能为空")
objs = await BreedingObservationCRUD(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 BreedingObservationCRUD(self.auth, self.db).delete(ids=ids)
async def list_options(self) -> list[dict[str, Any]]:
"""供前端下拉选择使用:返回 [{value, label}]。"""
obj_list = await BreedingObservationCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
return [{"value": o.id, "label": f"{o.obs_type or 'obs'}: {o.obs_value or ''}"} for o in obj_list]
@staticmethod
def batch_export(obj_list: list[dict[str, Any]]) -> bytes:
mapping_dict = {
"tree_no": "单株",
"plot_label": "小区",
"trait_name": "性状",
"obs_type": "观测类型",
"obs_value": "观测值",
"obs_date": "观测日期",
"obs_year": "观测年份",
"trial_study_name": "试验",
"operator_name": "观测人",
"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 batch_import(self, file: UploadFile, update_support: bool = False) -> ImportResultSchema:
header_dict = {
"单株": "tree_id",
"小区": "plot_id",
"性状": "trait_id",
"观测类型": "obs_type",
"观测值": "obs_value",
"观测日期": "obs_date",
"观测年份": "obs_year",
"试验": "trial_study_id",
"观测人": "operator_id",
"备注": "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)}")
tree_refs = await BreedingTreeCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
tree_map = {getattr(r, "tree_no"): r.id for r in tree_refs}
plot_refs = await BreedingPlotCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
site_ids = {getattr(r, "site_id") for r in plot_refs if getattr(r, "site_id")}
site_refs = await BreedingSiteCRUD(self.auth, self.db).get_list(search={"id": ("in", list(site_ids))}) if site_ids else []
site_map = {r.id: getattr(r, "site_name") for r in site_refs}
plot_map = {f"{site_map.get(r.site_id) or ''}-{r.plot_code}": r.id for r in plot_refs}
trait_refs = await BreedingTraitCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
trait_map = {getattr(r, "trait_name"): r.id for r in trait_refs}
trial_study_refs = await BreedingTrialStudyCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
trial_study_map = {getattr(r, "study_name"): r.id for r in trial_study_refs}
operator_refs = await BreedingPersonnelCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
operator_map = {getattr(r, "name"): r.id for r in operator_refs}
error_msgs: list[str] = []
success_count = 0
crud = BreedingObservationCRUD(self.auth, self.db)
for i, row in enumerate(rows, start=1):
try:
fields = {
"tree_id": tree_map.get(str(row.get("tree_id")).strip())
if not _is_blank(row.get("tree_id")) else None,
"plot_id": plot_map.get(str(row.get("plot_id")).strip())
if not _is_blank(row.get("plot_id")) else None,
"trait_id": trait_map.get(str(row.get("trait_id")).strip())
if not _is_blank(row.get("trait_id")) else None,
"obs_type": _none_if_blank(row.get("obs_type")),
"obs_value": _none_if_blank(row.get("obs_value")),
"obs_date": _none_if_blank(row.get("obs_date")),
"obs_year": _to_int(row.get("obs_year")),
"trial_study_id": trial_study_map.get(str(row.get("trial_study_id")).strip())
if not _is_blank(row.get("trial_study_id")) else None,
"operator_id": operator_map.get(str(row.get("operator_id")).strip())
if not _is_blank(row.get("operator_id")) else None,
"remark": _none_if_blank(row.get("remark")),
}
create_data = ObservationCreateSchema(**fields)
await crud.create(data=create_data)
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 import_template_download() -> bytes:
header_list = [
"单株",
"小区",
"性状",
"观测类型",
"观测值",
"观测日期",
"观测年份",
"试验",
"观测人",
"备注",
]
selector_header_list = []
option_list = []
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list,
)