init: 初始化 dpb 桃育种系统代码库
前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import urllib.parse
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, Path, Query, UploadFile
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.common.response import ResponseSchema, StreamResponse, SuccessResponse
|
||||
from app.core.base_schema import (
|
||||
AuthSchema,
|
||||
ImportResultSchema,
|
||||
PageResultSchema,
|
||||
PaginationQueryParam,
|
||||
)
|
||||
from app.core.dependencies import AuthPermission, db_getter
|
||||
from app.core.router_class import OperationLogRoute
|
||||
from app.utils.common_util import bytes2file_response
|
||||
|
||||
from .schema import (
|
||||
DusDescriptorCreateSchema,
|
||||
DusDescriptorOutSchema,
|
||||
DusDescriptorQueryParam,
|
||||
DusDescriptorUpdateSchema,
|
||||
DusDistinctnessIn,
|
||||
DusObservationCreateSchema,
|
||||
DusObservationOutSchema,
|
||||
DusObservationQueryParam,
|
||||
DusObservationUpdateSchema,
|
||||
DusTestCreateSchema,
|
||||
DusTestOutSchema,
|
||||
DusTestQueryParam,
|
||||
DusTestUpdateSchema,
|
||||
)
|
||||
from .service import DusService
|
||||
|
||||
DusRouter = APIRouter(route_class=OperationLogRoute, prefix="/dus_test", tags=["DUS 测试"])
|
||||
|
||||
|
||||
# ---------- 描述符模板 ----------
|
||||
@DusRouter.get("/descriptors/detail/{id}", summary="获取 DUS 描述符详情", response_model=ResponseSchema[DusDescriptorOutSchema])
|
||||
async def get_dus_descriptor__detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:detail"]))],
|
||||
id: Annotated[int, Path(description="描述符ID")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.descriptor_detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取描述符详情成功")
|
||||
|
||||
|
||||
@DusRouter.get("/descriptors/list", summary="分页查询 DUS 描述符", response_model=ResponseSchema[PageResultSchema[DusDescriptorOutSchema]])
|
||||
async def get_dus_descriptor__list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[DusDescriptorQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.descriptor_page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询描述符列表成功")
|
||||
|
||||
|
||||
@DusRouter.get("/descriptors/options", summary="DUS 描述符下拉选项")
|
||||
async def get_dus_descriptor__options_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
options = await service.descriptor_list_options()
|
||||
return SuccessResponse(data=options, msg="获取描述符选项成功")
|
||||
|
||||
|
||||
@DusRouter.post("/descriptors/create", summary="创建 DUS 描述符", response_model=ResponseSchema[DusDescriptorOutSchema])
|
||||
async def create_dus_descriptor__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:create"]))],
|
||||
data: Annotated[DusDescriptorCreateSchema, Body(description="创建参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.descriptor_create(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建描述符成功")
|
||||
|
||||
|
||||
@DusRouter.put("/descriptors/update/{id}", summary="修改 DUS 描述符", response_model=ResponseSchema[DusDescriptorOutSchema])
|
||||
async def update_dus_descriptor__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:update"]))],
|
||||
id: Annotated[int, Path(description="描述符ID")],
|
||||
data: Annotated[DusDescriptorUpdateSchema, Body(description="修改参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.descriptor_update(id=id, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="修改描述符成功")
|
||||
|
||||
|
||||
@DusRouter.delete("/descriptors/delete", summary="删除 DUS 描述符", response_model=ResponseSchema[None])
|
||||
async def delete_dus_descriptor__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
await service.descriptor_delete(ids=ids)
|
||||
return SuccessResponse(msg="删除描述符成功")
|
||||
|
||||
|
||||
@DusRouter.post("/descriptors/export", summary="导出 DUS 描述符")
|
||||
async def export_dus_descriptor__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:export"]))],
|
||||
search: Annotated[DusDescriptorQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> StreamingResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict_list = await service.descriptor_get_list(search=search)
|
||||
export_result = DusService.descriptor_batch_export(
|
||||
obj_list=[item.model_dump() for item in result_dict_list]
|
||||
)
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(export_result),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={urllib.parse.quote('DUS描述符.xlsx')}"},
|
||||
)
|
||||
|
||||
|
||||
@DusRouter.post("/descriptors/import", summary="导入 DUS 描述符", response_model=ResponseSchema[ImportResultSchema])
|
||||
async def import_dus_descriptor__controller(
|
||||
file: Annotated[UploadFile, File(description="导入文件")],
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:import"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
batch_import_result = await service.descriptor_batch_import(file=file, update_support=True)
|
||||
return SuccessResponse(data=batch_import_result, msg="导入描述符成功")
|
||||
|
||||
|
||||
@DusRouter.post("/descriptors/download/template", summary="获取 DUS 描述符导入模板", dependencies=[Depends(AuthPermission(["module_bre:dus_test:download"]))])
|
||||
async def download_dus_descriptor__template_controller() -> StreamingResponse:
|
||||
import_template_result = DusService.descriptor_import_template_download()
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(import_template_result),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={urllib.parse.quote('DUS描述符导入模板.xlsx')}",
|
||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------- 测试记录 ----------
|
||||
@DusRouter.get("/tests/detail/{id}", summary="获取 DUS 测试详情", response_model=ResponseSchema[DusTestOutSchema])
|
||||
async def get_dus_test__detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:detail"]))],
|
||||
id: Annotated[int, Path(description="测试ID")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.test_detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取测试详情成功")
|
||||
|
||||
|
||||
@DusRouter.get("/tests/list", summary="分页查询 DUS 测试", response_model=ResponseSchema[PageResultSchema[DusTestOutSchema]])
|
||||
async def get_dus_test__list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[DusTestQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.test_page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询测试列表成功")
|
||||
|
||||
|
||||
@DusRouter.get("/tests/options", summary="DUS 测试下拉选项")
|
||||
async def get_dus_test__options_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:query"]))],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
options = await service.test_list_options()
|
||||
return SuccessResponse(data=options, msg="获取测试选项成功")
|
||||
|
||||
|
||||
@DusRouter.post("/tests/create", summary="创建 DUS 测试", response_model=ResponseSchema[DusTestOutSchema])
|
||||
async def create_dus_test__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:create"]))],
|
||||
data: Annotated[DusTestCreateSchema, Body(description="创建参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.test_create(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建测试记录成功")
|
||||
|
||||
|
||||
@DusRouter.put("/tests/update/{id}", summary="修改 DUS 测试", response_model=ResponseSchema[DusTestOutSchema])
|
||||
async def update_dus_test__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:update"]))],
|
||||
id: Annotated[int, Path(description="测试ID")],
|
||||
data: Annotated[DusTestUpdateSchema, Body(description="修改参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.test_update(id=id, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="修改测试记录成功")
|
||||
|
||||
|
||||
@DusRouter.delete("/tests/delete", summary="删除 DUS 测试", response_model=ResponseSchema[None])
|
||||
async def delete_dus_test__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
await service.test_delete(ids=ids)
|
||||
return SuccessResponse(msg="删除测试记录成功")
|
||||
|
||||
|
||||
@DusRouter.post("/distinctness/{test_id}", summary="特异性统计判定(参照默认同测试点;结果落analysis_json+自动建议结论)")
|
||||
async def run_dus_distinctness__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:create"]))],
|
||||
test_id: Annotated[int, Path(description="候选测试ID")],
|
||||
data: Annotated[DusDistinctnessIn, Body(description="判定参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
report = await service.test_distinctness(
|
||||
test_id=test_id,
|
||||
reference_test_ids=data.reference_test_ids,
|
||||
alpha=data.alpha,
|
||||
)
|
||||
return SuccessResponse(data=report, msg="特异性判定完成")
|
||||
|
||||
|
||||
@DusRouter.get("/distinctness/{test_id}", summary="读取特异性判定报告(已存analysis_json)")
|
||||
async def get_dus_distinctness__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:query"]))],
|
||||
test_id: Annotated[int, Path(description="候选测试ID")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
report = await service.distinctness_report(test_id=test_id)
|
||||
return SuccessResponse(data=report, msg="获取特异性判定报告成功")
|
||||
|
||||
|
||||
@DusRouter.post("/tests/export", summary="导出 DUS 测试")
|
||||
async def export_dus_test__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:export"]))],
|
||||
search: Annotated[DusTestQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> StreamingResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict_list = await service.test_get_list(search=search)
|
||||
export_result = DusService.test_batch_export(obj_list=[item.model_dump() for item in result_dict_list])
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(export_result),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={urllib.parse.quote('DUS测试.xlsx')}"},
|
||||
)
|
||||
|
||||
|
||||
# ---------- 观测 ----------
|
||||
@DusRouter.get("/observations/detail/{id}", summary="获取 DUS 观测详情", response_model=ResponseSchema[DusObservationOutSchema])
|
||||
async def get_dus_observation__detail_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:detail"]))],
|
||||
id: Annotated[int, Path(description="观测ID")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.observation_detail(id=id)
|
||||
return SuccessResponse(data=result_dict, msg="获取观测详情成功")
|
||||
|
||||
|
||||
@DusRouter.get("/observations/list", summary="分页查询 DUS 观测", response_model=ResponseSchema[PageResultSchema[DusObservationOutSchema]])
|
||||
async def get_dus_observation__list_controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:query"]))],
|
||||
page: Annotated[PaginationQueryParam, Depends()],
|
||||
search: Annotated[DusObservationQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.observation_page(
|
||||
page_no=page.page_no,
|
||||
page_size=page.page_size,
|
||||
search=search,
|
||||
order_by=page.order_by,
|
||||
)
|
||||
return SuccessResponse(data=result_dict, msg="查询观测列表成功")
|
||||
|
||||
|
||||
@DusRouter.post("/observations/create", summary="创建 DUS 观测", response_model=ResponseSchema[DusObservationOutSchema])
|
||||
async def create_dus_observation__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:create"]))],
|
||||
data: Annotated[DusObservationCreateSchema, Body(description="创建参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.observation_create(data=data)
|
||||
return SuccessResponse(data=result_dict, msg="创建观测成功")
|
||||
|
||||
|
||||
@DusRouter.put("/observations/update/{id}", summary="修改 DUS 观测", response_model=ResponseSchema[DusObservationOutSchema])
|
||||
async def update_dus_observation__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:update"]))],
|
||||
id: Annotated[int, Path(description="观测ID")],
|
||||
data: Annotated[DusObservationUpdateSchema, Body(description="修改参数")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict = await service.observation_update(id=id, data=data)
|
||||
return SuccessResponse(data=result_dict, msg="修改观测成功")
|
||||
|
||||
|
||||
@DusRouter.delete("/observations/delete", summary="删除 DUS 观测", response_model=ResponseSchema[None])
|
||||
async def delete_dus_observation__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:delete"]))],
|
||||
ids: Annotated[list[int], Body(description="ID列表")],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> JSONResponse:
|
||||
service = DusService(auth, db)
|
||||
await service.observation_delete(ids=ids)
|
||||
return SuccessResponse(msg="删除观测成功")
|
||||
|
||||
|
||||
@DusRouter.post("/observations/export", summary="导出 DUS 观测")
|
||||
async def export_dus_observation__controller(
|
||||
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:dus_test:export"]))],
|
||||
search: Annotated[DusObservationQueryParam, Query()],
|
||||
db: Annotated[AsyncSession, Depends(db_getter)],
|
||||
) -> StreamingResponse:
|
||||
service = DusService(auth, db)
|
||||
result_dict_list = await service.observation_get_list(search=search)
|
||||
export_result = DusService.observation_batch_export(
|
||||
obj_list=[item.model_dump() for item in result_dict_list]
|
||||
)
|
||||
return StreamResponse(
|
||||
data=bytes2file_response(export_result),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={urllib.parse.quote('DUS观测.xlsx')}"},
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.base_crud import CRUDBase
|
||||
from app.core.base_schema import AuthSchema
|
||||
|
||||
from .model import DusDescriptorModel, DusObservationModel, DusTestModel
|
||||
|
||||
|
||||
class DusDescriptorCRUD(CRUDBase[DusDescriptorModel, Any, Any]):
|
||||
"""DUS 描述符模板 CRUD —— 直接复用 CRUDBase(自动注入数据权限过滤)。"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
super().__init__(DusDescriptorModel, auth, db)
|
||||
|
||||
|
||||
class DusTestCRUD(CRUDBase[DusTestModel, Any, Any]):
|
||||
"""DUS 测试记录 CRUD。"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
super().__init__(DusTestModel, auth, db)
|
||||
|
||||
|
||||
class DusObservationCRUD(CRUDBase[DusObservationModel, Any, Any]):
|
||||
"""DUS 观测 CRUD。"""
|
||||
|
||||
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
|
||||
super().__init__(DusObservationModel, auth, db)
|
||||
|
||||
|
||||
dus_descriptor_crud = DusDescriptorCRUD
|
||||
dus_test_crud = DusTestCRUD
|
||||
dus_observation_crud = DusObservationCRUD
|
||||
@@ -0,0 +1,109 @@
|
||||
"""DUS/品种保护 数据模型(UPOV TG/53 桃描述符模板 + 测试记录 + 观测)"""
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Date,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.base_model import MappedBase, ModelMixin, UserMixin
|
||||
|
||||
|
||||
class DusDescriptorModel(ModelMixin, UserMixin, MappedBase):
|
||||
"""DUS 描述符模板(§8.14;UPOV TG/53 桃特性,关联 bre_trait 继承 ontology_uri)。"""
|
||||
|
||||
__tablename__ = "bre_dus_descriptor"
|
||||
|
||||
descriptor_no: Mapped[str] = mapped_column(String(32), nullable=False, unique=True, comment="描述符编号(UPOV TG/53 特性号)")
|
||||
|
||||
trait_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("bre_trait.id", ondelete="SET NULL"), nullable=True, comment="关联性状字典(继承 ontology_uri)", default=None
|
||||
)
|
||||
|
||||
trait_code: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="性状编码(快照)", default=None)
|
||||
|
||||
descriptor_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="特性中文名")
|
||||
|
||||
expression_type: Mapped[str] = mapped_column(String(8), nullable=False, default="QN", comment="表达类型(QN数量/PQ假质量/QL质量)")
|
||||
|
||||
method: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="测试方法(目测/测量/计数)", default=None)
|
||||
|
||||
example_varieties: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="标准/示例品种", default=None)
|
||||
|
||||
test_stage: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="测试生育期(苗期/花期/果熟期)", default=None)
|
||||
|
||||
required: Mapped[str] = mapped_column(String(1), nullable=False, default="1", comment="是否必测(1/0)")
|
||||
|
||||
remark: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="备注", default=None)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_bre_dus_descriptor_created_deleted", "created_time", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class DusTestModel(ModelMixin, UserMixin, MappedBase):
|
||||
"""DUS 测试记录(申请品种 × 测试点,含结论)。"""
|
||||
|
||||
__tablename__ = "bre_dus_test"
|
||||
|
||||
test_name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, comment="测试名称(唯一)")
|
||||
|
||||
germplasm_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("bre_germplasm.id", ondelete="SET NULL"), nullable=True, comment="申请/候选品种", default=None
|
||||
)
|
||||
|
||||
trial_study_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("bre_trial_study.id", ondelete="SET NULL"), nullable=True, comment="测试点(试验研究)", default=None
|
||||
)
|
||||
|
||||
tester: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="测试人", default=None)
|
||||
|
||||
test_date: Mapped[date | None] = mapped_column(Date, nullable=True, comment="测试日期", default=None)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="planning", comment="状态(planning/testing/completed/report)")
|
||||
|
||||
conclusion: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", comment="结论(pending/distinct/not_distinct)")
|
||||
|
||||
analysis_json: Mapped[dict | None] = mapped_column(JSON, nullable=True, comment="特异性统计判定报告快照(逐描述符+建议+参照集)", default=None)
|
||||
|
||||
report_path: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="报告附件路径", default=None)
|
||||
|
||||
remark: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="备注", default=None)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_bre_dus_test_created_deleted", "created_time", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class DusObservationModel(ModelMixin, UserMixin, MappedBase):
|
||||
"""DUS 观测(某测试 × 某描述符的表达值;数值/文本双列)。"""
|
||||
|
||||
__tablename__ = "bre_dus_observation"
|
||||
|
||||
dus_test_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("bre_dus_test.id", ondelete="CASCADE"), nullable=False, comment="DUS 测试"
|
||||
)
|
||||
|
||||
dus_descriptor_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("bre_dus_descriptor.id", ondelete="CASCADE"), nullable=False, comment="描述符"
|
||||
)
|
||||
|
||||
value_numeric: Mapped[float | None] = mapped_column(Float, nullable=True, comment="数值表达值", default=None)
|
||||
|
||||
value_text: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="文本表达值", default=None)
|
||||
|
||||
expression_note: Mapped[str | None] = mapped_column(String(256), nullable=True, comment="表达说明", default=None)
|
||||
|
||||
remark: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="备注", default=None)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("dus_test_id", "dus_descriptor_id", name="uq_bre_dus_observation_test_descriptor"),
|
||||
Index("ix_bre_dus_observation_created_deleted", "created_time", "is_deleted"),
|
||||
)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""DUS/品种保护 —— Pydantic 校验/序列化模型(描述符模板 / 测试记录 / 观测)。"""
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.base_schema import CommonSchema
|
||||
|
||||
|
||||
# ---------- 描述符模板 ----------
|
||||
class DusDescriptorBaseSchema(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
descriptor_no: str = Field(..., description="描述符编号(UPOV TG/53 特性号)")
|
||||
trait_id: int | None = Field(default=None, description="关联性状字典 id")
|
||||
trait_code: str | None = Field(default=None, description="性状编码(快照)")
|
||||
descriptor_name: str = Field(..., description="特性中文名")
|
||||
expression_type: str = Field(default="QN", description="表达类型(QN/PQ/QL)")
|
||||
method: str | None = Field(default=None, description="测试方法")
|
||||
example_varieties: str | None = Field(default=None, description="标准/示例品种")
|
||||
test_stage: str | None = Field(default=None, description="测试生育期")
|
||||
required: str = Field(default="1", description="是否必测(1/0)")
|
||||
remark: str | None = Field(default=None, description="备注")
|
||||
|
||||
|
||||
class DusDescriptorCreateSchema(DusDescriptorBaseSchema):
|
||||
pass
|
||||
|
||||
|
||||
class DusDescriptorUpdateSchema(DusDescriptorBaseSchema):
|
||||
descriptor_no: str | None = Field(default=None, description="描述符编号")
|
||||
descriptor_name: str | None = Field(default=None, description="特性中文名")
|
||||
expression_type: str | None = Field(default=None, description="表达类型(QN/PQ/QL)")
|
||||
required: str | None = Field(default=None, description="是否必测(1/0)")
|
||||
|
||||
|
||||
class DusDescriptorOutSchema(DusDescriptorBaseSchema):
|
||||
id: int
|
||||
uuid: str
|
||||
trait_name: str | None = None
|
||||
created_time: datetime | None = None
|
||||
updated_time: datetime | None = None
|
||||
created_by: CommonSchema | None = None
|
||||
updated_by: CommonSchema | None = None
|
||||
|
||||
|
||||
class DusDescriptorQueryParam(BaseModel):
|
||||
descriptor_no: str | None = Field(default=None, description="描述符编号", json_schema_extra={"q": "like"})
|
||||
descriptor_name: str | None = Field(default=None, description="特性中文名", json_schema_extra={"q": "like"})
|
||||
trait_code: str | None = Field(default=None, description="关联性状编码", json_schema_extra={"q": "like"})
|
||||
expression_type: str | None = Field(default=None, description="表达类型")
|
||||
test_stage: str | None = Field(default=None, description="测试生育期")
|
||||
|
||||
|
||||
# ---------- 测试记录 ----------
|
||||
class DusTestBaseSchema(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
test_name: str = Field(..., description="测试名称")
|
||||
germplasm_id: int | None = Field(default=None, description="申请/候选品种")
|
||||
trial_study_id: int | None = Field(default=None, description="测试点")
|
||||
tester: str | None = Field(default=None, description="测试人")
|
||||
test_date: date | None = Field(default=None, description="测试日期")
|
||||
status: str = Field(default="planning", description="状态(planning/testing/completed/report)")
|
||||
conclusion: str = Field(default="pending", description="结论(pending/distinct/not_distinct)")
|
||||
report_path: str | None = Field(default=None, description="报告附件路径")
|
||||
remark: str | None = Field(default=None, description="备注")
|
||||
|
||||
|
||||
class DusTestCreateSchema(DusTestBaseSchema):
|
||||
pass
|
||||
|
||||
|
||||
class DusTestUpdateSchema(DusTestBaseSchema):
|
||||
test_name: str | None = Field(default=None, description="测试名称")
|
||||
status: str | None = Field(default=None, description="状态")
|
||||
conclusion: str | None = Field(default=None, description="结论")
|
||||
|
||||
|
||||
class DusTestOutSchema(DusTestBaseSchema):
|
||||
id: int
|
||||
uuid: str
|
||||
germplasm_name: str | None = None
|
||||
trial_study_name: str | None = None
|
||||
analysis_json: dict | None = None
|
||||
created_time: datetime | None = None
|
||||
updated_time: datetime | None = None
|
||||
created_by: CommonSchema | None = None
|
||||
updated_by: CommonSchema | None = None
|
||||
|
||||
|
||||
class DusDistinctnessIn(BaseModel):
|
||||
"""特异性统计判定入参(§8.18)。"""
|
||||
reference_test_ids: list[int] | None = Field(
|
||||
default=None, description="显式参照测试 id 列表;为空则自动取同测试点其他非软删测试"
|
||||
)
|
||||
alpha: float = Field(
|
||||
default=0.01, gt=0.0, lt=0.5,
|
||||
description="显著性水平(默认 1% 双侧,UPOV LSD 惯例)",
|
||||
)
|
||||
|
||||
|
||||
class DusTestQueryParam(BaseModel):
|
||||
test_name: str | None = Field(default=None, description="测试名称", json_schema_extra={"q": "like"})
|
||||
tester: str | None = Field(default=None, description="测试人", json_schema_extra={"q": "like"})
|
||||
germplasm_id: int | None = Field(default=None, description="申请/候选品种")
|
||||
trial_study_id: int | None = Field(default=None, description="测试点")
|
||||
status: str | None = Field(default=None, description="状态")
|
||||
conclusion: str | None = Field(default=None, description="结论")
|
||||
|
||||
|
||||
# ---------- 观测 ----------
|
||||
class DusObservationBaseSchema(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
dus_test_id: int = Field(..., description="DUS 测试")
|
||||
dus_descriptor_id: int = Field(..., description="描述符")
|
||||
value_numeric: float | None = Field(default=None, description="数值表达值")
|
||||
value_text: str | None = Field(default=None, description="文本表达值")
|
||||
expression_note: str | None = Field(default=None, description="表达说明")
|
||||
remark: str | None = Field(default=None, description="备注")
|
||||
|
||||
|
||||
class DusObservationCreateSchema(DusObservationBaseSchema):
|
||||
pass
|
||||
|
||||
|
||||
class DusObservationUpdateSchema(DusObservationBaseSchema):
|
||||
dus_test_id: int | None = Field(default=None, description="DUS 测试")
|
||||
dus_descriptor_id: int | None = Field(default=None, description="描述符")
|
||||
|
||||
|
||||
class DusObservationOutSchema(DusObservationBaseSchema):
|
||||
id: int
|
||||
uuid: str
|
||||
descriptor_no: str | None = None
|
||||
descriptor_name: str | None = None
|
||||
created_time: datetime | None = None
|
||||
updated_time: datetime | None = None
|
||||
created_by: CommonSchema | None = None
|
||||
updated_by: CommonSchema | None = None
|
||||
|
||||
|
||||
class DusObservationQueryParam(BaseModel):
|
||||
dus_test_id: int | None = Field(default=None, description="DUS 测试")
|
||||
dus_descriptor_id: int | None = Field(default=None, description="描述符")
|
||||
@@ -0,0 +1,836 @@
|
||||
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
|
||||
Reference in New Issue
Block a user