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 .crud import BreedingTraitObservationCRUD from .schema import ( TraitObservationCreateSchema, TraitObservationOutSchema, TraitObservationQueryParam, TraitObservationUpdateSchema, ) from app.api.v1.module_bre.tree_evaluation.crud import BreedingTreeEvaluationCRUD from app.api.v1.module_bre.tree.crud import BreedingTreeCRUD from app.api.v1.module_bre.cross_combination.crud import BreedingCrossCombinationCRUD from app.api.v1.module_bre.trait.crud import BreedingTraitCRUD from app.core.base_crud import assert_no_children, assert_parents_exist from app.api.v1.module_bre.cross_combination.model import CrossCombinationModel from app.api.v1.module_bre.trait.model import TraitModel from app.api.v1.module_bre.tree_evaluation.model import TreeEvaluationModel from app.api.v1.module_bre.tree.model import TreeModel 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 TraitObservationService: """性状观测 模块服务层""" def __init__(self, auth: AuthSchema, db: AsyncSession) -> None: self.auth = auth self.db = db async def _assert_numeric_range(self, trait_id: int | None, value_numeric: float | None) -> None: """数值观测必须在性状 valid_min/valid_max 区间内,越界拒绝入库。""" if trait_id is None or value_numeric is None: return trait = await BreedingTraitCRUD(self.auth, self.db).get(id=trait_id) if not trait: return # assert_parents_exist 已拦截不存在的性状,这里仅兜底 if trait.valid_min is not None and value_numeric < float(trait.valid_min): raise CustomException(msg=f"数值 {value_numeric} 低于性状有效下限 {trait.valid_min}") if trait.valid_max is not None and value_numeric > float(trait.valid_max): raise CustomException(msg=f"数值 {value_numeric} 超出性状有效上限 {trait.valid_max}") async def _assert_stage_gate(self, data: Any, exclude_id: int | None = None, eff_stage_override: str | None = None) -> None: """成株性状门禁(A6):实生苗(童期)单株不可录成株(evaluation)性状。 判定阶段取观测自带 stage,缺省继承 bre_trait.stage;观测显式 stage=juvenile 视为童期评估可放行。单株已选优晋级(sp/ap/line…)或非实生苗不受限。 update 走 eff_stage_override(data.stage 或既有行 stage),避免部分更新误判。 """ tree_id = getattr(data, "tree_id", None) trait_id = getattr(data, "trait_id", None) if not tree_id or not trait_id: return tree = await BreedingTreeCRUD(self.auth, self.db).get(id=tree_id) if not tree or (getattr(tree, "stage", None) or "") != "seedling": return trait = await BreedingTraitCRUD(self.auth, self.db).get(id=trait_id) if not trait: return eff_stage = eff_stage_override or getattr(data, "stage", None) or trait.stage or "evaluation" if eff_stage == "evaluation": tree_no = getattr(tree, "tree_no", None) or f"#{tree.id}" raise CustomException( msg=f"实生苗(童期)单株 {tree_no} 不可记录成株性状「{trait.trait_name}」;" f"请将观测阶段改为 juvenile 评估,或先将单株选优晋级(sp/ap/line)" ) async def _attach_fk_labels(self, items: list[TraitObservationOutSchema]) -> None: if not items: return crud = BreedingTraitObservationCRUD(self.auth, self.db) evaluation_id_ids = {getattr(it, "evaluation_id") for it in items if getattr(it, "evaluation_id")} if evaluation_id_ids: refs = await BreedingTreeEvaluationCRUD(self.auth, self.db).get_list(search={"id": ("in", list(evaluation_id_ids))}) ref_map = {r.id: getattr(r, "evaluate_date") for r in refs} for it in items: it.evaluation_name = ref_map.get(getattr(it, "evaluation_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")) 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")) trait_id_ids = {getattr(it, "trait_id") for it in items if getattr(it, "trait_id")} if trait_id_ids: refs = await BreedingTraitCRUD(self.auth, self.db).get_list(search={"id": ("in", list(trait_id_ids))}) ref_map = {r.id: getattr(r, "trait_name") for r in refs} for it in items: it.trait_name = ref_map.get(getattr(it, "trait_id")) async def detail(self, id: int) -> TraitObservationOutSchema: obj = await BreedingTraitObservationCRUD(self.auth, self.db).get(id=id) if not obj: raise CustomException(msg="该性状观测不存在") out = TraitObservationOutSchema.model_validate(obj) await self._attach_fk_labels([out]) return out async def get_list( self, search: TraitObservationQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> list[TraitObservationOutSchema]: obj_list = await BreedingTraitObservationCRUD(self.auth, self.db).get_list( search=search_to_dict(search), order_by=order_by ) outs = [TraitObservationOutSchema.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: TraitObservationQueryParam | None = None, order_by: list[dict[str, str]] | None = None, ) -> PageResultSchema[TraitObservationOutSchema]: offset = (page_no - 1) * page_size result = await BreedingTraitObservationCRUD(self.auth, self.db).page( offset=offset, limit=page_size, order_by=order_by or [{"id": "asc"}], search=search_to_dict(search, {}), out_schema=TraitObservationOutSchema, ) await self._attach_fk_labels(result.items) return result async def create(self, data: TraitObservationCreateSchema) -> TraitObservationOutSchema: await assert_parents_exist( self.db, [ (TreeEvaluationModel, data.evaluation_id, '单株评价'), (TreeModel, data.tree_id, '单株'), (CrossCombinationModel, data.combination_id, '杂交组合'), (TraitModel, data.trait_id, '性状'), ], ) # 缺省继承性状发育阶段(schema 注释约定):data.stage 留空时以 bre_trait.stage 回填落库 if data.stage is None and data.trait_id is not None: _trait = await BreedingTraitCRUD(self.auth, self.db).get(id=data.trait_id) if _trait and _trait.stage: data = data.model_copy(update={"stage": _trait.stage}) # 同一次鉴定下同一单株同一性状只允许一条观测(tree_id 为空表示组合级观测,跳过判重) if data.tree_id is not None: dup = await BreedingTraitObservationCRUD(self.auth, self.db).get( evaluation_id=data.evaluation_id, tree_id=data.tree_id, trait_id=data.trait_id ) if dup: raise CustomException(msg="创建失败,该单株在此次鉴定中已存在该性状观测") await self._assert_numeric_range(data.trait_id, data.value_numeric) await self._assert_stage_gate(data) obj = await BreedingTraitObservationCRUD(self.auth, self.db).create(data=data) out = TraitObservationOutSchema.model_validate(obj) await self._attach_fk_labels([out]) return out async def update(self, id: int, data: TraitObservationUpdateSchema) -> TraitObservationOutSchema: obj = await BreedingTraitObservationCRUD(self.auth, self.db).get(id=id) if not obj: raise CustomException(msg="更新失败,该性状观测不存在") await assert_parents_exist( self.db, [ (TreeEvaluationModel, data.evaluation_id, '单株评价'), (TreeModel, data.tree_id, '单株'), (CrossCombinationModel, data.combination_id, '杂交组合'), (TraitModel, data.trait_id, '性状'), ], ) # 更新后 (evaluation_id, tree_id, trait_id) 仍需唯一(未提交的字段取现值) eval_id = data.evaluation_id if data.evaluation_id is not None else obj.evaluation_id tree_id = data.tree_id if data.tree_id is not None else obj.tree_id trait_id = data.trait_id if data.trait_id is not None else obj.trait_id if tree_id is not None: dup = await BreedingTraitObservationCRUD(self.auth, self.db).get( evaluation_id=eval_id, tree_id=tree_id, trait_id=trait_id ) if dup and dup.id != id: raise CustomException(msg="更新失败,该单株在此次鉴定中已存在该性状观测") await self._assert_numeric_range( data.trait_id if data.trait_id is not None else obj.trait_id, data.value_numeric if data.value_numeric is not None else obj.value_numeric, ) await self._assert_stage_gate( data, exclude_id=id, eff_stage_override=(data.stage or obj.stage) ) obj = await BreedingTraitObservationCRUD(self.auth, self.db).update(id=id, data=data) out = TraitObservationOutSchema.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 BreedingTraitObservationCRUD(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 BreedingTraitObservationCRUD(self.auth, self.db).delete(ids=ids) async def list_options(self) -> list[dict[str, Any]]: """供前端下拉选择使用:返回 [{value, label}]。""" obj_list = await BreedingTraitObservationCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}]) return [{"value": o.id, "label": o.value_text} for o in obj_list] @staticmethod def batch_export(obj_list: list[dict[str, Any]]) -> bytes: mapping_dict = { "evaluation_name": "所属鉴定", "tree_name": "单株", "combination_name": "杂交组合", "trait_name": "性状", "evaluate_year": "观测年份", "value_numeric": "数值", "value_text": "文本值", "value_date": "日期值", "stage": "阶段", "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 = { "所属鉴定": "evaluation_id", "单株": "tree_id", "杂交组合": "combination_id", "性状": "trait_id", "观测年份": "evaluate_year", "数值": "value_numeric", "文本值": "value_text", "日期值": "value_date", "阶段": "stage", "备注": "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)}") evaluation_id_refs = await BreedingTreeEvaluationCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}]) evaluation_id_map = {getattr(r, "evaluate_date"): r.id for r in evaluation_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} 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} trait_id_refs = await BreedingTraitCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}]) trait_id_map = {getattr(r, "trait_name"): r.id for r in trait_id_refs} trait_valid_map = {r.id: (r.valid_min, r.valid_max) for r in trait_id_refs} mapped_rows = [] for row in rows: mapped_rows.append({en: row.get(ch) for ch, en in header_dict.items()}) required_fields = ["trait_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 = BreedingTraitObservationCRUD(self.auth, self.db) for i, row in enumerate(mapped_rows, start=1): try: evaluation_id_val = evaluation_id_map.get(str(row.get("evaluation_id")).strip()) if not _is_blank(row.get("evaluation_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 combination_id_val = combination_id_map.get(str(row.get("combination_id")).strip()) if not _is_blank(row.get("combination_id")) else None trait_id_val = trait_id_map.get(str(row.get("trait_id")).strip()) if not _is_blank(row.get("trait_id")) else None fields = { "evaluation_id": evaluation_id_val, "tree_id": tree_id_val, "combination_id": combination_id_val, "trait_id": trait_id_val, "evaluate_year": _to_int(row.get("evaluate_year")), "value_numeric": _to_float(row.get("value_numeric")), "value_text": _none_if_blank(row.get("value_text")), "value_date": _none_if_blank(row.get("value_date")), "stage": _none_if_blank(row.get("stage")), "remark": _none_if_blank(row.get("remark")), } if fields["value_numeric"] is not None and trait_id_val is not None: vmin, vmax = trait_valid_map.get(trait_id_val, (None, None)) if vmin is not None and fields["value_numeric"] < float(vmin): error_msgs.append(f"第{i}行: 数值 {fields['value_numeric']} 低于性状有效下限 {vmin}") continue if vmax is not None and fields["value_numeric"] > float(vmax): error_msgs.append(f"第{i}行: 数值 {fields['value_numeric']} 超出性状有效上限 {vmax}") continue create_data = TraitObservationCreateSchema(**fields) if tree_id_val is not None: dup = await crud.get( evaluation_id=evaluation_id_val, tree_id=tree_id_val, trait_id=trait_id_val ) if dup: if update_support: await crud.update(id=dup.id, data=TraitObservationUpdateSchema(**fields)) success_count += 1 else: error_msgs.append(f"第{i}行: 该单株在此次鉴定中已存在该性状观测") continue 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, )