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

480 lines
20 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 import func, select
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 BreedingPollenCRUD
from .schema import (
PollenCreateSchema,
PollenOutSchema,
PollenQueryParam,
PollenUpdateSchema,
)
from app.api.v1.module_bre.tree.crud import BreedingTreeCRUD
from app.api.v1.module_bre.germplasm.crud import BreedingGermplasmCRUD
from app.core.base_crud import assert_parents_exist
from app.api.v1.module_bre.tree.model import TreeModel
from app.api.v1.module_bre.germplasm.model import BreedingGermplasmModel
from .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_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
# 花期 5 档字典 → 代表区间(月,日)(粗粒度 fallback,仅规划提示非实测;与 sys_dict
# flowering_period 值对齐,区间按桃 3-4 月物候经验设定)
_BLOOM_TIER_SPAN: dict[str, tuple[tuple[int, int], tuple[int, int]]] = {
"very_early": ((2, 25), (3, 10)),
"early": ((3, 5), (3, 20)),
"mid": ((3, 15), (3, 30)),
"late": ((3, 25), (4, 10)),
"very_late": ((4, 5), (4, 20)),
}
def _project_bloom(g: BreedingGermplasmModel, ref_year: int) -> tuple[_date, _date, str, str] | None:
"""把种质花期投影到 ref_year(start, end, source, note);无花期返回 None。
结构化区间(bloom_start_date~bloom_end_date)按年际月日投影(桃花期不跨年,
取窗口起始年为参考年);缺区间 fallback 到 flowering_period 档位代表区间。
"""
s, e = g.bloom_start_date, g.bloom_end_date
if s is not None and e is not None:
try:
return (
_date(ref_year, s.month, s.day),
_date(ref_year, e.month, e.day),
"interval",
"结构化区间(年际重复)",
)
except ValueError:
return None
span = _BLOOM_TIER_SPAN.get(g.flowering_period)
if span is None:
return None
try:
(sm, sd), (em, ed) = span
return (
_date(ref_year, sm, sd),
_date(ref_year, em, ed),
"tier",
"花期档位估计(粗粒度)",
)
except ValueError:
return None
class PollenService:
"""花粉档案 模块服务层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def _check_code(self, code: str, exclude_id: int | None = None) -> None:
if _is_blank(code):
raise CustomException(msg="花粉批次号不能为空")
conditions = [PollenModel.lot_code == code.strip(), PollenModel.is_deleted.is_(False)]
if exclude_id is not None:
conditions.append(PollenModel.id != exclude_id)
result = await self.db.execute(select(func.count()).select_from(PollenModel).where(*conditions))
if result.scalar() or 0:
raise CustomException(msg=f"花粉批次号 {code} 已存在", status_code=409)
async def _attach_fk_labels(self, items: list[PollenOutSchema]) -> None:
if not items:
return
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.male_tree_no = ref_map.get(getattr(it, "male_tree_id"))
async def detail(self, id: int) -> PollenOutSchema:
obj = await BreedingPollenCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="该花粉批次不存在")
out = PollenOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def get_list(
self,
search: PollenQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[PollenOutSchema]:
obj_list = await BreedingPollenCRUD(self.auth, self.db).get_list(
search=search_to_dict(search), order_by=order_by
)
outs = [PollenOutSchema.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: PollenQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[PollenOutSchema]:
offset = (page_no - 1) * page_size
result = await BreedingPollenCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=PollenOutSchema,
)
await self._attach_fk_labels(result.items)
return result
async def create(self, data: PollenCreateSchema) -> PollenOutSchema:
await self._check_code(data.lot_code)
await assert_parents_exist(
self.db,
[
(TreeModel, data.male_tree_id, '采集父本树'),
],
)
obj = await BreedingPollenCRUD(self.auth, self.db).create(data=data)
out = PollenOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def update(self, id: int, data: PollenUpdateSchema) -> PollenOutSchema:
obj = await BreedingPollenCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="更新失败,该花粉批次不存在")
if not _is_blank(data.lot_code):
await self._check_code(data.lot_code, exclude_id=id)
await assert_parents_exist(
self.db,
[
(TreeModel, data.male_tree_id, '采集父本树'),
],
)
obj = await BreedingPollenCRUD(self.auth, self.db).update(id=id, data=data)
out = PollenOutSchema.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 BreedingPollenCRUD(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 BreedingPollenCRUD(self.auth, self.db).delete(ids=ids)
async def list_options(self) -> list[dict[str, Any]]:
"""供前端下拉选择使用:返回 [{value, label}]label=批次号(+父本树号)。"""
obj_list = await BreedingPollenCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
tree_ids = {o.male_tree_id for o in obj_list if o.male_tree_id}
tree_map: dict[int, str] = {}
if tree_ids:
refs = await BreedingTreeCRUD(self.auth, self.db).get_list(search={"id": ("in", list(tree_ids))})
tree_map = {r.id: getattr(r, "tree_no") for r in refs}
result = []
for o in obj_list:
label = o.lot_code
tn = tree_map.get(o.male_tree_id)
if tn:
label = f"{label}{tn}"
result.append({"value": o.id, "label": label})
return result
async def available_lots(self, date_on: _date | None = None) -> list[dict[str, Any]]:
"""授粉窗口计划:返回指定日期(缺省今天)在有效期内、可用的花粉批次。
窗口 = [collect_date, expiry_date],两日齐备且日期落在窗口内才可用。
"""
ref = date_on or _date.today()
lots = await BreedingPollenCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
result = []
for o in lots:
if o.collect_date is None or o.expiry_date is None:
continue
if o.collect_date <= ref <= o.expiry_date:
label = f"{o.lot_code}{o.collect_date}~{o.expiry_date}"
if o.viability_pct is not None:
label = f"{label} 活力{float(o.viability_pct):g}%"
result.append({
"value": o.id,
"label": label,
"collect_date": o.collect_date,
"expiry_date": o.expiry_date,
"viability_pct": float(o.viability_pct) if o.viability_pct is not None else None,
})
return result
async def window_plan(self, window_start: _date, window_end: _date) -> dict[str, Any]:
"""授粉窗口规划:花期数据 × bre_pollen 库存映射到 [window_start, window_end]。
纯计算不落库。花期按年际月日投影到窗口起始年(桃花期 3-4 月,单年窗口假设);
每株窗口内花期种质给出可用花粉覆盖三态:
covered=整段花期有花粉批次覆盖 / partial=部分覆盖 / none=无覆盖(需采粉贮藏或同期授粉)。
库存侧给出窗口内批次状态(full/expiring/entering/expired/not_started)。
"""
if window_end < window_start:
raise CustomException(msg="窗口结束不能早于窗口起始")
ref_year = window_start.year
gs = await BreedingGermplasmCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
lots = await BreedingPollenCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
lot_list = [o for o in lots if o.collect_date is not None and o.expiry_date is not None]
status_msg = {
"covered": "整段花期有可用花粉批次,可直接授粉",
"partial": "花期仅部分覆盖可用花粉批次,注意贮藏量/有效期衔接",
"none": "花期无可用花粉批次,需采粉贮藏或安排同期开花父本授粉",
}
blooming: list[dict[str, Any]] = []
n_need_pollen = 0
for g in gs:
proj = _project_bloom(g, ref_year)
if proj is None:
continue
bs, be, src, note = proj
if be < window_start or bs > window_end:
continue
covers: list[dict[str, Any]] = []
status = "none"
for o in lot_list:
if o.expiry_date < bs or o.collect_date > be:
continue
full = o.collect_date <= bs and o.expiry_date >= be
covers.append({
"lot_id": o.id,
"lot_code": o.lot_code,
"collect_date": o.collect_date,
"expiry_date": o.expiry_date,
"viability_pct": float(o.viability_pct) if o.viability_pct is not None else None,
"covers_full": full,
})
if full:
status = "covered"
elif status == "none":
status = "partial"
if status == "none":
n_need_pollen += 1
blooming.append({
"germplasm_id": g.id,
"name": g.cultivar_name,
"bloom_start": bs,
"bloom_end": be,
"bloom_source": src,
"bloom_note": note,
"pollen_status": status,
"message": status_msg[status],
"pollen_lots": covers,
})
inventory: list[dict[str, Any]] = []
for o in lot_list:
if o.expiry_date < window_start:
st, msg = "expired", "窗口起始前已失效"
elif o.collect_date > window_end:
st, msg = "not_started", "窗口结束前尚未采集"
elif o.collect_date <= window_start and o.expiry_date >= window_end:
st, msg = "full", "全程覆盖窗口"
elif o.expiry_date < window_end:
st, msg = "expiring", "窗口内到期(注意窗口后半段)"
else:
st, msg = "entering", "窗口内采集(窗口前半段不可用)"
inventory.append({
"lot_id": o.id,
"lot_code": o.lot_code,
"collect_date": o.collect_date,
"expiry_date": o.expiry_date,
"viability_pct": float(o.viability_pct) if o.viability_pct is not None else None,
"status": st,
"message": msg,
})
return {
"window_start": window_start,
"window_end": window_end,
"n_germplasm_total": len(gs),
"n_blooming": len(blooming),
"n_need_pollen": n_need_pollen,
"blooming": blooming,
"n_lots_total": len(lot_list),
"inventory": inventory,
}
@staticmethod
def batch_export(obj_list: list[dict[str, Any]]) -> bytes:
mapping_dict = {
"lot_code": "花粉批次号",
"male_tree_no": "采集父本树",
"source_type": "来源类型",
"source_desc": "来源说明",
"collect_date": "采集日期",
"collect_method": "采集方式",
"quantity": "采集量",
"storage_method": "贮藏方式",
"viability_method": "活力测定方法",
"viability_pct": "花粉活力%",
"viability_test_date": "活力测定日期",
"expiry_date": "有效期",
"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 = {
"花粉批次号": "lot_code",
"采集父本树": "male_tree_id",
"来源类型": "source_type",
"来源说明": "source_desc",
"采集日期": "collect_date",
"采集方式": "collect_method",
"采集量": "quantity",
"贮藏方式": "storage_method",
"活力测定方法": "viability_method",
"花粉活力%": "viability_pct",
"活力测定日期": "viability_test_date",
"有效期": "expiry_date",
"备注": "remark",
}
try:
contents = await file.read()
rows = ExcelUtil.read_excel_to_dicts(contents)
await file.close()
if not rows:
raise CustomException(msg="导入文件为空")
missing_headers = [h for h in header_dict if h not in rows[0]]
if missing_headers:
raise CustomException(msg=f"导入文件缺少必要的列: {', '.join(missing_headers)}")
tree_refs = await BreedingTreeCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
tree_id_map = {getattr(r, "tree_no"): r.id for r in tree_refs}
mapped_rows = []
for row in rows:
mapped_rows.append({en: row.get(ch) for ch, en in header_dict.items()})
required_fields = ["lot_code"]
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 = BreedingPollenCRUD(self.auth, self.db)
for i, row in enumerate(mapped_rows, start=1):
try:
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
fields = {
"lot_code": _none_if_blank(row.get("lot_code")),
"male_tree_id": male_tree_id_val,
"source_type": _none_if_blank(row.get("source_type")) or "tree",
"source_desc": _none_if_blank(row.get("source_desc")),
"collect_date": _to_date(row.get("collect_date")),
"collect_method": _none_if_blank(row.get("collect_method")),
"quantity": _none_if_blank(row.get("quantity")),
"storage_method": _none_if_blank(row.get("storage_method")) or "fridge",
"viability_method": _none_if_blank(row.get("viability_method")),
"viability_pct": _to_float(row.get("viability_pct")),
"viability_test_date": _to_date(row.get("viability_test_date")),
"expiry_date": _to_date(row.get("expiry_date")),
"remark": _none_if_blank(row.get("remark")),
}
create_data = PollenCreateSchema(**fields)
await self._check_code(create_data.lot_code)
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,
)