init: 初始化 dpb 桃育种系统代码库

前后端 + 后端 FastAPI 全量源码、部署脚本与文档。
This commit is contained in:
34047007@qq.com
2026-08-06 00:17:49 +08:00
commit b95053c52c
1469 changed files with 322298 additions and 0 deletions
@@ -0,0 +1,148 @@
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, PageResultSchema, PaginationQueryParam, ImportResultSchema
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 (
SelectionResultBatchCreateResultSchema,
SelectionResultBatchCreateSchema,
SelectionResultCreateSchema,
SelectionResultOutSchema,
SelectionResultQueryParam,
SelectionResultUpdateSchema,
)
from .service import SelectionResultService
SelectionResultRouter = APIRouter(route_class=OperationLogRoute, prefix="/selection_result", tags=["选育结果"])
@SelectionResultRouter.get("/detail/{id}", summary="获取选育结果详情", response_model=ResponseSchema[SelectionResultOutSchema])
async def get_selection_result__detail_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:selection_result:detail"]))],
id: Annotated[int, Path(description="选育结果ID")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = SelectionResultService(auth, db)
result_dict = await service.detail(id=id)
return SuccessResponse(data=result_dict, msg="获取选育结果详情成功")
@SelectionResultRouter.get("/list", summary="分页查询选育结果", response_model=ResponseSchema[PageResultSchema[SelectionResultOutSchema]])
async def get_selection_result__list_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:selection_result:query"]))],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[SelectionResultQueryParam, Query()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = SelectionResultService(auth, db)
result_dict = await service.page(
page_no=page.page_no,
page_size=page.page_size,
search=search,
order_by=page.order_by,
)
return SuccessResponse(data=result_dict, msg="查询选育结果列表成功")
@SelectionResultRouter.get("/options", summary="选育结果下拉选项")
async def get_selection_result__options_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:selection_result:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = SelectionResultService(auth, db)
options = await service.list_options()
return SuccessResponse(data=options, msg="获取选育结果选项成功")
@SelectionResultRouter.post("/create", summary="创建选育结果", response_model=ResponseSchema[SelectionResultOutSchema])
async def create_selection_result__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:selection_result:create"]))],
data: Annotated[SelectionResultCreateSchema, Body(description="创建参数")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = SelectionResultService(auth, db)
result_dict = await service.create(data=data)
return SuccessResponse(data=result_dict, msg="创建选育结果成功")
@SelectionResultRouter.post("/batch_create", summary="批量评选", response_model=ResponseSchema[SelectionResultBatchCreateResultSchema])
async def batch_create_selection_result__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:selection_result:create"]))],
data: Annotated[SelectionResultBatchCreateSchema, Body(description="批量评选参数")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = SelectionResultService(auth, db)
result_dict = await service.create_batch(data=data)
return SuccessResponse(data=result_dict, msg="批量评选完成")
@SelectionResultRouter.put("/update/{id}", summary="修改选育结果", response_model=ResponseSchema[SelectionResultOutSchema])
async def update_selection_result__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:selection_result:update"]))],
id: Annotated[int, Path(description="选育结果ID")],
data: Annotated[SelectionResultUpdateSchema, Body(description="修改参数")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = SelectionResultService(auth, db)
result_dict = await service.update(id=id, data=data)
return SuccessResponse(data=result_dict, msg="修改选育结果成功")
@SelectionResultRouter.delete("/delete", summary="删除选育结果", response_model=ResponseSchema[None])
async def delete_selection_result__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:selection_result:delete"]))],
ids: Annotated[list[int], Body(description="ID列表")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = SelectionResultService(auth, db)
await service.delete(ids=ids)
return SuccessResponse(msg="删除选育结果成功")
@SelectionResultRouter.post("/export", summary="导出选育结果")
async def export_selection_result__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:selection_result:export"]))],
search: Annotated[SelectionResultQueryParam, Query()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> StreamingResponse:
service = SelectionResultService(auth, db)
result_dict_list = await service.get_list(search=search)
export_result = SelectionResultService.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('选育结果管理.xlsx')}"},
)
@SelectionResultRouter.post("/import", summary="导入选育结果", response_model=ResponseSchema[ImportResultSchema])
async def import_selection_result__controller(
file: Annotated[UploadFile, File(description="导入文件")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:selection_result:import"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = SelectionResultService(auth, db)
batch_import_result = await service.batch_import(file=file, update_support=True)
return SuccessResponse(data=batch_import_result, msg="导入选育结果成功")
@SelectionResultRouter.post("/download/template", summary="获取选育结果导入模板", dependencies=[Depends(AuthPermission(["module_bre:selection_result:download"]))])
async def download_selection_result__template_controller() -> StreamingResponse:
import_template_result = SelectionResultService.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('选育结果管理导入模板.xlsx')}",
"Access-Control-Expose-Headers": "Content-Disposition",
},
)
@@ -0,0 +1,18 @@
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 SelectionResultModel
class BreedingSelectionResultCRUD(CRUDBase[SelectionResultModel, Any, Any]):
"""选育结果 CRUD —— 直接复用 CRUDBase(已自动注入数据权限过滤)。"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(SelectionResultModel, auth, db)
selection_result_crud = BreedingSelectionResultCRUD
@@ -0,0 +1,63 @@
"""选育结果 数据模型"""
from datetime import datetime
from sqlalchemy import Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import MappedBase, ModelMixin, UserMixin
class SelectionResultModel(ModelMixin, UserMixin, MappedBase):
"""选育结果 主数据表。"""
__tablename__ = "bre_selection_result"
combination_id: Mapped[int] = mapped_column(
Integer, ForeignKey("bre_cross_combination.id", ondelete="CASCADE"),
index=True, nullable=False, comment="杂交组合"
)
tree_id: Mapped[int] = mapped_column(
Integer, ForeignKey("bre_tree.id", ondelete="CASCADE"),
index=True, nullable=False, comment="单株"
)
tree_no: Mapped[str] = mapped_column(String(100), nullable=False, comment="单株编号(冗余自单株表)", default="")
selection_year: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="入选年份", default=None)
is_selected: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="选育结论", default=None)
clone_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("bre_clone.id", ondelete="SET NULL"),
index=True, nullable=True, comment="晋级无性系"
)
rule_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("bre_selection_rule.id", ondelete="SET NULL"),
index=True, nullable=True, comment="选育规则"
)
from_stage: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="起始阶段(breeding_stage)", default=None)
to_stage: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="晋级阶段(breeding_stage)", default=None)
approved_by: Mapped[int | None] = mapped_column(
Integer, ForeignKey("bre_personnel.id", ondelete="SET NULL"),
index=True, nullable=True, comment="审核人"
)
trial_study_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("bre_trial_study.id", ondelete="SET NULL"),
index=True, nullable=True, comment="所属试验研究点"
)
reason: Mapped[str | None] = mapped_column(Text, nullable=True, comment="入选理由", default=None)
remark: Mapped[str | None] = mapped_column(Text, nullable=True, comment="备注", default=None)
# 无 status 列:覆盖基类默认 ix_<表>_status_deleted 索引,
# 仅保留 (created_time, is_deleted) 复合索引用于数据权限过滤。
__table_args__ = (
Index("ix_bre_selection_result_created_deleted", "created_time", "is_deleted"),
)
@@ -0,0 +1,102 @@
from app.core.base_schema import CommonSchema
"""选育结果 —— Pydantic 校验/序列化模型。"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class SelectionResultBaseSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
combination_id: int = Field(..., description="杂交组合")
tree_id: int = Field(..., description="单株")
selection_year: int | None = Field(default=None, description="入选年份")
is_selected: str | None = Field(default=None, description="选育结论")
clone_id: int | None = Field(default=None, description="晋级无性系")
rule_id: int | None = Field(default=None, description="选育规则")
from_stage: str | None = Field(default=None, description="起始阶段(breeding_stage)")
to_stage: str | None = Field(default=None, description="晋级阶段(breeding_stage)")
approved_by: int | None = Field(default=None, description="审核人")
trial_study_id: int | None = Field(default=None, description="所属试验研究点")
reason: str | None = Field(default=None, description="入选理由")
remark: str | None = Field(default=None, description="备注")
class SelectionResultCreateSchema(SelectionResultBaseSchema):
pass
class SelectionResultBatchCreateSchema(BaseModel):
"""批量评选:一批单株共享同一评审结论(同一轮评选同一结论)。"""
model_config = ConfigDict(from_attributes=True)
combination_id: int = Field(..., description="杂交组合")
tree_id: list[int] = Field(..., description="单株ID列表")
selection_year: int | None = Field(default=None, description="入选年份")
is_selected: str | None = Field(default=None, description="选育结论")
rule_id: int | None = Field(default=None, description="选育规则")
from_stage: str | None = Field(default=None, description="起始阶段(breeding_stage)")
to_stage: str | None = Field(default=None, description="晋级阶段(breeding_stage)")
approved_by: int | None = Field(default=None, description="审核人")
trial_study_id: int | None = Field(default=None, description="所属试验研究点")
reason: str | None = Field(default=None, description="入选理由")
remark: str | None = Field(default=None, description="备注")
class SelectionResultBatchCreateResultSchema(BaseModel):
"""批量评选逐棵结果:一棵失败不影响其他。"""
success_count: int = Field(..., description="成功条数")
fail_count: int = Field(..., description="失败条数")
success_ids: list[int] = Field(default_factory=list, description="成功创建的选育结果ID")
fail_details: list[dict] = Field(default_factory=list, description="失败明细 [{tree_id, tree_no, msg}]")
class SelectionResultUpdateSchema(SelectionResultBaseSchema):
combination_id: int | None = Field(default=None, description="杂交组合")
tree_id: int | None = Field(default=None, description="单株")
selection_year: int | None = Field(default=None, description="入选年份")
is_selected: str | None = Field(default=None, description="选育结论")
clone_id: int | None = Field(default=None, description="晋级无性系")
rule_id: int | None = Field(default=None, description="选育规则")
from_stage: str | None = Field(default=None, description="起始阶段(breeding_stage)")
to_stage: str | None = Field(default=None, description="晋级阶段(breeding_stage)")
approved_by: int | None = Field(default=None, description="审核人")
trial_study_id: int | None = Field(default=None, description="所属试验研究点")
reason: str | None = Field(default=None, description="入选理由")
remark: str | None = Field(default=None, description="备注")
class SelectionResultOutSchema(SelectionResultBaseSchema):
id: int
uuid: str
combination_name: str | None = None # 由 Service 层联表填充
tree_name: str | None = None # 由 Service 层联表填充
tree_no: str | None = None # 冗余自单株表的单株编号
selection_year: int | None = None
is_selected: str | None = None
clone_name: str | None = None # 由 Service 层联表填充
rule_name: str | None = None # 由 Service 层联表填充
from_stage: str | None = None
to_stage: str | None = None
approved_by_name: str | None = None # 由 Service 层联表填充
trial_study_name: str | None = None # 由 Service 层联表填充
reason: str | None = None
remark: str | None = None
created_time: datetime | None = None
updated_time: datetime | None = None
created_by: CommonSchema | None = None
updated_by: CommonSchema | None = None
class SelectionResultQueryParam(BaseModel):
combination_id: int | None = Field(default=None, description="杂交组合", json_schema_extra={"q": "eq"})
tree_id: int | None = Field(default=None, description="单株", json_schema_extra={"q": "eq"})
selection_year: int | None = Field(default=None, description="入选年份", json_schema_extra={"q": "eq"})
is_selected: str | None = Field(default=None, description="选育结论", json_schema_extra={"q": "eq"})
clone_id: int | None = Field(default=None, description="晋级无性系", json_schema_extra={"q": "eq"})
rule_id: int | None = Field(default=None, description="选育规则", json_schema_extra={"q": "eq"})
from_stage: str | None = Field(default=None, description="起始阶段", json_schema_extra={"q": "eq"})
to_stage: str | None = Field(default=None, description="晋级阶段", json_schema_extra={"q": "eq"})
approved_by: int | None = Field(default=None, description="审核人", json_schema_extra={"q": "eq"})
trial_study_id: int | None = Field(default=None, description="所属试验研究点", json_schema_extra={"q": "eq"})
@@ -0,0 +1,552 @@
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 app.utils.dict_util import DictLabelResolver, dict_value_to_label
from app.utils.number_gen import NumberGenService
from .crud import BreedingSelectionResultCRUD
from .schema import (
SelectionResultBatchCreateResultSchema,
SelectionResultBatchCreateSchema,
SelectionResultCreateSchema,
SelectionResultOutSchema,
SelectionResultQueryParam,
SelectionResultUpdateSchema,
)
from app.api.v1.module_bre.cross_combination.crud import BreedingCrossCombinationCRUD
from app.api.v1.module_bre.tree.crud import BreedingTreeCRUD
from app.api.v1.module_bre.clone.crud import BreedingCloneCRUD
from app.api.v1.module_bre.selection_rule.crud import BreedingSelectionRuleCRUD
from app.api.v1.module_bre.personnel.crud import BreedingPersonnelCRUD
from app.api.v1.module_bre.trial_study.crud import BreedingTrialStudyCRUD
from app.api.v1.module_bre.germplasm.crud import BreedingGermplasmCRUD
from app.api.v1.module_bre.pedigree.crud import BreedingPedigreeCRUD
from app.core.base_crud import assert_dict_values, assert_no_children, assert_parents_exist, assert_status_forward
from app.api.v1.module_bre.cross_combination.model import CrossCombinationModel
from app.api.v1.module_bre.tree.model import TreeModel
from app.api.v1.module_bre.clone.model import CloneModel
from app.api.v1.module_bre.selection_rule.model import SelectionRuleModel
from app.api.v1.module_bre.personnel.model import PersonnelModel
from app.api.v1.module_bre.trial_study.model import TrialStudyModel
# A1 晋级触发条件(§8.10):选育结论为正向选择且晋级阶段为无性系阶段时建 clone。
_PROMOTE_TO_STAGES = {"sp", "ap", "line", "regional_trial", "released"}
_POSITIVE_SELECT = {"selected", "primary", "key"}
# 晋级到品系及以后时,单株晋升为种质(建 bre_germplasm 并回填 tree.germplasm_id)。
_GERMPLASM_STAGES = {"line", "regional_trial", "released"}
_STAGE_ORDER = {
"germplasm": 0, "parent": 1, "seedling": 2,
"sp": 3, "ap": 4, "line": 5, "regional_trial": 6, "released": 7,
}
# identify_status(选育结论)落回单株状态(tree_status)的映射:两字典共享键,
# 正向结论写回 tree.status,使已入选/已淘汰的单株不再出现在存活候选池,避免重复筛选。
_TREE_STATUS_BY_SELECT = {
"selected": "selected",
"primary": "primary",
"key": "key",
"preserved": "preserved",
"eliminated": "eliminated",
}
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_float(v: Any) -> float | None:
if _is_blank(v):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _to_int(v: Any) -> int | None:
if _is_blank(v):
return None
try:
return int(float(v))
except (TypeError, ValueError):
return None
class SelectionResultService:
"""选育结果 模块服务层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def _attach_fk_labels(self, items: list[SelectionResultOutSchema]) -> None:
if not items:
return
crud = BreedingSelectionResultCRUD(self.auth, self.db)
combination_id_ids = {getattr(it, "combination_id") for it in items if getattr(it, "combination_id")}
if combination_id_ids:
refs = await BreedingCrossCombinationCRUD(self.auth, self.db).get_list(search={"id": ("in", list(combination_id_ids))})
ref_map = {r.id: getattr(r, "combination_code") for r in refs}
for it in items:
it.combination_name = ref_map.get(getattr(it, "combination_id"))
tree_id_ids = {getattr(it, "tree_id") for it in items if getattr(it, "tree_id")}
if tree_id_ids:
refs = await BreedingTreeCRUD(self.auth, self.db).get_list(search={"id": ("in", list(tree_id_ids))})
ref_map = {r.id: getattr(r, "tree_no") for r in refs}
for it in items:
it.tree_name = ref_map.get(getattr(it, "tree_id"))
clone_id_ids = {getattr(it, "clone_id") for it in items if getattr(it, "clone_id")}
if clone_id_ids:
refs = await BreedingCloneCRUD(self.auth, self.db).get_list(search={"id": ("in", list(clone_id_ids))})
ref_map = {r.id: getattr(r, "clone_code") for r in refs}
for it in items:
it.clone_name = ref_map.get(getattr(it, "clone_id"))
rule_id_ids = {getattr(it, "rule_id") for it in items if getattr(it, "rule_id")}
if rule_id_ids:
refs = await BreedingSelectionRuleCRUD(self.auth, self.db).get_list(search={"id": ("in", list(rule_id_ids))})
ref_map = {r.id: getattr(r, "rule_name") for r in refs}
for it in items:
it.rule_name = ref_map.get(getattr(it, "rule_id"))
approved_by_ids = {getattr(it, "approved_by") for it in items if getattr(it, "approved_by")}
if approved_by_ids:
refs = await BreedingPersonnelCRUD(self.auth, self.db).get_list(search={"id": ("in", list(approved_by_ids))})
ref_map = {r.id: getattr(r, "name") for r in refs}
for it in items:
it.approved_by_name = ref_map.get(getattr(it, "approved_by"))
trial_study_id_ids = {getattr(it, "trial_study_id") for it in items if getattr(it, "trial_study_id")}
if trial_study_id_ids:
refs = await BreedingTrialStudyCRUD(self.auth, self.db).get_list(search={"id": ("in", list(trial_study_id_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"))
async def detail(self, id: int) -> SelectionResultOutSchema:
obj = await BreedingSelectionResultCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="该选育结果不存在")
out = SelectionResultOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def get_list(
self,
search: SelectionResultQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[SelectionResultOutSchema]:
obj_list = await BreedingSelectionResultCRUD(self.auth, self.db).get_list(
search=search_to_dict(search), order_by=order_by
)
outs = [SelectionResultOutSchema.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: SelectionResultQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[SelectionResultOutSchema]:
offset = (page_no - 1) * page_size
result = await BreedingSelectionResultCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=SelectionResultOutSchema,
)
await self._attach_fk_labels(result.items)
return result
async def _fetch_tree_no(self, tree_id: int) -> str:
tree = await BreedingTreeCRUD(self.auth, self.db).get(id=tree_id)
return (tree.tree_no or "") if tree else ""
@staticmethod
def _year_of(v: Any) -> int | None:
"""'YYYY-MM-DD' / 'YYYY' 等字符串提取年份。"""
if _is_blank(v):
return None
head = str(v).strip()[:4]
return int(head) if head.isdigit() else None
async def _promote_tree_to_clone(self, tree_id: int, to_stage: str) -> int | None:
"""A1 入选晋升才建 clone(§8.10)。命中晋级条件时:
单株尚无无性系 → 按 {组合编号}-{序号:04d} 新建 bre_clone(复选沿用既有,不重复建);
推进 tree.stage=to_stage(不回退);晋级到品系及以后且未关联种质 →
以 clone_code 为品种名新建 bre_germplasm 并回填 tree.germplasm_id。
返回应关联的无性系 id(新建或既有;无则 None)。
"""
if to_stage not in _PROMOTE_TO_STAGES:
return None
tree = await BreedingTreeCRUD(self.auth, self.db).get(id=tree_id)
if not tree:
return None
if tree.stage in _STAGE_ORDER and to_stage in _STAGE_ORDER \
and _STAGE_ORDER[tree.stage] > _STAGE_ORDER[to_stage]:
return None # 不回退阶段
clone_id = tree.clone_id
combo = None
if clone_id is None:
combo = await BreedingCrossCombinationCRUD(self.auth, self.db).get(id=tree.combination_id)
combo_code = combo.combination_code if combo else ""
async def _seed() -> int | None:
existing = await BreedingCloneCRUD(self.auth, self.db).get_list(search={"combination_id": tree.combination_id})
seq = 0
for c in existing:
tail = (c.clone_code or "").rsplit("-", 1)[-1]
if tail.isdigit():
seq = max(seq, int(tail))
return seq + 1
seq = await NumberGenService(self.db).next_seq(f"clone:{tree.combination_id}", seed_fn=_seed)
clone_code = f"{combo_code}-{seq:04d}" if combo_code else f"CL{seq:04d}"
clone = await BreedingCloneCRUD(self.auth, self.db).create(data={
"clone_code": clone_code,
"combination_id": tree.combination_id,
"female_parent_id": tree.dam_id if tree.dam_id is not None else (combo.female_parent_id if combo else None),
"male_parent_id": tree.sire_id if tree.sire_id is not None else (combo.male_parent_id if combo else None),
"planting_year": self._year_of(tree.planted_date),
"generation": tree.generation,
"status": "1",
})
clone_id = clone.id
tree_update = {"clone_id": clone.id}
else:
_c = await BreedingCloneCRUD(self.auth, self.db).get(id=clone_id)
clone_code = _c.clone_code if _c else f"CL{clone_id}"
tree_update = {}
if tree.stage != to_stage:
tree_update["stage"] = to_stage
if tree_update:
await BreedingTreeCRUD(self.auth, self.db).update(id=tree.id, data=tree_update)
if to_stage in _GERMPLASM_STAGES and tree.germplasm_id is None:
germ = await BreedingGermplasmCRUD(self.auth, self.db).create(data={
"cultivar_name": clone_code,
"generation": tree.generation,
"stage": to_stage,
})
await BreedingTreeCRUD(self.auth, self.db).update(id=tree.id, data={"germplasm_id": germ.id})
# 世代闭环:晋升种质须同步落 bre_pedigreechild_code=品种名,dam/sire=种质级亲本)。
# 否则统计引擎 _build_pedigree 按 child_code 匹配不到 → 该种质被当 founder,
# 跨世代系谱链(A-BLUP/亲缘/近交规避/遗传增益滚雪球)断裂。
if combo is None:
combo = await BreedingCrossCombinationCRUD(self.auth, self.db).get(id=tree.combination_id)
dam_id = tree.dam_id if tree.dam_id is not None else (combo.female_parent_id if combo else None)
sire_id = tree.sire_id if tree.sire_id is not None else (combo.male_parent_id if combo else None)
if dam_id is not None or sire_id is not None:
existing_ped = await BreedingPedigreeCRUD(self.auth, self.db).get_list(
search={"child_code": clone_code})
if not existing_ped:
await BreedingPedigreeCRUD(self.auth, self.db).create(data={
"combination_id": tree.combination_id,
"child_code": clone_code,
"dam_id": dam_id,
"sire_id": sire_id,
"generation": tree.generation,
"remark": "单株晋级自动落系谱(世代闭环)",
})
return clone_id
async def _assert_stage_forward(self, tree_id: int, new_value: str | None) -> None:
"""选育结论状态机:不早于该单株此前已到达的最晚阶段。"""
if not new_value:
return
existing = await BreedingSelectionResultCRUD(self.auth, self.db).get_list(search={"tree_id": tree_id})
await assert_status_forward(
self.db, "identify_status", [e.is_selected for e in existing], new_value, "选育结论"
)
async def _sync_tree_status(self, tree_id: int, is_selected: str | None) -> None:
"""选育结论落回单株状态(tree_status):只进不退,已入选/已淘汰的单株
不再出现在存活候选池,避免重复筛选。"""
if not is_selected or is_selected not in _TREE_STATUS_BY_SELECT:
return
tree = await BreedingTreeCRUD(self.auth, self.db).get(id=tree_id)
if not tree:
return
new_status = _TREE_STATUS_BY_SELECT[is_selected]
await assert_status_forward(self.db, "tree_status", [tree.status], new_status, "单株状态")
if tree.status != new_status:
await BreedingTreeCRUD(self.auth, self.db).update(id=tree.id, data={"status": new_status})
async def create(self, data: SelectionResultCreateSchema) -> SelectionResultOutSchema:
await assert_parents_exist(
self.db,
[
(CrossCombinationModel, data.combination_id, '杂交组合'),
(TreeModel, data.tree_id, '单株'),
(CloneModel, data.clone_id, '晋级无性系'),
(SelectionRuleModel, data.rule_id, '选育规则'),
(PersonnelModel, data.approved_by, '审核人'),
(TrialStudyModel, data.trial_study_id, '试验研究点'),
],
)
await assert_dict_values(
self.db,
[
('identify_status', data.is_selected, '选育结论'),
('breeding_stage', data.from_stage, '起始阶段'),
('breeding_stage', data.to_stage, '晋级阶段'),
],
)
await self._assert_stage_forward(data.tree_id, data.is_selected)
fields = data.model_dump(exclude_none=True)
fields["tree_no"] = await self._fetch_tree_no(data.tree_id)
obj = await BreedingSelectionResultCRUD(self.auth, self.db).create(data=fields)
if data.is_selected in _POSITIVE_SELECT:
clone_id = await self._promote_tree_to_clone(data.tree_id, data.to_stage)
if clone_id is not None and obj.clone_id is None:
obj.clone_id = clone_id
await self._sync_tree_status(data.tree_id, data.is_selected)
out = SelectionResultOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def create_batch(self, data: SelectionResultBatchCreateSchema) -> SelectionResultBatchCreateResultSchema:
"""批量评选:一批单株共享同一评审结论,单事务内逐棵复用 create()
(含校验 / A1 晋升 / 状态回写),一棵失败不影响其他。"""
if not data.tree_id:
raise CustomException(msg="批量评选失败,请至少选择一棵单株")
combo = await BreedingCrossCombinationCRUD(self.auth, self.db).get(id=data.combination_id)
if not combo:
raise CustomException(msg="批量评选失败,该杂交组合不存在")
common = data.model_dump(exclude={"combination_id", "tree_id"}, exclude_none=True)
success_ids: list[int] = []
fail_details: list[dict] = []
for tid in data.tree_id:
try:
create_data = SelectionResultCreateSchema(
combination_id=data.combination_id,
tree_id=tid,
**common,
)
obj = await self.create(create_data)
if obj.id is not None:
success_ids.append(obj.id)
except Exception as e:
tree_no = await self._fetch_tree_no(tid)
fail_details.append({"tree_id": tid, "tree_no": tree_no or f"#{tid}", "msg": f"{e!s}"})
return SelectionResultBatchCreateResultSchema(
success_count=len(success_ids),
fail_count=len(fail_details),
success_ids=success_ids,
fail_details=fail_details,
)
async def update(self, id: int, data: SelectionResultUpdateSchema) -> SelectionResultOutSchema:
obj = await BreedingSelectionResultCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="更新失败,该选育结果不存在")
await assert_parents_exist(
self.db,
[
(CrossCombinationModel, data.combination_id, '杂交组合'),
(TreeModel, data.tree_id, '单株'),
(CloneModel, data.clone_id, '晋级无性系'),
(SelectionRuleModel, data.rule_id, '选育规则'),
(PersonnelModel, data.approved_by, '审核人'),
(TrialStudyModel, data.trial_study_id, '试验研究点'),
],
)
await assert_dict_values(
self.db,
[
('identify_status', data.is_selected, '选育结论'),
('breeding_stage', data.from_stage, '起始阶段'),
('breeding_stage', data.to_stage, '晋级阶段'),
],
)
tree_id = data.tree_id if data.tree_id is not None else obj.tree_id
await self._assert_stage_forward(tree_id, data.is_selected)
fields = data.model_dump(exclude_unset=True, exclude_none=True)
if "tree_id" in fields:
fields["tree_no"] = await self._fetch_tree_no(fields["tree_id"])
obj = await BreedingSelectionResultCRUD(self.auth, self.db).update(id=id, data=fields)
is_selected = data.is_selected if data.is_selected is not None else obj.is_selected
to_stage = data.to_stage if data.to_stage is not None else obj.to_stage
if is_selected in _POSITIVE_SELECT:
clone_id = await self._promote_tree_to_clone(tree_id, to_stage)
if clone_id is not None and obj.clone_id is None:
obj.clone_id = clone_id
await self._sync_tree_status(tree_id, is_selected)
out = SelectionResultOutSchema.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 BreedingSelectionResultCRUD(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 BreedingSelectionResultCRUD(self.auth, self.db).delete(ids=ids)
async def list_options(self) -> list[dict[str, Any]]:
"""供前端下拉选择使用:返回 [{value, label}]。"""
obj_list = await BreedingSelectionResultCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
return [{"value": o.id, "label": o.is_selected} for o in obj_list]
@staticmethod
def batch_export(obj_list: list[dict[str, Any]]) -> bytes:
mapping_dict = {
"combination_name": "杂交组合",
"tree_name": "单株",
"selection_year": "入选年份",
"is_selected": "选育结论",
"clone_name": "晋级无性系",
"rule_name": "选育规则",
"from_stage": "起始阶段",
"to_stage": "晋级阶段",
"approved_by_name": "审核人",
"trial_study_name": "试验研究点",
"reason": "入选理由",
"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 "未知"
item["is_selected"] = dict_value_to_label("identify_status", item.get("is_selected"))
item["from_stage"] = dict_value_to_label("breeding_stage", item.get("from_stage"))
item["to_stage"] = dict_value_to_label("breeding_stage", item.get("to_stage"))
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 = {
"杂交组合": "combination_id",
"单株": "tree_id",
"入选年份": "selection_year",
"选育结论": "is_selected",
"晋级无性系": "clone_id",
"选育规则": "rule_id",
"起始阶段": "from_stage",
"晋级阶段": "to_stage",
"审核人": "approved_by",
"试验研究点": "trial_study_id",
"入选理由": "reason",
"备注": "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)}")
combination_id_refs = await BreedingCrossCombinationCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
combination_id_map = {getattr(r, "combination_code"): r.id for r in combination_id_refs}
tree_id_refs = await BreedingTreeCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
tree_id_map = {getattr(r, "tree_no"): r.id for r in tree_id_refs}
clone_id_refs = await BreedingCloneCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
clone_id_map = {getattr(r, "clone_code"): r.id for r in clone_id_refs}
rule_id_refs = await BreedingSelectionRuleCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
rule_id_map = {getattr(r, "rule_name"): r.id for r in rule_id_refs}
approved_by_refs = await BreedingPersonnelCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
approved_by_map = {getattr(r, "name"): r.id for r in approved_by_refs}
trial_study_id_refs = await BreedingTrialStudyCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
trial_study_id_map = {getattr(r, "study_name"): r.id for r in trial_study_id_refs}
mapped_rows = []
for row in rows:
mapped_rows.append({en: row.get(ch) for ch, en in header_dict.items()})
required_fields = ["combination_id", "tree_id"]
errors = []
for field in required_fields:
missing_indices = [i + 1 for i, r in enumerate(mapped_rows) if _is_blank(r.get(field))]
if missing_indices:
field_name = next(k for k, v in header_dict.items() if v == field)
rows_str = "".join(str(i) for i in missing_indices)
errors.append(f"{field_name}不能为空,第{rows_str}")
if errors:
raise CustomException(msg=f"导入失败,以下行缺少必要字段:\n{'; '.join(errors)}")
error_msgs: list[str] = []
success_count = 0
resolver = DictLabelResolver(self.auth, self.db, ["identify_status", "breeding_stage"])
for i, row in enumerate(mapped_rows, start=1):
try:
combination_id_val = combination_id_map.get(str(row.get("combination_id")).strip()) if not _is_blank(row.get("combination_id")) else None
tree_id_val = tree_id_map.get(str(row.get("tree_id")).strip()) if not _is_blank(row.get("tree_id")) else None
clone_id_val = clone_id_map.get(str(row.get("clone_id")).strip()) if not _is_blank(row.get("clone_id")) else None
rule_id_val = rule_id_map.get(str(row.get("rule_id")).strip()) if not _is_blank(row.get("rule_id")) else None
approved_by_val = approved_by_map.get(str(row.get("approved_by")).strip()) if not _is_blank(row.get("approved_by")) else None
trial_study_id_val = trial_study_id_map.get(str(row.get("trial_study_id")).strip()) if not _is_blank(row.get("trial_study_id")) else None
fields = {
"combination_id": combination_id_val,
"tree_id": tree_id_val,
"selection_year": _to_int(row.get("selection_year")),
"is_selected": await resolver.resolve("identify_status", row.get("is_selected")),
"clone_id": clone_id_val,
"rule_id": rule_id_val,
"from_stage": await resolver.resolve("breeding_stage", row.get("from_stage")),
"to_stage": await resolver.resolve("breeding_stage", row.get("to_stage")),
"approved_by": approved_by_val,
"trial_study_id": trial_study_id_val,
"reason": _none_if_blank(row.get("reason")),
"remark": _none_if_blank(row.get("remark")),
}
create_data = SelectionResultCreateSchema(**fields)
await self.create(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 = [
{"选育结论": ["未知", "入选", "初选", "重点", "保存", "淘汰"]},
{"起始阶段": ["germplasm", "parent", "seedling", "sp", "ap", "line", "regional_trial", "released"]},
{"晋级阶段": ["germplasm", "parent", "seedling", "sp", "ap", "line", "regional_trial", "released"]},
]
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list,
)