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

390 lines
18 KiB
Python
Raw Permalink 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 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 app.utils.number_gen import NumberGenService
from .crud import BreedingSeedlingCRUD
from .schema import (
SeedlingCreateSchema,
SeedlingOutSchema,
SeedlingQueryParam,
SeedlingUpdateSchema,
)
from app.api.v1.module_bre.cross_combination.crud import BreedingCrossCombinationCRUD
from app.api.v1.module_bre.personnel.crud import BreedingPersonnelCRUD
from app.api.v1.module_bre.seed_treatment.crud import BreedingSeedTreatmentCRUD
from app.api.v1.module_bre.seed_lot.crud import BreedingSeedLotCRUD
from app.api.v1.module_bre.seed_lot.service import adjust_used
from app.core.base_crud import assert_no_children, assert_parents_exist
from app.core.bre_audit_ctx import bre_audit_suppress
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.planting.model import PlantingModel
from app.api.v1.module_bre.seed_lot.model import SeedLotModel
from app.api.v1.module_bre.treatment.model import TreatmentModel
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 SeedlingService:
"""育苗管理 模块服务层"""
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
self.auth = auth
self.db = db
async def _attach_fk_labels(self, items: list[SeedlingOutSchema]) -> None:
if not items:
return
crud = BreedingSeedlingCRUD(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"))
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"))
treatment_id_ids = {getattr(it, "treatment_id") for it in items if getattr(it, "treatment_id")}
if treatment_id_ids:
refs = await BreedingSeedTreatmentCRUD(self.auth, self.db).get_list(search={"id": ("in", list(treatment_id_ids))})
ref_map = {r.id: getattr(r, "treatment_method") for r in refs}
for it in items:
it.treatment_batch_no = ref_map.get(getattr(it, "treatment_id"))
lot_ids = {getattr(it, "seed_lot_id") for it in items if getattr(it, "seed_lot_id")}
if lot_ids:
refs = await BreedingSeedLotCRUD(self.auth, self.db).get_list(search={"id": ("in", list(lot_ids))})
ref_map = {r.id: getattr(r, "lot_code") for r in refs}
for it in items:
it.lot_code = ref_map.get(getattr(it, "seed_lot_id"))
async def detail(self, id: int) -> SeedlingOutSchema:
obj = await BreedingSeedlingCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="该育苗不存在")
out = SeedlingOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def get_list(
self,
search: SeedlingQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> list[SeedlingOutSchema]:
obj_list = await BreedingSeedlingCRUD(self.auth, self.db).get_list(
search=search_to_dict(search), order_by=order_by
)
outs = [SeedlingOutSchema.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: SeedlingQueryParam | None = None,
order_by: list[dict[str, str]] | None = None,
) -> PageResultSchema[SeedlingOutSchema]:
offset = (page_no - 1) * page_size
result = await BreedingSeedlingCRUD(self.auth, self.db).page(
offset=offset,
limit=page_size,
order_by=order_by or [{"id": "asc"}],
search=search_to_dict(search, {}),
out_schema=SeedlingOutSchema,
)
await self._attach_fk_labels(result.items)
return result
async def create(self, data: SeedlingCreateSchema) -> SeedlingOutSchema:
# 默认阶段为"播种"
if not data.stage:
data.stage = "1"
# 自动生成育苗批次号:YP-{组合ID}-{播种日期YYYYMMDD}-{当日序号},原子取号
if not data.batch_no and data.combination_id:
sowing = (data.sowing_date or "")[:10].replace("-", "")
prefix = f"YP-{data.combination_id}-{sowing}"
async def _seed() -> int | None:
existing = await BreedingSeedlingCRUD(self.auth, self.db).get_list(
search={"combination_id": ("eq", data.combination_id), "sowing_date": ("like", (data.sowing_date or "")[:10])}
)
seq = 0
for o in existing:
tail = (o.batch_no or "").rsplit("-", 1)[-1]
if tail.isdigit():
seq = max(seq, int(tail))
return seq + 1
seq = await NumberGenService(self.db).next_seq(
f"seedling:{data.combination_id}:{sowing}", seed_fn=_seed
)
data.batch_no = f"{prefix}-{seq:02d}"
await assert_parents_exist(
self.db,
[
(CrossCombinationModel, data.combination_id, '杂交组合'),
(TreatmentModel, data.treatment_id, '种子处理'),
(SeedLotModel, data.seed_lot_id, '种子批'),
(PersonnelModel, data.bre_personnel_id, '育种人员'),
],
)
# 选择强度链:取用粒数累加(按出苗数近似,规格 §3.13)
await adjust_used(self.db, data.seed_lot_id, data.seedling_count or 0)
obj = await BreedingSeedlingCRUD(self.auth, self.db).create(data=data)
out = SeedlingOutSchema.model_validate(obj)
await self._attach_fk_labels([out])
return out
async def update(self, id: int, data: SeedlingUpdateSchema) -> SeedlingOutSchema:
obj = await BreedingSeedlingCRUD(self.auth, self.db).get(id=id)
if not obj:
raise CustomException(msg="更新失败,该育苗不存在")
await assert_parents_exist(
self.db,
[
(CrossCombinationModel, data.combination_id, '杂交组合'),
(TreatmentModel, data.treatment_id, '种子处理'),
(SeedLotModel, data.seed_lot_id, '种子批'),
(PersonnelModel, data.bre_personnel_id, '育种人员'),
],
)
# 选择强度链:出苗数/种子批变更时同步 used_countupdate 传 None 视为保持原值)
old_lot = obj.seed_lot_id
old_count = obj.seedling_count or 0
new_lot = data.seed_lot_id if data.seed_lot_id is not None else old_lot
new_count = data.seedling_count if data.seedling_count is not None else old_count
if old_lot is not None and old_lot != new_lot:
await adjust_used(self.db, old_lot, -old_count)
if new_lot is not None:
await adjust_used(self.db, new_lot, new_count - (old_count if new_lot == old_lot else 0))
obj = await BreedingSeedlingCRUD(self.auth, self.db).update(id=id, data=data)
out = SeedlingOutSchema.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 BreedingSeedlingCRUD(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 assert_no_children(
self.db,
ids,
[
(PlantingModel, 'seedling_id', '定植'),
],
)
# 选择强度链:删除/回收育苗时回退种子批已用粒数
for o in objs:
if getattr(o, 'seed_lot_id'):
await adjust_used(self.db, o.seed_lot_id, -(o.seedling_count or 0))
await BreedingSeedlingCRUD(self.auth, self.db).delete(ids=ids)
async def list_options(self) -> list[dict[str, Any]]:
"""供前端下拉选择使用:返回 [{value, label}]。"""
obj_list = await BreedingSeedlingCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
return [{"value": o.id, "label": o.sowing_date} for o in obj_list]
@staticmethod
def batch_export(obj_list: list[dict[str, Any]]) -> bytes:
mapping_dict = {
"combination_name": "杂交组合",
"treatment_batch_no": "来源处理方式",
"batch_no": "育苗批次号",
"sowing_date": "播种日期",
"tray_no": "穴盘号",
"nursery": "苗圃",
"seedling_count": "出苗数",
"emergence_date": "出苗日期",
"strong_seedling_date": "壮苗日期",
"strong_seedling_count": "壮苗数",
"stage": "阶段(1播种/2出苗/3壮苗)",
"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",
"种子批": "seed_lot_id",
"来源处理方式": "treatment_method",
"育苗批次号": "batch_no",
"播种日期": "sowing_date",
"穴盘号": "tray_no",
"苗圃": "nursery",
"出苗数": "seedling_count",
"出苗日期": "emergence_date",
"壮苗日期": "strong_seedling_date",
"壮苗数": "strong_seedling_count",
"阶段": "stage",
"育苗人": "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}
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}
treatment_refs = await BreedingSeedTreatmentCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
treatment_method_map = {getattr(r, "treatment_method"): r.id for r in treatment_refs}
seed_lot_refs = await BreedingSeedLotCRUD(self.auth, self.db).get_list(order_by=[{"id": "asc"}])
seed_lot_id_map = {getattr(r, "lot_code"): r.id for r in seed_lot_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
# 批量导入:抑制逐行审计,仅导入结束汇总记一条 IMPORT 审计行
with bre_audit_suppress():
for i, row in enumerate(mapped_rows, start=1):
try:
await self._import_one(row, combination_id_map, seed_lot_id_map,
bre_personnel_id_map, treatment_method_map)
success_count += 1
except Exception as e:
error_msgs.append(f"第{i}行: {e!s}")
continue
from app.api.v1.module_bre.audit.service import AuditLogService
await AuditLogService.write(
self.db,
entity_type="bre_seedling",
entity_id=None,
action="IMPORT",
new_value=f"valid={success_count}, invalid={len(error_msgs)}",
created_id=self.auth.user.id if self.auth.user.id else None,
)
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}")
async def _import_one(
self,
row: dict[str, Any],
combination_id_map: dict[str, int],
seed_lot_id_map: dict[str, int],
bre_personnel_id_map: dict[str, int],
treatment_method_map: dict[str, int],
) -> None:
"""导入单行:映射外键 → 走服务层 create(记 used_count + 批次号自动生成 + 父表校验)。"""
combination_id_val = combination_id_map.get(str(row.get("combination_id")).strip()) if not _is_blank(row.get("combination_id")) else None
seed_lot_id_val = seed_lot_id_map.get(str(row.get("seed_lot_id")).strip()) if not _is_blank(row.get("seed_lot_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
treatment_id_val = treatment_method_map.get(str(row.get("treatment_method")).strip()) if not _is_blank(row.get("treatment_method")) else None
fields = {
"combination_id": combination_id_val,
"seed_lot_id": seed_lot_id_val,
"treatment_id": treatment_id_val,
"batch_no": _none_if_blank(row.get("batch_no")),
"sowing_date": _none_if_blank(row.get("sowing_date")),
"tray_no": _none_if_blank(row.get("tray_no")),
"nursery": _none_if_blank(row.get("nursery")),
"seedling_count": _to_int(row.get("seedling_count")),
"emergence_date": _none_if_blank(row.get("emergence_date")),
"strong_seedling_date": _none_if_blank(row.get("strong_seedling_date")),
"strong_seedling_count": _to_int(row.get("strong_seedling_count")),
"stage": _none_if_blank(row.get("stage")) or "1",
"bre_personnel_id": bre_personnel_id_val,
"remark": _none_if_blank(row.get("remark")),
}
create_data = SeedlingCreateSchema(**fields)
await self.create(data=create_data)
@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,
)