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

417 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from typing import Any
from datetime import date as _date, datetime
from fastapi import UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_schema import AuthSchema, PageResultSchema, ImportResultSchema
from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.common_util import search_to_dict
from app.utils.excel_util import ExcelUtil
from .crud import BreedingPollinationCRUD
from .schema import (
PollinationCreateSchema,
PollinationOutSchema,
PollinationQueryParam,
PollinationUpdateSchema,
)
from app.api.v1.module_bre.cross_combination.crud import BreedingCrossCombinationCRUD
from app.api.v1.module_bre.site.crud import BreedingPlotCRUD
from app.api.v1.module_bre.personnel.crud import BreedingPersonnelCRUD
from app.api.v1.module_bre.tree.crud import BreedingTreeCRUD
from app.api.v1.module_bre.pollen.crud import BreedingPollenCRUD
from app.core.base_crud import assert_no_children, assert_parents_exist
from app.api.v1.module_bre.site.model import BreedingPlotModel
from app.api.v1.module_bre.cross_combination.model import CrossCombinationModel
from app.api.v1.module_bre.personnel.model import PersonnelModel
from app.api.v1.module_bre.tree.model import TreeModel
from app.api.v1.module_bre.pollen.model import PollenModel
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
def _to_date(v: Any) -> _date | None:
"""把 Excel 单元格里的日期(字符串/日期对象/序列号)解析为 date。"""
if _is_blank(v):
return None
if isinstance(v, _date):
return v
s = str(v).strip()
if s.isdigit():
try:
return datetime.strptime(s, "%Y%m%d").date()
except ValueError:
pass
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
class PollinationService:
"""授粉管理 模块服务层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def _attach_fk_labels(self, items: list[PollinationOutSchema]) -> None:
if not items:
return
crud = BreedingPollinationCRUD(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"))
plot_id_ids = {getattr(it, "plot_id") for it in items if getattr(it, "plot_id")}
if plot_id_ids:
refs = await BreedingPlotCRUD(self.auth, self.db).get_list(search={"id": ("in", list(plot_id_ids))})
ref_map = {r.id: getattr(r, "plot_code") for r in refs}
for it in items:
it.plot_name = ref_map.get(getattr(it, "plot_id"))
bre_personnel_id_ids = {getattr(it, "bre_personnel_id") for it in items if getattr(it, "bre_personnel_id")}
if bre_personnel_id_ids:
refs = await BreedingPersonnelCRUD(self.auth, self.db).get_list(search={"id": ("in", list(bre_personnel_id_ids))})
ref_map = {r.id: getattr(r, "name") for r in refs}
for it in items:
it.bre_personnel_name = ref_map.get(getattr(it, "bre_personnel_id"))
tree_ids = {getattr(it, "female_tree_id") for it in items if getattr(it, "female_tree_id")}
tree_ids |= {getattr(it, "male_tree_id") for it in items if getattr(it, "male_tree_id")}
if tree_ids:
refs = await BreedingTreeCRUD(self.auth, self.db).get_list(search={"id": ("in", list(tree_ids))})
ref_map = {r.id: getattr(r, "tree_no") for r in refs}
for it in items:
it.female_tree_no = ref_map.get(getattr(it, "female_tree_id"))
it.male_tree_no = ref_map.get(getattr(it, "male_tree_id"))
pollen_lot_ids = {getattr(it, "pollen_lot_id") for it in items if getattr(it, "pollen_lot_id")}
if pollen_lot_ids:
refs = await BreedingPollenCRUD(self.auth, self.db).get_list(search={"id": ("in", list(pollen_lot_ids))})
ref_map = {r.id: getattr(r, "lot_code") for r in refs}
for it in items:
it.pollen_lot_no = ref_map.get(getattr(it, "pollen_lot_id"))
async def _validate_pollen_window(self, data: Any, existing: Any = None) -> None:
"""授粉窗口校验:授粉日期须落在所选花粉批次的 [采集日, 失效日] 内。
未选批次 / 批次缺有效期 / 授粉日期缺失或不可解析 时放行。
"""
lot_id = getattr(data, "pollen_lot_id", None)
if lot_id is None and existing is not None:
lot_id = getattr(existing, "pollen_lot_id", None)
if lot_id is None:
return
lot = await BreedingPollenCRUD(self.auth, self.db).get(id=lot_id)
if not lot:
raise CustomException(msg="所选花粉批次不存在或已被删除")
if lot.collect_date is None or lot.expiry_date is None:
return
date_str = getattr(data, "pollination_date", None)
if _is_blank(date_str) and existing is not None:
date_str = getattr(existing, "pollination_date", None)
poll_date = _to_date(date_str)
if poll_date is None:
return
if not (lot.collect_date <= poll_date <= lot.expiry_date):
raise CustomException(
msg=f"授粉日期 {poll_date} 不在花粉批次 {lot.lot_code} 的授粉窗口 "
f"[{lot.collect_date} ~ {lot.expiry_date}] 内"
)
async def _validate_pollen_parent(self, data: Any, existing: Any = None) -> None:
"""花粉来源父本一致性校验。
若花粉批次声明了采集父本树(male_tree_id 非空),授粉声明的父本树须与之相同,
否则 409;批次无采集父本(混合/外地采集)或授粉未声明父本树时放行。
"""
lot_id = getattr(data, "pollen_lot_id", None)
if lot_id is None and existing is not None:
lot_id = getattr(existing, "pollen_lot_id", None)
if lot_id is None:
return
lot = await BreedingPollenCRUD(self.auth, self.db).get(id=lot_id)
if not lot:
raise CustomException(msg="所选花粉批次不存在或已被删除")
if lot.male_tree_id is None:
return
poll_male = getattr(data, "male_tree_id", None)
if poll_male is None and existing is not None:
poll_male = getattr(existing, "male_tree_id", None)
if poll_male is None:
return
if poll_male != lot.male_tree_id:
lot_male = await BreedingTreeCRUD(self.auth, self.db).get(id=lot.male_tree_id)
poll_male_tree = await BreedingTreeCRUD(self.auth, self.db).get(id=poll_male)
lot_no = lot_male.tree_no if lot_male else f"#{lot.male_tree_id}"
poll_no = poll_male_tree.tree_no if poll_male_tree else f"#{poll_male}"
raise CustomException(
msg=f"花粉批次 {lot.lot_code} 采集自父本树 {lot_no}"
f"与授粉声明父本树 {poll_no} 不一致,请确认花粉来源"
)
async def detail(self, id: int) -> PollinationOutSchema:
obj = await BreedingPollinationCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="该授粉不存在")
out = PollinationOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def get_list(
self,
search: PollinationQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[PollinationOutSchema]:
obj_list = await BreedingPollinationCRUD(self.auth, self.db).get_list(
search=search_to_dict(search), order_by=order_by
)
outs = [PollinationOutSchema.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: PollinationQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[PollinationOutSchema]:
offset = (page_no - 1) * page_size
result = await BreedingPollinationCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=PollinationOutSchema,
)
await self._attach_fk_labels(result.items)
return result
async def create(self, data: PollinationCreateSchema) -> PollinationOutSchema:
await assert_parents_exist(
self.db,
[
(CrossCombinationModel, data.combination_id, '杂交组合'),
(BreedingPlotModel, data.plot_id, '试验地块'),
(PersonnelModel, data.bre_personnel_id, '育种人员'),
(TreeModel, data.female_tree_id, '母本树'),
(TreeModel, data.male_tree_id, '父本树'),
(PollenModel, data.pollen_lot_id, '花粉批次'),
],
)
await self._validate_pollen_window(data)
await self._validate_pollen_parent(data)
obj = await BreedingPollinationCRUD(self.auth, self.db).create(data=data)
out = PollinationOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def update(self, id: int, data: PollinationUpdateSchema) -> PollinationOutSchema:
obj = await BreedingPollinationCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="更新失败,该授粉不存在")
await assert_parents_exist(
self.db,
[
(CrossCombinationModel, data.combination_id, '杂交组合'),
(BreedingPlotModel, data.plot_id, '试验地块'),
(PersonnelModel, data.bre_personnel_id, '育种人员'),
(TreeModel, data.female_tree_id, '母本树'),
(TreeModel, data.male_tree_id, '父本树'),
(PollenModel, data.pollen_lot_id, '花粉批次'),
],
)
await self._validate_pollen_window(data, existing=obj)
await self._validate_pollen_parent(data, existing=obj)
obj = await BreedingPollinationCRUD(self.auth, self.db).update(id=id, data=data)
out = PollinationOutSchema.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 BreedingPollinationCRUD(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 BreedingPollinationCRUD(self.auth, self.db).delete(ids=ids)
async def list_options(self) -> list[dict[str, Any]]:
"""供前端下拉选择使用:返回 [{value, label}]。"""
obj_list = await BreedingPollinationCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
return [{"value": o.id, "label": o.pollination_date} for o in obj_list]
@staticmethod
def batch_export(obj_list: list[dict[str, Any]]) -> bytes:
mapping_dict = {
"combination_name": "杂交组合",
"plot_name": "试验地块",
"pollination_date": "授粉日期",
"bagging_date": "套袋日期",
"emasculation_date": "去雄日期",
"female_tree_no": "母本树",
"male_tree_no": "父本树",
"pollination_method": "授粉方式",
"pollen_lot_no": "花粉批次",
"flower_count": "花朵数",
"effective_count": "有效坐果数",
"bre_personnel_name": "授粉人",
"remark": "备注",
"created_time": "创建时间",
"created_by": "创建者",
}
data = [dict(item) for item in obj_list]
for item in data:
creator = item.get("created_by")
item["created_by"] = creator.get("name", "未知") if isinstance(creator, dict) else "未知"
return ExcelUtil.export_list2excel(list_data=data, mapping_dict=mapping_dict)
async def batch_import(self, file: UploadFile, update_support: bool = False) -> ImportResultSchema:
header_dict = {
"杂交组合": "combination_id",
"试验地块": "plot_id",
"授粉日期": "pollination_date",
"套袋日期": "bagging_date",
"去雄日期": "emasculation_date",
"母本树": "female_tree_id",
"父本树": "male_tree_id",
"授粉方式": "pollination_method",
"花粉批次号": "pollen_lot_id",
"花朵数": "flower_count",
"有效坐果数": "effective_count",
"授粉人": "bre_personnel_id",
"备注": "remark",
}
try:
contents = await file.read()
rows = ExcelUtil.read_excel_to_dicts(contents)
await file.close()
if not rows:
raise CustomException(msg="导入文件为空")
missing_headers = [h for h in header_dict if h not in rows[0]]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
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}
plot_id_refs = await BreedingPlotCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
plot_id_map = {getattr(r, "plot_code"): r.id for r in plot_id_refs}
bre_personnel_id_refs = await BreedingPersonnelCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
bre_personnel_id_map = {getattr(r, "name"): r.id for r in bre_personnel_id_refs}
tree_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_refs}
pollen_refs = await BreedingPollenCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
pollen_id_map = {getattr(r, "lot_code"): r.id for r in pollen_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"]
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
crud = BreedingPollinationCRUD(self.auth, self.db)
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
plot_id_val = plot_id_map.get(str(row.get("plot_id")).strip()) if not _is_blank(row.get("plot_id")) else None
bre_personnel_id_val = bre_personnel_id_map.get(str(row.get("bre_personnel_id")).strip()) if not _is_blank(row.get("bre_personnel_id")) else None
female_tree_id_val = tree_id_map.get(str(row.get("female_tree_id")).strip()) if not _is_blank(row.get("female_tree_id")) else None
male_tree_id_val = tree_id_map.get(str(row.get("male_tree_id")).strip()) if not _is_blank(row.get("male_tree_id")) else None
pollen_lot_id_val = pollen_id_map.get(str(row.get("pollen_lot_id")).strip()) if not _is_blank(row.get("pollen_lot_id")) else None
fields = {
"combination_id": combination_id_val,
"plot_id": plot_id_val,
"pollination_date": _none_if_blank(row.get("pollination_date")),
"bagging_date": _to_date(row.get("bagging_date")),
"emasculation_date": _to_date(row.get("emasculation_date")),
"female_tree_id": female_tree_id_val,
"male_tree_id": male_tree_id_val,
"pollination_method": _none_if_blank(row.get("pollination_method")),
"pollen_lot_id": pollen_lot_id_val,
"flower_count": _to_int(row.get("flower_count")),
"effective_count": _to_int(row.get("effective_count")),
"bre_personnel_id": bre_personnel_id_val,
"remark": _none_if_blank(row.get("remark")),
}
create_data = PollinationCreateSchema(**fields)
await crud.create(data=create_data)
success_count += 1
except Exception as e:
error_msgs.append(f"第{i}行: {e!s}")
continue
return ImportResultSchema(
valid_count=success_count,
invalid_count=len(error_msgs),
message_list=error_msgs,
)
except Exception as e:
logger.error(f"批量导入授粉管理失败: {e!s}")
raise CustomException(msg=f"导入失败: {e!s}")
@staticmethod
def import_template_download() -> bytes:
header_list = [
"杂交组合",
"试验地块",
"授粉日期",
"套袋日期",
"去雄日期",
"母本树",
"父本树",
"授粉方式",
"花粉批次号",
"花朵数",
"有效坐果数",
"授粉人",
"备注",
]
selector_header_list = []
option_list = [
]
return ExcelUtil.get_excel_template(
header_list=header_list,
selector_header_list=selector_header_list,
option_list=option_list,
)