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, )