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,158 @@
import urllib.parse
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Body, Depends, File, Path, Query, UploadFile
from fastapi.responses import FileResponse, 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 (
BreedingReportCreateSchema,
BreedingReportGenerateSchema,
BreedingReportOutSchema,
BreedingReportQueryParam,
BreedingReportUpdateSchema,
)
from .service import BreedingReportService
BreedingReportRouter = APIRouter(route_class=OperationLogRoute, prefix="/breeding_report", tags=["育种报告"])
@BreedingReportRouter.get("/detail/{id}", summary="获取育种报告详情", response_model=ResponseSchema[BreedingReportOutSchema])
async def get_breeding_report__detail_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:breeding_report:detail"]))],
id: Annotated[int, Path(description="育种报告ID")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = BreedingReportService(auth, db)
result_dict = await service.detail(id=id)
return SuccessResponse(data=result_dict, msg="获取育种报告详情成功")
@BreedingReportRouter.get("/list", summary="分页查询育种报告", response_model=ResponseSchema[PageResultSchema[BreedingReportOutSchema]])
async def get_breeding_report__list_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:breeding_report:query"]))],
page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[BreedingReportQueryParam, Query()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = BreedingReportService(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="查询育种报告列表成功")
@BreedingReportRouter.get("/options", summary="育种报告下拉选项")
async def get_breeding_report__options_controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:breeding_report:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = BreedingReportService(auth, db)
options = await service.list_options()
return SuccessResponse(data=options, msg="获取育种报告选项成功")
@BreedingReportRouter.post("/create", summary="创建育种报告", response_model=ResponseSchema[BreedingReportOutSchema])
async def create_breeding_report__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:breeding_report:create"]))],
data: Annotated[BreedingReportCreateSchema, Body(description="创建参数")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = BreedingReportService(auth, db)
result_dict = await service.create(data=data)
return SuccessResponse(data=result_dict, msg="创建育种报告成功")
@BreedingReportRouter.put("/update/{id}", summary="修改育种报告", response_model=ResponseSchema[BreedingReportOutSchema])
async def update_breeding_report__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:breeding_report:update"]))],
id: Annotated[int, Path(description="育种报告ID")],
data: Annotated[BreedingReportUpdateSchema, Body(description="修改参数")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = BreedingReportService(auth, db)
result_dict = await service.update(id=id, data=data)
return SuccessResponse(data=result_dict, msg="修改育种报告成功")
@BreedingReportRouter.delete("/delete", summary="删除育种报告", response_model=ResponseSchema[None])
async def delete_breeding_report__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:breeding_report:delete"]))],
ids: Annotated[list[int], Body(description="ID列表")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = BreedingReportService(auth, db)
await service.delete(ids=ids)
return SuccessResponse(msg="删除育种报告成功")
@BreedingReportRouter.post("/export", summary="导出育种报告")
async def export_breeding_report__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:breeding_report:export"]))],
search: Annotated[BreedingReportQueryParam, Query()],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> StreamingResponse:
service = BreedingReportService(auth, db)
result_dict_list = await service.get_list(search=search)
export_result = BreedingReportService.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')}"},
)
@BreedingReportRouter.post("/import", summary="导入育种报告", response_model=ResponseSchema[ImportResultSchema])
async def import_breeding_report__controller(
file: Annotated[UploadFile, File(description="导入文件")],
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:breeding_report:import"]))],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = BreedingReportService(auth, db)
batch_import_result = await service.batch_import(file=file, update_support=True)
return SuccessResponse(data=batch_import_result, msg="导入育种报告成功")
@BreedingReportRouter.post("/download/template", summary="获取育种报告导入模板", dependencies=[Depends(AuthPermission(["module_bre:breeding_report:download"]))])
async def download_breeding_report__template_controller() -> StreamingResponse:
import_template_result = BreedingReportService.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",
},
)
@BreedingReportRouter.post("/report/generate", summary="生成育种报告")
async def generate_breeding_report__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:breeding_report:create"]))],
data: Annotated[BreedingReportGenerateSchema, Body(description="生成参数")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> JSONResponse:
service = BreedingReportService(auth, db)
result_dict = await service.generate_report(data=data)
return SuccessResponse(data=result_dict, msg="报告生成成功")
@BreedingReportRouter.get("/report/download/{id}", summary="下载育种报告")
async def download_breeding_report__controller(
auth: Annotated[AuthSchema, Depends(AuthPermission(["module_bre:breeding_report:query"]))],
id: Annotated[int, Path(description="育种报告ID")],
db: Annotated[AsyncSession, Depends(db_getter)],
) -> FileResponse:
service = BreedingReportService(auth, db)
doc_path = await service.download(report_id=id)
return FileResponse(path=doc_path, filename=Path(doc_path).name)
@@ -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 BreedingReportModel
class BreedingReportCRUD(CRUDBase[BreedingReportModel, Any, Any]):
"""育种报告 CRUD —— 直接复用 CRUDBase(已自动注入数据权限过滤)。"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
super().__init__(BreedingReportModel, auth, db)
breeding_report_crud = BreedingReportCRUD
@@ -0,0 +1,33 @@
"""育种报告 数据模型"""
from sqlalchemy import Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.base_model import MappedBase, ModelMixin, UserMixin
class BreedingReportModel(ModelMixin, UserMixin, MappedBase):
"""育种报告(§3.6prediction/stability/cv 结果快照 → JSON 报告落盘)。"""
__tablename__ = "bre_report"
report_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="报告名称")
report_type: Mapped[str | None] = mapped_column(
String(32), nullable=True, comment="报告类型(prediction/stability/cv)", default=None
)
year: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="年份", default=None)
target_id: Mapped[int | None] = mapped_column(
Integer, nullable=True, comment="目标批次id(按报告类型解析)", default=None
)
doc_path: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="报告文件路径", default=None)
gen_by: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="生成人", default=None)
remark: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="备注", default=None)
__table_args__ = (
Index("ix_bre_report_created_deleted", "created_time", "is_deleted"),
)
@@ -0,0 +1,63 @@
"""育种报告 —— Pydantic 校验/序列化模型。"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.core.base_schema import CommonSchema
class BreedingReportBaseSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
report_name: str = Field(..., description="报告名称")
report_type: str | None = Field(default=None, description="报告类型(prediction/stability/cv)")
year: int | None = Field(default=None, description="年份")
target_id: int | None = Field(default=None, description="目标批次id(按报告类型解析)")
doc_path: str | None = Field(default=None, description="报告文件路径")
gen_by: str | None = Field(default=None, description="生成人")
remark: str | None = Field(default=None, description="备注")
class BreedingReportCreateSchema(BreedingReportBaseSchema):
pass
class BreedingReportUpdateSchema(BreedingReportBaseSchema):
report_name: str | None = Field(default=None, description="报告名称")
report_type: str | None = Field(default=None, description="报告类型(prediction/stability/cv)")
year: int | None = Field(default=None, description="年份")
target_id: int | None = Field(default=None, description="目标批次id(按报告类型解析)")
doc_path: str | None = Field(default=None, description="报告文件路径")
gen_by: str | None = Field(default=None, description="生成人")
remark: str | None = Field(default=None, description="备注")
class BreedingReportOutSchema(BreedingReportBaseSchema):
id: int
uuid: str
report_name: str | None = None
report_type: str | None = None
year: int | None = None
target_id: int | None = None
doc_path: str | None = None
gen_by: 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 BreedingReportQueryParam(BaseModel):
report_type: str | None = Field(
default=None, description="报告类型(prediction/stability/cv)", json_schema_extra={"q": "eq"}
)
year: int | None = Field(default=None, description="年份", json_schema_extra={"q": "eq"})
class BreedingReportGenerateSchema(BaseModel):
report_name: str = Field(..., description="报告名称")
report_type: str = Field(..., description="prediction/stability/cv")
year: int | None = Field(default=None, description="年份")
target_id: int | None = Field(default=None, description="目标批次id(按报告类型解析)")
remark: str | None = Field(default=None, description="备注")
@@ -0,0 +1,287 @@
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.module_bre.statistics.model import (
CvResultModel,
PredictionModel,
StabilityResultModel,
)
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 BreedingReportCRUD
from .schema import (
BreedingReportCreateSchema,
BreedingReportGenerateSchema,
BreedingReportOutSchema,
BreedingReportQueryParam,
BreedingReportUpdateSchema,
)
BASE_DIR = Path(__file__).resolve().parents[5]
_SOURCE_MODEL_MAP: dict[str, type] = {
"prediction": PredictionModel,
"stability": StabilityResultModel,
"cv": CvResultModel,
}
def _is_blank(v: Any) -> bool:
return v is None or (isinstance(v, str) and v.strip() == "")
def _none_if_blank(v: Any) -> Any:
if _is_blank(v):
return None
return str(v).strip() if isinstance(v, str) else v
def _to_int(v: Any) -> int | None:
if _is_blank(v):
return None
try:
return int(float(v))
except (TypeError, ValueError):
return None
class BreedingReportService:
"""育种报告 模块服务层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def _attach_fk_labels(self, items: list[BreedingReportOutSchema]) -> None:
"""育种报告无 FK 关联标签字段,保留空实现以与其它模块 Service 对齐。"""
if not items:
return
async def detail(self, id: int) -> BreedingReportOutSchema:
obj = await BreedingReportCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="该育种报告不存在")
out = BreedingReportOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def get_list(
self,
search: BreedingReportQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[BreedingReportOutSchema]:
obj_list = await BreedingReportCRUD(self.auth, self.db).get_list(
search=search_to_dict(search), order_by=order_by
)
outs = [BreedingReportOutSchema.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: BreedingReportQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[BreedingReportOutSchema]:
offset = (page_no - 1) * page_size
result = await BreedingReportCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=BreedingReportOutSchema,
)
await self._attach_fk_labels(result.items)
return result
async def create(self, data: BreedingReportCreateSchema) -> BreedingReportOutSchema:
if _is_blank(data.report_name):
raise CustomException(msg="报告名称不能为空")
obj = await BreedingReportCRUD(self.auth, self.db).create(data=data)
out = BreedingReportOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def update(self, id: int, data: BreedingReportUpdateSchema) -> BreedingReportOutSchema:
obj = await BreedingReportCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="更新失败,该育种报告不存在")
obj = await BreedingReportCRUD(self.auth, self.db).update(id=id, data=data)
out = BreedingReportOutSchema.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 BreedingReportCRUD(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 BreedingReportCRUD(self.auth, self.db).delete(ids=ids)
async def list_options(self) -> list[dict[str, Any]]:
"""供前端下拉选择使用:返回 [{value, label}]。"""
obj_list = await BreedingReportCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
return [{"value": o.id, "label": o.report_name} for o in obj_list]
@staticmethod
def batch_export(obj_list: list[dict[str, Any]]) -> bytes:
mapping_dict = {
"report_name": "报告名称",
"report_type": "报告类型",
"year": "年份",
"target_id": "目标批次ID",
"doc_path": "文件路径",
"gen_by": "生成人",
"remark": "备注",
"created_time": "创建时间",
"created_by": "创建者",
}
data = [dict(item) for item in obj_list]
for item in data:
creator = item.get("created_by")
item["created_by"] = creator.get("name", "未知") if isinstance(creator, dict) else "未知"
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
async def batch_import(self, file: UploadFile, update_support: bool = False) -> ImportResultSchema:
header_dict = {
"报告名称": "report_name",
"报告类型": "report_type",
"年份": "year",
"目标批次ID": "target_id",
"文件路径": "doc_path",
"生成人": "gen_by",
"备注": "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)}")
error_msgs: list[str] = []
success_count = 0
crud = BreedingReportCRUD(self.auth, self.db)
for i, row in enumerate(rows, start=1):
try:
fields = {
"report_name": _none_if_blank(row.get("report_name")),
"report_type": _none_if_blank(row.get("report_type")),
"year": _to_int(row.get("year")),
"target_id": _to_int(row.get("target_id")),
"doc_path": _none_if_blank(row.get("doc_path")),
"gen_by": _none_if_blank(row.get("gen_by")),
"remark": _none_if_blank(row.get("remark")),
}
create_data = BreedingReportCreateSchema(**fields)
await crud.create(data=create_data)
success_count += 1
except Exception as e:
error_msgs.append(f"{i}行: {e!s}")
continue
return ImportResultSchema(
valid_count=success_count,
invalid_count=len(error_msgs),
message_list=error_msgs,
)
except Exception as e:
logger.error(f"批量导入育种报告失败: {e!s}")
raise CustomException(msg=f"导入失败: {e!s}")
@staticmethod
def import_template_download() -> bytes:
header_list = [
"报告名称",
"报告类型",
"年份",
"目标批次ID",
"文件路径",
"生成人",
"备注",
]
selector_header_list = []
option_list = []
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list,
)
async def generate_report(self, data: BreedingReportGenerateSchema) -> dict:
"""按 report_type 抓取统计结果快照 → 组装 JSON 报告落盘并登记 bre_report 行。"""
source_model = _SOURCE_MODEL_MAP.get(data.report_type)
if source_model is None:
raise CustomException(msg=f"不支持的报告类型: {data.report_type}(仅支持 prediction/stability/cv)")
stmt = select(source_model).where(source_model.is_deleted.is_(False))
# 年份过滤:用 getattr 探测数据源模型是否有 year 列,没有就跳过年份过滤
year_col = getattr(source_model, "year", None)
if data.year is not None and year_col is not None:
stmt = stmt.where(year_col == data.year)
# 目标批次过滤:数据源模型必有 id 列(ModelMixin 提供)
id_col = getattr(source_model, "id", None)
if data.target_id is not None and id_col is not None:
stmt = stmt.where(id_col == data.target_id)
result = await self.db.execute(stmt)
objs = result.scalars().all()
rows = [
{c.name: getattr(obj, c.name) for c in obj.__table__.columns}
for obj in objs
]
report = {
"report_name": data.report_name,
"report_type": data.report_type,
"year": data.year,
"target_id": data.target_id,
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"source_table": source_model.__tablename__,
"count": len(rows),
"rows": rows,
}
report_dir = BASE_DIR / "Temp" / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
file_name = f"{data.report_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
doc_path = str(report_dir / file_name)
(report_dir / file_name).write_text(
json.dumps(report, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
obj = await BreedingReportCRUD(self.auth, self.db).create(data={
"report_name": data.report_name,
"report_type": data.report_type,
"year": data.year,
"target_id": data.target_id,
"doc_path": doc_path,
"gen_by": self.auth.user.username,
"remark": data.remark,
})
row_dict = BreedingReportOutSchema.model_validate(obj).model_dump()
return {**row_dict, "report_summary": {"count": len(rows), "doc_path": doc_path}}
async def download(self, report_id: int) -> str:
obj = await BreedingReportCRUD(self.auth, self.db).get(id=report_id)
if not obj:
raise CustomException(msg="该育种报告不存在")
doc_path = obj.doc_path
if _is_blank(doc_path) or not Path(doc_path).exists():
raise CustomException(msg="报告文件不存在")
return doc_path